Infinite query with max pages
An infinite query that starts in the middle of a hundred projects, pages
forward and backward, and holds at most three pages: maxPages: 3 drops the
page at the far end whenever a fourth comes in, so the window slides instead
of growing. The page function is told which way each fetch extends the
window. Use it where a list can be entered anywhere and scrolled both ways
while memory stays bounded: a chat history opened at an unread message, a
log viewer jumped to a timestamp, a long timeline of sensor readings.
What to try
- The screen opens on the page at cursor 30:
pageParams=30, and bothhasPreviousPageandhasNextPageare true. - Press Load next twice for
pageParams=30,40,50, then once more: the window slides to40,50,60, stillpages=3, and Project 30 is gone from the rows.lastPagenames the cursor andforward. - Press Load previous: a loading previous pill shows while
isFetchingPreviousPage=true, the window slides back to30,40,50, andlastPagesaysbackward. - Press Refetch: the pages in the window are fetched again, first to last,
and nothing outside it:
lastPageends on the window's last cursor withforward, and each row's fetched time is renewed. - Keep paging one way: at cursor 90
hasNextPage=falseand Load next is disabled; at cursor 0 the same happens to Load previous.
The code
Both directions read their cursor off the page the backend sent, and
maxPages caps the window. The page function's context carries pageParam,
signal and direction:
InfiniteQueryObserverOptions<ProjectSlice, int> projectsWindowQuery(
ShowcaseApi api, {
void Function(int cursor, FetchDirection direction)? onPage,
}) =>
InfiniteQueryObserverOptions<ProjectSlice, int>(
queryKey: projectsWindowKey,
initialPageParam: startCursor,
pageFn: (context) {
onPage?.call(context.pageParam, context.direction);
return api.projectsFrom(
context.pageParam,
limit: pageSize,
signal: context.signal,
);
},
getNextPageParam: (page, _, __, ___) => page.nextId,
getPreviousPageParam: (page, _, __, ___) => page.previousId,
maxPages: windowSize,
);
The screen holds the query as an InfiniteQueryController, created in
initState and disposed with the state, and rebuilds through a
ListenableBuilder:
_window = InfiniteQueryController<ProjectSlice, int, ProjectWindow>(
QueryClientProvider.read(context),
projectsWindowQuery(
api,
onPage: (cursor, direction) => _lastPage = '$cursor ${direction.name}',
),
);
Which end is loading is on the controller, not on the sealed result, so the card's pill switches over both:
trailing: switch (result) {
QueryResult(isLoading: true) => const Pill('loading'),
_ when window.isFetchingPreviousPage => const Pill('loading previous'),
_ when window.isFetchingNextPage => const Pill('loading next'),
_ when window.isRefetching => const Pill('refreshing'),
_ => const SizedBox.shrink(),
},
The whole screen
/// Upstream's `infinite-query-with-max-pages` example: an infinite query that
/// pages in both directions from a cursor in the middle of the data, holding
/// a window of at most three pages. `maxPages: 3` drops the page at the far
/// end whenever a fourth comes in, so paging forward slides the window and
/// paging back slides it again; a refetch re-requests exactly the pages the
/// window holds, first to last, and every row's `fetched` stamp moves.
///
/// The query is an `InfiniteQueryController` created in `initState` and read
/// through a `ListenableBuilder`; the paging half — `hasNextPage`,
/// `isFetchingPreviousPage`, `fetchNextPage` — lives on the controller, not
/// on the sealed result. The page function receives an `InfinitePageContext`,
/// whose `direction` says which end of the window a fetch extends: the screen
/// notes the last one as `lastPage=<cursor> <direction>`. A refetch walks
/// the window first to last, every page `forward`.
///
/// Proofs (widget tests in `test/features/max_pages_test.dart`, end-to-end in
/// `e2e/tests/max_pages.spec.ts`): the screen starts on the page at cursor 30
/// with both directions available; two `Load next` make `pageParams=30,40,50`
/// and a third slides the window to `40,50,60` — still three pages, rows
/// 30–39 gone, rows 60–69 there, one request per cursor; `Load previous`
/// from there slides it back to `30,40,50` with one new request for 30;
/// `Refetch` sends one request per page in the window, bumps `fetches` by one
/// and renews every row's `fetched` stamp; cursor 90 ends the forward
/// direction (`hasNextPage=false`, button disabled) and cursor 0 the backward
/// one; and `lastPage` says `40 forward` after `Load next`, `20 backward`
/// after `Load previous`, and the window's last cursor `forward` after a
/// refetch.
library;
import 'package:flutter/material.dart';
import 'package:query_kit_flutter/query_kit_flutter.dart';
import '../../shared/api.dart';
import '../../shared/chrome.dart';
import '../../shared/debug_strip.dart';
import '../../shared/fact_group.dart';
import '../../shared/feature.dart';
import '../../shared/feature_scaffold.dart';
import '../../shared/models.dart';
import '../../shared/scope.dart';
const Feature maxPagesFeature = Feature(
id: 'max-pages',
title: 'Infinite query with max pages',
summary: 'Pages in both directions, with a window of three.',
upstream: 'infinite-query-with-max-pages',
);
/// The window's cache entry — its own key, so the pagination and load-more
/// screens' project entries are untouched by what happens here.
QueryKey get projectsWindowKey =>
QueryKey(const <Object?>['projects', 'window']);
/// Ten rows per page; the backend has a hundred, ids 0 to 99.
const int pageSize = 10;
/// Where the window starts: in the middle, so there is a page on either side
/// from the first frame on.
const int startCursor = 30;
/// How many pages the window keeps.
const int windowSize = 3;
typedef ProjectWindow = InfiniteData<ProjectSlice, int>;
/// The window's options. The cursors come back with every slice, so the
/// paging functions read them off the page rather than counting. [onPage]
/// hears each page fetch with what the page function was told about it: the
/// cursor and the direction — `forward` for `fetchNextPage`, the first page
/// and every page of a refetch, `backward` for `fetchPreviousPage`.
InfiniteQueryObserverOptions<ProjectSlice, int> projectsWindowQuery(
ShowcaseApi api, {
void Function(int cursor, FetchDirection direction)? onPage,
}) =>
InfiniteQueryObserverOptions<ProjectSlice, int>(
queryKey: projectsWindowKey,
initialPageParam: startCursor,
pageFn: (context) {
onPage?.call(context.pageParam, context.direction);
return api.projectsFrom(
context.pageParam,
limit: pageSize,
signal: context.signal,
);
},
getNextPageParam: (page, _, __, ___) => page.nextId,
getPreviousPageParam: (page, _, __, ___) => page.previousId,
maxPages: windowSize,
);
class MaxPagesScreen extends StatefulWidget {
const MaxPagesScreen({super.key});
State<MaxPagesScreen> createState() => _MaxPagesScreenState();
}
class _MaxPagesScreenState extends State<MaxPagesScreen> {
late final InfiniteQueryController<ProjectSlice, int, ProjectWindow> _window;
/// The last page fetch the page function was asked for, as
/// `<cursor> <direction>`. Written from inside the page function — before
/// the request goes out — and read by the rebuild its answer causes, so no
/// `setState` is needed for it.
String _lastPage = 'none';
void initState() {
super.initState();
// Neither lookup subscribes: the api and the client are fixed for the
// life of the app, and a subscribing lookup is not allowed here anyway.
final api = context.getInheritedWidgetOfExactType<ShowcaseScope>()!.api;
_window = InfiniteQueryController<ProjectSlice, int, ProjectWindow>(
QueryClientProvider.read(context),
projectsWindowQuery(
api,
onPage: (cursor, direction) => _lastPage = '$cursor ${direction.name}',
),
);
}
void dispose() {
_window.dispose();
super.dispose();
}
Widget build(BuildContext context) => FeatureScaffold(
feature: maxPagesFeature,
children: <Widget>[
ListenableBuilder(
listenable: _window,
builder: (context, _) =>
_WindowCard(window: _window, lastPage: _lastPage),
),
QueryDebugStrip(queryKey: projectsWindowKey, label: 'projects'),
],
);
}
/// The window: its facts, the three buttons, and the rows it holds.
class _WindowCard extends StatelessWidget {
const _WindowCard({required this.window, required this.lastPage});
final InfiniteQueryController<ProjectSlice, int, ProjectWindow> window;
/// `<cursor> <direction>` of the last page fetch, off the page context.
final String lastPage;
Widget build(BuildContext context) {
final result = window.value;
final data = result.dataOrNull;
final pages = data?.pages ?? const <ProjectSlice>[];
final pageParams = data?.pageParams ?? const <int>[];
final rows = <Project>[
for (final page in pages) ...page.items,
];
return SectionCard(
title: '$pageSize projects per page, $windowSize pages at most',
trailing: switch (result) {
QueryResult(isLoading: true) => const Pill('loading'),
_ when window.isFetchingPreviousPage => const Pill('loading previous'),
_ when window.isFetchingNextPage => const Pill('loading next'),
_ when window.isRefetching => const Pill('refreshing'),
_ => const SizedBox.shrink(),
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
// Explicit child nodes: a row of buttons and texts folds into one
// semantics node otherwise, and every `key=value` here is read as
// an exact text.
SemanticsGroup(
child: Wrap(
spacing: 12,
runSpacing: 4,
children: <Widget>[
_Fact('pages=${pages.length}'),
_Fact('pageParams=${pageParams.join(',')}'),
_Fact('hasPreviousPage=${window.hasPreviousPage}'),
_Fact('hasNextPage=${window.hasNextPage}'),
_Fact(
'isFetchingPreviousPage=${window.isFetchingPreviousPage}'),
_Fact('isFetchingNextPage=${window.isFetchingNextPage}'),
_Fact('lastPage=$lastPage'),
],
),
),
const SizedBox(height: 12),
SemanticsGroup(
child: Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
FilledButton.tonalIcon(
onPressed:
window.hasPreviousPage && !window.isFetchingPreviousPage
? () => window.fetchPreviousPage().ignore()
: null,
icon: const Icon(Icons.arrow_upward),
label: const Text('Load previous'),
),
FilledButton.tonalIcon(
onPressed: window.hasNextPage && !window.isFetchingNextPage
? () => window.fetchNextPage().ignore()
: null,
icon: const Icon(Icons.arrow_downward),
label: const Text('Load next'),
),
OutlinedButton.icon(
onPressed: result.isFetching
? null
: () => window.refetch().ignore(),
icon: const Icon(Icons.refresh),
label: const Text('Refetch'),
),
],
),
),
const SizedBox(height: 12),
switch (result) {
QueryPending() => const Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
SkeletonBox(height: 20),
SizedBox(height: 4),
SkeletonBox(height: 20),
SizedBox(height: 4),
SkeletonBox(height: 20),
],
),
QueryError(:final error, staleData: null) =>
Notice('$error', error: true),
_ => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (result case QueryError(:final error)) ...<Widget>[
Notice('Fetch failed: $error', error: true),
const SizedBox(height: 8),
],
_Rows(rows: rows),
],
),
},
],
),
);
}
}
class _Fact extends StatelessWidget {
const _Fact(this.text);
final String text;
Widget build(BuildContext context) => Text(
text,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
);
}
/// The rows of every page in the window, in a scroller of their own: the
/// screen's scaffold builds its children lazily, and the strip under thirty
/// rows would otherwise never be built.
///
/// Not a lazy list. Thirty rows are nothing, and a lazy list whose row count
/// grows under a fixed row height keeps its old scroll extent until the next
/// scroll — the rows the new page brought would be unreachable for one
/// frame. With every row in the tree the far end of the window is always
/// there to be found.
class _Rows extends StatelessWidget {
const _Rows({required this.rows});
final List<Project> rows;
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
key: const ValueKey<String>('project-rows'),
height: 240,
decoration: BoxDecoration(
border: Border.all(color: scheme.outlineVariant),
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
for (final project in rows)
SizedBox(
height: 40,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: <Widget>[
Expanded(child: Text(project.name)),
Text(
'fetched ${hhmmss(project.fetchedAt)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
],
),
),
);
}
}
Related
- Guides: Infinite queries
- Upstream: TanStack's React
infinite-query-with-max-pagesexample - Tested by
test/features/max_pages_test.dart(widget) ande2e/tests/max_pages.spec.ts(browser) - View the feature on GitHub