Pagination part 4
Presentation (Integration)
Summary
This lesson implements the core pagination logic for the featured books list using a ScrollController. It demonstrates how to detect when the user has scrolled to 70% of the list, triggering a fetch for the next page of data. To prevent new data from replacing the existing list, the FeaturedBooksCubit is refactored to accumulate the fetched books in a local list and append new items. Finally, the lesson addresses debounce logic by introducing a loading flag in the widget state and creating specific pagination states to prevent the UI from rebuilding with a full-screen loader. This approach keeps state logic inside the Cubit, maintaining a clean separation of concerns.
Key Ideas
- Pagination is triggered by attaching a listener to a ScrollController and checking if position.pixels exceeds 70% of maxScrollExtent.
- The FetchFeaturedBooksUseCase and HomeRepo are updated to accept a pageNumber parameter for sequential fetching.
- Google Books API utilizes startIndex for pagination, which is calculated dynamically as pageNumber * 10.
- Local isLoading boolean acts as a debounce mechanism inside the widget to prevent duplicate API requests while a fetch is in progress.
- FeaturedBooksCubit maintains an internal List<BookEntity> to accumulate books across multiple sequential requests.
- books.addAll() is used to append newly fetched items to the accumulated list before emitting the success state.
- Distinct states (FeaturedBooksPaginationLoading and FeaturedBooksPaginationFailure) are introduced to handle pagination loading without interrupting the active UI.
Code Snippets
class FeaturedBooksListView extends StatefulWidget {
const FeaturedBooksListView({super.key, required this.books});
final List<BookEntity> books;
@override
State<FeaturedBooksListView> createState() => _FeaturedBooksListViewState();
}
class _FeaturedBooksListViewState extends State<FeaturedBooksListView> {
late final ScrollController _scrollController;
int nextPage = 1;
bool isLoading = false;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
_scrollController.addListener(_scrollListener);
}
void _scrollListener() async {
if (_scrollController.position.pixels >= 0.7 * _scrollController.position.maxScrollExtent) {
if (!isLoading) {
isLoading = true;
await BlocProvider.of<FeaturedBooksCubit>(context).fetchFeaturedBooks(pageNumber: nextPage++);
isLoading = false;
}
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _scrollController,
// ... list view implementation
);
}
}class FeaturedBooksCubit extends Cubit<FeaturedBooksState> {
FeaturedBooksCubit(this.fetchFeaturedBooksUseCase) : super(FeaturedBooksInitial());
final FetchFeaturedBooksUseCase fetchFeaturedBooksUseCase;
List<BookEntity> books = [];
Future<void> fetchFeaturedBooks({int pageNumber = 0}) async {
if (pageNumber == 0) {
emit(FeaturedBooksLoading());
} else {
emit(FeaturedBooksPaginationLoading());
}
var result = await fetchFeaturedBooksUseCase.call(pageNumber);
result.fold(
(failure) {
if (pageNumber == 0) {
emit(FeaturedBooksFailure(failure.errMessage));
} else {
emit(FeaturedBooksPaginationFailure(failure.errMessage));
}
},
(newBooks) {
books.addAll(newBooks);
emit(FeaturedBooksSuccess(books));
}
);
}
}@override
Future<Either<Failure, List<BookEntity>>> fetchFeaturedBooks({int pageNumber = 0}) async {
try {
var data = await apiService.get(
endPoint: 'volumes?Filtering=free-ebooks&q=programming&startIndex=${pageNumber * 10}');
List<BookEntity> books = [];
for (var item in data['items']) {
books.add(BookModel.fromJson(item));
}
return right(books);
} catch (e) {
if (e is DioException) {
return left(ServerFailure.fromDioError(e));
}
return left(ServerFailure(e.toString()));
}
}abstract class FeaturedBooksState {}
class FeaturedBooksInitial extends FeaturedBooksState {}
class FeaturedBooksLoading extends FeaturedBooksState {}
class FeaturedBooksPaginationLoading extends FeaturedBooksState {}
class FeaturedBooksFailure extends FeaturedBooksState {
final String errMessage;
FeaturedBooksFailure(this.errMessage);
}
class FeaturedBooksPaginationFailure extends FeaturedBooksState {
final String errMessage;
FeaturedBooksPaginationFailure(this.errMessage);
}
class FeaturedBooksSuccess extends FeaturedBooksState {
final List<BookEntity> books;
FeaturedBooksSuccess(this.books);
}My Notes
- State Accumulation Decision: The instructor actively debates where to store the accumulated list, eventually settling on the FeaturedBooksCubit. This is the correct Clean Architecture approach for Bloc/Cubit; the UI should purely consume the state, not manipulate it.
- setState Avoidance: isLoading is used as a local boolean without wrapping it in setState. This is intentional and optimal. It acts purely as a synchronous lock inside the _scrollListener without triggering unnecessary UI rebuilds.
- Future<void> in Cubit: [Editor's note] Making fetchFeaturedBooks return a Future<void> is critical. If it were standard void, the await in the UI's scroll listener would resolve instantly, breaking the debounce logic and causing a flood of overlapping API calls.
- UI Resilience: [Editor's note] By emitting FeaturedBooksPaginationLoading, the Cubit signals the UI that a background fetch is occurring. Ensure your BlocBuilder handles this state properly so it continues rendering the old list rather than falling back to a CircularProgressIndicator that replaces the whole screen.