Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Created August 13, 2025 18:52
Show Gist options
  • Save slightfoot/ffddf2dba61293e7512c9506c2c9ede4 to your computer and use it in GitHub Desktop.
Save slightfoot/ffddf2dba61293e7512c9506c2c9ede4 to your computer and use it in GitHub Desktop.
Content Pagination - by Simon Lightfoot :: #HumpdayQandA on 13th August 2025 :: https://www.youtube.com/watch?v=bDtpe7Grdb0
// MIT License
//
// Copyright (c) 2025 Simon Lightfoot
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show SystemChrome, SystemUiMode, SystemUiOverlayStyle;
void main() {
runApp(App());
}
abstract interface class PaginatedContent {
String get id;
}
class Post implements PaginatedContent {
const Post({
required this.id,
required this.content,
});
@override
final String id;
final String content;
}
class Api {
Api() {
_mockData = Map.fromEntries(
List.generate(
45,
(index) {
final post = Post(id: 'item_$index', content: 'Item #$index');
return MapEntry(post.id, post);
},
),
);
}
late Map<String, Post> _mockData;
int _indexOfId(String id) {
return _mockData.keys.toList().indexWhere((key) => key == id);
}
Future<List<Post>> fetchItems(String? cursor, int limit) async {
await Future.delayed(const Duration(seconds: 3));
final start = cursor == null ? 0 : 1 + _indexOfId(cursor);
return _mockData.values.skip(start).take(limit).toList();
}
}
class App extends StatefulWidget {
const App({super.key});
static Api apiOf(BuildContext context) {
return context.findAncestorStateOfType<_AppState>()!.api;
}
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
late final Api api;
@override
void initState() {
super.initState();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
api = Api();
}
@override
Widget build(BuildContext context) {
return AnnotatedRegion(
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
),
child: MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.from(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.white,
brightness: Brightness.dark,
dynamicSchemeVariant: DynamicSchemeVariant.monochrome,
),
useMaterial3: false,
),
home: Home(),
),
);
}
}
class Home extends StatelessWidget {
const Home({super.key});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.grey,
child: PaginatedListView(
fetchItems: App.apiOf(context).fetchItems,
itemBuilder: (BuildContext context, Post post) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0, horizontal: 16.0),
child: AnnotatedRegion(
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
),
child: ListTile(
title: Text(post.content),
tileColor: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
),
),
);
},
),
);
}
}
class PaginatedListView<T extends PaginatedContent> extends StatefulWidget {
const PaginatedListView({
super.key,
required this.fetchItems,
required this.itemBuilder,
this.pageSize = 10,
this.emptyBuilder = defaultEmptyBuilder,
this.errorBuilder = defaultErrorBuilder,
this.loadingBuilder = defaultLoadingBuilder,
});
final Future<List<T>> Function(String? cursor, int limit) fetchItems;
final Widget Function(BuildContext context, T item) itemBuilder;
final int pageSize;
final Widget Function(BuildContext context) emptyBuilder;
final Widget Function(BuildContext context, dynamic error, StackTrace? stackTrace) errorBuilder;
final Widget Function(BuildContext context) loadingBuilder;
static Widget defaultEmptyBuilder(BuildContext context) {
return Center(
child: const Text('Nothing to see here!'),
);
}
static Widget defaultErrorBuilder(BuildContext context, dynamic error, StackTrace? stackTrace) {
return ErrorWidget('$error\n$stackTrace');
}
static Widget defaultLoadingBuilder(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(12.0),
child: Center(
child: const CircularProgressIndicator(),
),
);
}
@override
State<PaginatedListView<T>> createState() => _PaginatedListViewState<T>();
}
class _PaginatedListViewState<T extends PaginatedContent> extends State<PaginatedListView<T>> {
Future<void>? _nextPageFuture;
String? _cursor;
bool _canLoadMore = true;
final _items = <T>[];
dynamic _error;
StackTrace? _stackTrace;
@override
void initState() {
super.initState();
_nextPageFuture ??= _fetchNextPage();
}
Future<void> _fetchNextPage() async {
try {
final items = await widget.fetchItems(_cursor, widget.pageSize);
if (mounted) {
setState(() {
if (items.length < widget.pageSize) {
_canLoadMore = false;
}
_items.addAll(items);
_cursor = items.isEmpty ? null : items.last.id;
_nextPageFuture = null;
});
}
} catch (error, stackTrace) {
if (mounted) {
setState(() {
_error = error;
_stackTrace = stackTrace;
_canLoadMore = false;
_nextPageFuture = null;
});
}
}
}
@override
Widget build(BuildContext context) {
return CustomScrollView(
slivers: [
if (_canLoadMore == false && _items.isEmpty && _error == null)
SliverFillRemaining(
child: widget.emptyBuilder(context),
),
SliverPadding(
padding: MediaQuery.paddingOf(context).copyWith(left: 0.0, right: 0.0),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
childCount: _items.length,
(BuildContext context, int index) {
final item = _items[index];
return widget.itemBuilder(context, item);
},
),
),
),
if (_error != null) //
SliverFillRemaining(
child: widget.errorBuilder(context, _error, _stackTrace),
)
else if (_canLoadMore) //
SliverFillRemaining(
child: Builder(
builder: (BuildContext context) {
_nextPageFuture ??= _fetchNextPage();
return widget.loadingBuilder(context);
},
),
),
],
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment