main
md 194 lines 5.63 KB
Rendered Raw
1 # Pagination StateNotifier Example
2
3 Use this reference when a project already standardizes on `StateNotifierProvider` or when the user specifically asks for this indexed page-pagination style. For new projects, prefer the main skill's current Riverpod defaults first.
4
5 ## Contents
6
7 - [State](#state)
8 - [Provider and Controller](#provider-and-controller)
9 - [UI Trigger Guidance](#ui-trigger-guidance)
10
11 ## State
12
13 Keep pagination state immutable and handwritten. Track the loaded items, next page, initial load, pagination spinner, error text, and whether more data is available.
14
15 ```dart
16 import 'package:flutter/foundation.dart';
17
18 import '../data/models/article_model.dart';
19
20 class PostPagination {
21 static const pageSize = 10;
22
23 const PostPagination({
24 required this.posts,
25 required this.page,
26 required this.errorMessage,
27 required this.initialLoaded,
28 required this.isPaginationLoading,
29 required this.hasMore,
30 });
31
32 const PostPagination.initial()
33 : posts = const [],
34 page = 1,
35 errorMessage = '',
36 initialLoaded = false,
37 isPaginationLoading = false,
38 hasMore = true;
39
40 final List<ArticleModel> posts;
41 final int page;
42 final String errorMessage;
43 final bool initialLoaded;
44 final bool isPaginationLoading;
45 final bool hasMore;
46
47 bool get hasRefreshError {
48 return errorMessage.isNotEmpty && posts.length <= pageSize;
49 }
50
51 PostPagination copyWith({
52 List<ArticleModel>? posts,
53 int? page,
54 String? errorMessage,
55 bool? initialLoaded,
56 bool? isPaginationLoading,
57 bool? hasMore,
58 }) {
59 return PostPagination(
60 posts: posts ?? this.posts,
61 page: page ?? this.page,
62 errorMessage: errorMessage ?? this.errorMessage,
63 initialLoaded: initialLoaded ?? this.initialLoaded,
64 isPaginationLoading: isPaginationLoading ?? this.isPaginationLoading,
65 hasMore: hasMore ?? this.hasMore,
66 );
67 }
68
69 @override
70 bool operator ==(Object other) {
71 return identical(this, other) ||
72 other is PostPagination &&
73 listEquals(other.posts, posts) &&
74 other.page == page &&
75 other.errorMessage == errorMessage &&
76 other.initialLoaded == initialLoaded &&
77 other.isPaginationLoading == isPaginationLoading &&
78 other.hasMore == hasMore;
79 }
80
81 @override
82 int get hashCode {
83 return Object.hash(
84 Object.hashAll(posts),
85 page,
86 errorMessage,
87 initialLoaded,
88 isPaginationLoading,
89 hasMore,
90 );
91 }
92 }
93 ```
94
95 ## Provider and Controller
96
97 Keep widget APIs out of the controller. Do not import `material.dart` or use `WidgetsBinding` here. Let the provider create the controller and start the first page load.
98
99 ```dart
100 import 'dart:async';
101
102 import 'package:flutter_riverpod/flutter_riverpod.dart';
103
104 import '../data/models/article_model.dart';
105 import '../data/repositories/post_repository.dart';
106 import 'post_pagination.dart';
107
108 final recentPostsControllerProvider =
109 StateNotifierProvider.autoDispose<RecentPostsController, PostPagination>(
110 (ref) {
111 final repository = ref.watch(postRepositoryProvider);
112 final controller = RecentPostsController(repository);
113 unawaited(controller.loadNextPage());
114 return controller;
115 },
116 );
117
118 class RecentPostsController extends StateNotifier<PostPagination> {
119 RecentPostsController(this._repository) : super(const PostPagination.initial());
120
121 final PostRepository _repository;
122
123 bool _isAlreadyLoading = false;
124 int _requestGeneration = 0;
125
126 Future<void> loadNextPage() async {
127 if (_isAlreadyLoading || !state.hasMore) return;
128
129 _isAlreadyLoading = true;
130 final requestGeneration = _requestGeneration;
131 final pageToLoad = state.page;
132 final isFirstPage = pageToLoad == 1;
133
134 state = state.copyWith(
135 errorMessage: '',
136 isPaginationLoading: !isFirstPage,
137 );
138
139 try {
140 final fetched = await _repository.getAllPosts(pageNumber: pageToLoad);
141
142 if (!mounted || requestGeneration != _requestGeneration) return;
143
144 final posts = fetched
145 .map((post) => post.copyWith(heroTag: '${post.link}recents'))
146 .toList();
147
148 state = state.copyWith(
149 posts: [...state.posts, ...posts],
150 page: pageToLoad + 1,
151 initialLoaded: true,
152 isPaginationLoading: false,
153 hasMore: posts.length >= PostPagination.pageSize,
154 );
155 } on Exception {
156 if (!mounted || requestGeneration != _requestGeneration) return;
157
158 state = state.copyWith(
159 errorMessage: 'Fetch Error',
160 initialLoaded: true,
161 isPaginationLoading: false,
162 );
163 } finally {
164 if (requestGeneration == _requestGeneration) {
165 _isAlreadyLoading = false;
166 }
167 }
168 }
169
170 void handleVisibleIndex(int index) {
171 final itemPosition = index + 1;
172 final reachedPageBoundary =
173 itemPosition % PostPagination.pageSize == 0 && itemPosition != 0;
174 final viewedPage = itemPosition ~/ PostPagination.pageSize;
175
176 if (reachedPageBoundary && viewedPage + 1 >= state.page) {
177 loadNextPage();
178 }
179 }
180
181 Future<void> refresh() async {
182 _requestGeneration++;
183 _isAlreadyLoading = false;
184 state = const PostPagination.initial();
185 await loadNextPage();
186 }
187 }
188 ```
189
190 ## UI Trigger Guidance
191
192 Use `handleVisibleIndex` when a list component has a stable visible-index callback. Avoid calling pagination triggers directly from `itemBuilder`; render callbacks can run repeatedly for reasons unrelated to user scroll intent.
193
194 If the UI has only a standard `ListView`, prefer triggering `loadNextPage()` from a scroll notification, scroll controller, or explicit "load more" affordance. Keep the list UI in `views/components/` as `StatelessWidget` components and pass callbacks from the page.