InfiniteQueryBuilder
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
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 :
context: leBuildContextstate: l'InfiniteQueryState<TData, TPageParam>courant (ce que vous avez)controller: unInfiniteQueryControllerexposantfetchNextPage,fetchPreviousPageetrefetch
Props
| Prop | Type | Défaut | Description |
|---|---|---|---|
queryKey | Object (List ou QoraKey) | requis | Clé de cache. La changer dispose l'observer courant et repart de la page 1. |
fetcher | Future<TData> Function(TPageParam pageParam) | requis | Fonction asynchrone qui récupère une seule page pour le paramètre donné. Doit lever une exception en cas d'échec. |
options | InfiniteQueryOptions<TData, TPageParam> | requis | Config de pagination : initialPageParam, getNextPageParam, getPreviousPageParam, maxPages, baseOptions. |
builder | Widget Function(BuildContext, InfiniteQueryState<TData, TPageParam>, InfiniteQueryController<TData, TPageParam>) | requis | Construit l'arbre de widgets depuis l'état courant et le contrôleur de pagination. |
client | QoraClient? | null | Surcharge le client du QoraScope. Utile pour les tests ou les scopes imbriqués. |
enabled | bool | true | Quand 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 :
- Crée un
InfiniteQueryObserversoutenu par leQoraClientduQoraScopele plus proche (ou leclientsurchargé). - S'abonne au stream de l'observer : émet chaque transition d'
InfiniteQueryState. - Appelle
observer.fetch()pour charger la première page (sauf sienabledestfalse).
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 :
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 :
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" :
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 :
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 :
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éthode | Signature | Description |
|---|---|---|
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 :
- Annule l'abonnement au stream.
- Dispose l'
InfiniteQueryObservercourant. - Crée un nouvel observer pour la nouvelle clé.
- 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
- Guide des requêtes infinies : Concepts fondamentaux, machine d'état et stratégies de pagination
- Référence API des requêtes infinies : Référence complète pour tous les types de requêtes infinies et méthodes client
- QoraBuilder : Widget de requête sur une seule page (non paginée)