Pagination States part 4
Presentation (Integration)
Summary
This lesson demonstrates how to build a custom skeleton loading indicator for the featured books list to replace a generic circular loader. The instructor constructs a specialized widget (FeaturedBooksListViewLoadingIndicator) that mimics the exact layout of the actual list, using hardcoded empty items (CustomBookImageLoadingIndicator) wrapped in a fading animation (CustomFadingWidget). This approach enhances the user experience by providing a structural preview of the content layout while the Bloc state is still loading data from the repository.
Key Ideas
- Skeleton loaders provide a better user experience than generic spinners by previewing the layout of incoming data.
- To create a skeleton list, duplicate the UI structure of the target ListView.builder and remove data-dependent properties like scroll controllers.
- Hardcode an itemCount (e.g., 15) to fill the screen with placeholder items during the loading state.
- Replace dynamic image widgets (like CachedNetworkImage) with simple, static shapes (like a gray Container) in the placeholder item.
- Wrap the entire skeleton list in a fading animation widget to create a smooth, pulsing visual effect.
- AnimationController instances require an explicit Duration to avoid runtime errors during widget initialization.
Code Snippets
import 'package:flutter/material.dart';
class CustomBookImageLoadingIndicator extends StatelessWidget {
const CustomBookImageLoadingIndicator({super.key});
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 2.6 / 4,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
color: Colors.grey[50],
),
),
);
}
}import 'package:bookly/features/home/presentation/views/widgets/custom_book_image_loading_indicator.dart';
import 'package:bookly/core/widgets/custom_fading_widget.dart';
import 'package:flutter/material.dart';
class FeaturedBooksListViewLoadingIndicator extends StatelessWidget {
const FeaturedBooksListViewLoadingIndicator({super.key});
@override
Widget build(BuildContext context) {
return CustomFadingWidget(
child: SizedBox(
height: MediaQuery.of(context).size.height * .3,
child: ListView.builder(
itemCount: 15,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: CustomBookImageLoadingIndicator(),
);
},
),
),
);
}
}@override
void initState() {
animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
// ... (animation setup continues)
}if (state is FeaturedBooksPaginationLoading) {
return const FeaturedBooksListViewLoadingIndicator();
}My Notes
The instructor opted to copy-paste the entire ListView.builder configuration from the actual UI into a new "skeleton" widget to maintain identical spacing and dimensions.
- Gotcha: Don't forget to remove the ScrollController from the copy-pasted ListView.builder when creating the loader, as the skeleton doesn't need to trigger pagination events.
- Gotcha: The AnimationController threw a runtime error because the duration was initially omitted. Always define explicit durations for custom animations.
- [Editor's note] While duplicating the UI code is an easy way to get exact dimensions, it can lead to maintenance issues if the core layout changes later (you'd have to remember to update both the real UI and the skeleton widget). A more robust approach in large applications might involve creating a generic skeleton builder or sharing layout parameters via a constants file. Furthermore, the instructor's custom fading approach is lightweight, but utilizing the shimmer package remains the standard industry practice for this design pattern.