Usage avec Flutter

InfiniteQueryBuilder

Le widget qui gère les requêtes paginées et expose fetchNextPage, fetchPreviousPage et refetch via une interface de contrôleur stable.

InfiniteQueryBuilder<TData, TPageParam> est la liaison Flutter pour les requêtes infinies (paginées). Il gère le cycle de vie complet de la pagination dans un seul widget : récupération de la première page au montage, abonnement à toutes les transitions d'état, et exposition d'un InfiniteQueryController pour déclencher des chargements de pages supplémentaires depuis des listeners de scroll, des boutons ou un RefreshIndicator.


Utilisation de base : défilement avant

posts_screen.dart
InfiniteQueryBuilder<List<Post>, int>(
  queryKey: ['posts'],
  fetcher: (page) => api.getPosts(page: page),
  options: InfiniteQueryOptions(
    initialPageParam: 1,
    getNextPageParam: (last, all) => last.hasMore ? all.length + 1 : null,
  ),
  builder: (context, state, controller) => switch (state) {
    InfiniteInitial() || InfiniteLoading() =>
      const Center(child: CircularProgressIndicator()),
    InfiniteFailure(:final error) => ErrorWidget('$error'),
    InfiniteSuccess(:final data, :final hasNextPage, :final isFetchingNextPage) =>
      ListView.builder(
        itemCount: data.flatten((p) => p).length + (hasNextPage ? 1 : 0),
        itemBuilder: (ctx, i) {
          final items = data.flatten((p) => p);
          if (i == items.length) {
            if (!isFetchingNextPage) controller.fetchNextPage();
            return const Padding(
              padding: EdgeInsets.all(16),
              child: Center(child: CircularProgressIndicator()),
            );
          }
          return PostCard(items[i]);
        },
      ),
  },
)

Le builder reçoit trois arguments :

  1. context : le BuildContext
  2. state : l'InfiniteQueryState<TData, TPageParam> courant (ce que vous avez)
  3. controller : un InfiniteQueryController exposant fetchNextPage, fetchPreviousPage et refetch

Props

PropTypeDéfautDescription
queryKeyObject (List ou QoraKey)requisClé de cache. La changer dispose l'observer courant et repart de la page 1.
fetcherFuture<TData> Function(TPageParam pageParam)requisFonction asynchrone qui récupère une seule page pour le paramètre donné. Doit lever une exception en cas d'échec.
optionsInfiniteQueryOptions<TData, TPageParam>requisConfig de pagination : initialPageParam, getNextPageParam, getPreviousPageParam, maxPages, baseOptions.
builderWidget Function(BuildContext, InfiniteQueryState<TData, TPageParam>, InfiniteQueryController<TData, TPageParam>)requisConstruit l'arbre de widgets depuis l'état courant et le contrôleur de pagination.
clientQoraClient?nullSurcharge le client du QoraScope. Utile pour les tests ou les scopes imbriqués.
enabledbooltrueQuand false, le fetch de la première page n'est pas déclenché au montage. Le widget observe quand même l'état en cache.

Fonctionnement

Au montage, InfiniteQueryBuilder :

  1. Crée un InfiniteQueryObserver soutenu par le QoraClient du QoraScope le plus proche (ou le client surchargé).
  2. S'abonne au stream de l'observer : émet chaque transition d'InfiniteQueryState.
  3. Appelle observer.fetch() pour charger la première page (sauf si enabled est false).

Quand la requête est invalidée externalement (via client.invalidateInfiniteQuery(key)), l'état se réinitialise à InfiniteInitial. Le builder détecte cela et refetche automatiquement la première page, de sorte que l'UI ne reste jamais vide en attente d'une action manuelle.

Quand queryKey change, l'observer courant est disposé et un nouvel est créé, repartant de initialPageParam.


Patterns

Pull-to-refresh

Encapsuler le scroll view dans un RefreshIndicator et passer controller.refetch directement à onRefresh :

posts_screen.dart
InfiniteQueryBuilder<List<Post>, int>(
  queryKey: ['posts'],
  fetcher: (page) => api.getPosts(page: page),
  options: InfiniteQueryOptions(
    initialPageParam: 1,
    getNextPageParam: (last, all) => last.hasMore ? all.length + 1 : null,
  ),
  builder: (context, state, controller) {
    final items = state is InfiniteSuccess<List<Post>, int>
        ? state.data.flatten((p) => p)
        : <Post>[];
    return RefreshIndicator(
      onRefresh: controller.refetch,
      child: ListView(
        // Toujours scrollable pour que RefreshIndicator puisse toujours être déclenché.
        physics: const AlwaysScrollableScrollPhysics(),
        children: items.map(PostCard.new).toList(),
      ),
    );
  },
)

refetch() refetche toutes les pages actuellement chargées en séquence, en préservant le nombre de pages. Les données existantes restent visibles pendant le rechargement des pages.


Défilement bidirectionnel

Les fils de discussion et les timelines chargent souvent dans les deux directions. Fournir getPreviousPageParam et appeler controller.fetchPreviousPage() quand l'utilisateur défile vers le haut :

chat_screen.dart
InfiniteQueryBuilder<List<Message>, String?>(
  queryKey: ['chat', roomId],
  fetcher: (cursor) => api.getMessages(roomId, cursor: cursor),
  options: InfiniteQueryOptions(
    initialPageParam: null,         // Démarrer depuis le dernier message
    getNextPageParam: (last, all) => last.olderCursor,
    getPreviousPageParam: (first, all) => first.newerCursor,
  ),
  builder: (context, state, controller) => switch (state) {
    InfiniteInitial() || InfiniteLoading() =>
      const Center(child: CircularProgressIndicator()),
    InfiniteFailure(:final error) => ErrorWidget('$error'),
    InfiniteSuccess(
      :final data,
      :final hasPreviousPage,
      :final hasNextPage,
      :final isFetchingPreviousPage,
      :final isFetchingNextPage,
    ) =>
      NotificationListener<ScrollNotification>(
        onNotification: (n) {
          if (n is ScrollStartNotification) {
            if (n.metrics.pixels <= 0 && hasPreviousPage && !isFetchingPreviousPage) {
              controller.fetchPreviousPage(); // Messages plus récents
            } else if (n.metrics.atEdge && n.metrics.pixels > 0 &&
                hasNextPage && !isFetchingNextPage) {
              controller.fetchNextPage(); // Messages plus anciens
            }
          }
          return false;
        },
        child: ListView.builder(
          reverse: true,
          itemCount: data.flatten((p) => p).length,
          itemBuilder: (ctx, i) => MessageBubble(data.flatten((p) => p)[i]),
        ),
      ),
  },
)

Affichage avec pagination fenêtrée

Quand maxPages est défini, InfiniteSuccess.hasPreviousPage devient true une fois que la fenêtre a glissé au-delà de la première page. Afficher un indicateur "défiler vers le haut pour charger les éléments précédents" :

feed_screen.dart
InfiniteQueryBuilder<List<Post>, int>(
  queryKey: ['feed'],
  fetcher: (page) => api.getFeed(page: page),
  options: InfiniteQueryOptions(
    initialPageParam: 1,
    getNextPageParam: (last, all) => last.hasMore ? all.length + 1 : null,
    maxPages: 10,
  ),
  builder: (context, state, controller) {
    if (state is! InfiniteSuccess<List<Post>, int>) {
      return const Center(child: CircularProgressIndicator());
    }
    final items = state.data.flatten((p) => p);
    return CustomScrollView(
      slivers: [
        if (state.hasPreviousPage)
          SliverToBoxAdapter(
            child: TextButton(
              onPressed: state.isFetchingPreviousPage
                  ? null
                  : controller.fetchPreviousPage,
              child: const Text('Charger les posts précédents'),
            ),
          ),
        SliverList.builder(
          itemCount: items.length,
          itemBuilder: (ctx, i) => PostCard(items[i]),
        ),
        if (state.hasNextPage)
          SliverToBoxAdapter(
            child: state.isFetchingNextPage
                ? const Center(child: CircularProgressIndicator())
                : TextButton(
                    onPressed: controller.fetchNextPage,
                    child: const Text('Charger plus'),
                  ),
          ),
      ],
    );
  },
)

Requêtes conditionnelles (enabled)

Différer le fetch jusqu'à ce qu'une dépendance soit prête : par exemple, jusqu'à ce qu'une session utilisateur soit disponible :

user_feed_screen.dart
InfiniteQueryBuilder<List<Post>, int>(
  queryKey: ['feed', userId],
  fetcher: (page) => api.getFeed(userId: userId!, page: page),
  options: InfiniteQueryOptions(
    initialPageParam: 1,
    getNextPageParam: (last, all) => last.hasMore ? all.length + 1 : null,
  ),
  enabled: userId != null,
  builder: (context, state, controller) { ... },
)

Quand enabled passe de false à true (via setState), le fetch de la première page est déclenché automatiquement.


Chargement déclenché par le scroll

Utiliser un listener ScrollController pour déclencher la page suivante quand l'utilisateur approche du bas :

posts_screen.dart
class _PostsScreenState extends State<PostsScreen> {
  final _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    _scrollController.addListener(_onScroll);
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  // Alimenté via un StateSetter ou transmis dans l'arbre de widgets
  InfiniteQueryController<List<Post>, int>? _queryController;

  void _onScroll() {
    final position = _scrollController.position;
    if (position.pixels >= position.maxScrollExtent - 200) {
      _queryController?.fetchNextPage();
    }
  }

  @override
  Widget build(BuildContext context) => InfiniteQueryBuilder<List<Post>, int>(
        queryKey: ['posts'],
        fetcher: (page) => api.getPosts(page: page),
        options: InfiniteQueryOptions(
          initialPageParam: 1,
          getNextPageParam: (last, all) => last.hasMore ? all.length + 1 : null,
        ),
        builder: (context, state, controller) {
          _queryController = controller;
          final items = state is InfiniteSuccess<List<Post>, int>
              ? state.data.flatten((p) => p)
              : <Post>[];
          return ListView.builder(
            controller: _scrollController,
            itemCount: items.length,
            itemBuilder: (ctx, i) => PostCard(items[i]),
          );
        },
      );
}

InfiniteQueryController

Un handle stable vers l'InfiniteQueryObserver sous-jacent. La référence au contrôleur ne change jamais tant que queryKey et client restent identiques, ce qui le rend sûr à capturer dans des listeners de scroll ou des callbacks de boutons.

Méthodes

MéthodeSignatureDescription
fetchNextPage()Future<void>Récupère et ajoute la page suivante. Sans effet si hasNextPage est false ou un fetch est déjà en cours.
fetchPreviousPage()Future<void>Récupère et prépose la page précédente. Nécessite que getPreviousPageParam soit configuré.
refetch()Future<void>Refetche toutes les pages actuellement chargées en séquence. Utile pour le pull-to-refresh.

Comportement lors du changement de clé

Quand queryKey change (didUpdateWidget), InfiniteQueryBuilder :

  1. Annule l'abonnement au stream.
  2. Dispose l'InfiniteQueryObserver courant.
  3. Crée un nouvel observer pour la nouvelle clé.
  4. S'abonne et récupère depuis initialPageParam.

C'est ainsi que le changement de flux (ex. onglets Accueil / Abonnements) est géré automatiquement sans gestion d'état supplémentaire.


Étapes suivantes

Copyright © 2026