When developing mobile apps with Flutter, it is common to need to fetch and display lists of data in a paginated way. To manage data flow and state in Flutter, many developers rely on the BLoC pattern. In this blog post, we'll examine how to utilize a generic BLoC to fetch and display lists in Flutter. This approach offers advantages like code reusability, better maintainability, and cleaner architecture.
Before diving into the specifics of using a generic BLoC, let's briefly understand what it is. BLoC, or Business Logic Component, serves as a design pattern that effectively decouples the business logic from the UI layer in Flutter applications. BloC enables a continuous directional flow of data between applications as per a predefined framework. This adds to the remarkable attributes of BloC. It acts as a mediator between the UI and data layers, handling the business logic and state management.
Generics in programming allow us to create reusable components that can work with different types. By utilizing generics in our BLoC implementation, we can create a flexible and generic solution for handling list fetching.
Reusability: With a generic BLoC, you can easily reuse the same logic and implementation for fetching different types of lists in your application.
Maintainability: By encapsulating the list fetching logic in a separate BLoC class, you can easily maintain and update the codebase without affecting other application parts.
Testability: The generic BLoC can be easily tested in isolation, ensuring the correctness of your list fetching logic.
Scalability: As your application grows, you can extend the generic BLoC to handle more complex list-fetching scenarios without significant modifications to your existing code.
Also read: Enhancing Development Efficiency with Reusable Components
Before beginning the implementation process, let’s add the necessary dependencies to the project's pubspec.yaml file:
1dependencies:
2 flutter:
3 sdk: flutter
4 flutter_bloc: ^8.1.3
We will create a generic BLoC class called FetchListBloc that can handle fetching lists of any type. This class will include methods for fetching data, managing the state, and handling events.
1class FetchListBloc<T> extends Bloc<FetchListEvent, FetchListState<T>> {
2 FetchListBloc({
3 required this.fetchListItems,
4 FetchListState<T>? initialState,
5 Filter? filter,
6 }) : super(initialState ?? FetchListState(filter: filter, items: [])) {
7 on<FetchItems>((event, emit) async {});
8 }
9
10 final Future<FetchedList<T>> Function({
11 required int page,
12 Filter? filter,
13 }) fetchListItems;
14}
15
16enum Status { loading, success, failure, empty }
17
18abstract class Filter {
19 const Filter();
20}
21
22class FetchedList<T> {
23 const FetchedList({
24 required this.nextPage,
25 required this.hasNext,
26 required this.items,
27 this.filter,
28 });
29
30 const FetchedList.empty({
31 this.filter,
32 this.nextPage,
33 this.hasNext = false,
34 this.items = const [],
35 });
36 /// true if the list has items on the next page (should be provided in the API response)
37 final bool hasNext;
38 /// list of fetched items on the current page
39 final List<T> items;
40 /// page number to fetch in the next call (should be provided in the API response)
41 final int? nextPage;
42 /// filters used to fetch the list
43 final Filter? filter;
44
45 FetchedList<T> operator +(FetchedList<T> other) {
46 return FetchedList(
47 hasNext: other.hasNext,
48 nextPage: other.nextPage,
49 items: items + other.items,
50 );
51 }
52
53 FetchedList<T> copyWith({
54 Filter? filter,
55 List<T>? items,
56 bool? hasNext,
57 int? nextPage,
58 }) {
59 return FetchedList(
60 items: items ?? this.items,
61 filter: filter ?? this.filter,
62 hasNext: hasNext ?? this.hasNext,
63 nextPage: nextPage ?? this.nextPage,
64 );
65 }
66}
Let's break down the FetchListBloc class:
Define events and states specific to your list fetching needs. We will have events like FetchItems, which extends the FetchList Event and states like FetchList State<T>. Here we will be managing a single generic state class that will handle all the states like loading, success, failure, and empty.
1abstract class FetchListEvent extends Equatable {
2 const FetchListEvent();
3
4 @override
5 List<Object?> get props => [];
6}
7
8class FetchItems extends FetchListEvent {
9 const FetchItems({this.refresh = false, this.filter});
10 /// whether to fetch the first page of the list (i.e. fetch the list from the start)
11 final bool refresh;
12 /// the filters needed to fetch the list
13 final Filter? filter;
14
15 @override
16 List<Object?> get props => [refresh, filter];
17}
18
19class FetchListState<T> extends Equatable {
20 const FetchListState({
21 required this.items,
22 this.error,
23 this.filter,
24 this.nextPage = 1,
25 this.hasNext = false,
26 this.status = Status.loading,
27 this.paginationStatus = Status.empty,
28 });
29 /// true if the list has items on the next page (should be provided in the API response)
30 final bool hasNext;
31 /// page number to fetch in the next call (should be provided in the API response)
32 final int nextPage;
33 /// list of fetched items
34 final List<T> items;
35 /// API call status of the first page
36 final Status status;
37 /// holds the error for the latest API call if any
38 final Object? error;
39 /// filters used to fetch the list
40 final Filter? Filter;
41 /// API call status of the latest subsequent page
42 final Status paginationStatus;
43
44 String get errorMessage {
45 if (error is Exception) return error.toString();
46
47 return "Something went wrong";
48 }
49
50 FetchListState<T> copyWith({
51 int? nextPage,
52 bool? hasNext,
53 Object? error,
54 Status? status,
55 List<T>? items,
56 Filter? filter,
57 Status? paginationStatus,
58 }) {
59 return FetchListState<T>(
60 items: items ?? this.items,
61 error: error ?? this.error,
62 filter: filter ?? this.filter,
63 status: status ?? this.status,
64 hasNext: hasNext ?? this.hasNext,
65 nextPage: nextPage ?? this.nextPage,
66 paginationStatus: paginationStatus ?? this.paginationStatus,
67 );
68 }
69
70 @override
71 List<Object?> get props => [
72 items,
73 error,
74 filter,
75 status,
76 hasNext,
77 nextPage,
78 paginationStatus,
79 ];
80}
Inside the FetchListBloc class, implement the necessary logic to handle events, update the state, and fetch the list data.
1class FetchListBloc<T> extends Bloc<FetchListEvent, FetchListState<T>> {
2 FetchListBloc({
3 required this.fetchListItems,
4 FetchListState<T>? initialState,
5 Filter? filter,
6 }) : super(initialState ?? FetchListState(filter: filter, items: [])) {
7 on<FetchItems>(
8 (event, emit) async {
9 final newFilter = event.filter ?? state.filter;
10 final reset = event.refresh || event.filter != null;
11
12 if (reset) {
13 // update the filter to prevent any logical errors
14 emit(
15 state.copyWith(
16 nextPage: 1,
17 filter: newFilter,
18 status: Status.loading,
19 ),
20 );
21 }
22
23 // update the state to loading
24 if (state.status != Status.loading) {
25 emit(state.copyWith(paginationStatus: Status.loading));
26 }
27
28 try {
29 // fetch the list
30 final fetchedList =
31 await fetchListItems(page: state.nextPage, filter: newFilter);
32
33 if (isClosed) return;
34
35 // update the state as empty but don't make the existing list empty
36 if (fetchedList.items.isEmpty) {
37 emit(
38 state.copyWith(
39 status: Status.empty,
40 paginationStatus: Status.empty,
41 filter: fetchedList.filter ?? newFilter,
42 ),
43 );
44 return;
45 }
46
47 // update the state with the list data
48 emit(
49 FetchListState(
50 status: Status.success,
51 hasNext: fetchedList.hasNext,
52 nextPage: fetchedList.nextPage ?? 1,
53 filter: fetchedList.filter ?? newFilter,
54 items:
55 reset ? fetchedList.items : state.items + fetchedList.items,
56 ),
57 );
58 } catch (error, stackTrace) {
59 // update the state as error
60 if (state.items.isEmpty || reset) {
61 emit(state.copyWith(status: Status.failure, error: error));
62 } else {
63 emit(
64 state.copyWith(paginationStatus: Status.failure, error: error),
65 );
66 }
67 }
68 },
69 );
70
71 // add the event as soon as the bloc is created
72 add(const FetchItems());
73 }
74
75 final Future<FetchedList<T>> Function({
76 required int page,
77 Filter? filter,
78 }) fetchListItems;
79}
To integrate the generic BLoC with the UI layer, we will create a generic widget called Custom Builder which will handle the UI for all the states. Since it’s a generic widget, it can be used to manage not just FetchList Bloc but any BLoC that shares a similar footprint as FetchList Event and FetchList State for its events and states respectively.
1class CustomBuilder<B extends Bloc<E, S>, S, E> extends StatelessWidget {
2 const CustomBuilder({
3 super.key,
4 required this.onRefresh,
5 required this.successWidgetBuilder,
6 this.loadingWidget,
7 this.emptyWidget,
8 this.errorWidget,
9 this.buildWhen,
10 this.listener,
11 });
12 /// widget builder for success state
13 final Widget Function(BuildContext) successWidgetBuilder;
14 /// callback for bloc event listener
15 final void Function(BuildContext, S)? Listener;
16 /// callback for the conditions to restrict when to rebuild the UI
17 final BlocBuilderCondition<S>? buildWhen;
18 /// callback for the refresh action
19 final VoidCallback? onRefresh;
20 final Widget? loadingWidget;
21 final Widget? errorWidget;
22 final Widget? emptyWidget;
23
24 Widget builder(BuildContext context, S state) {
25 late final Widget widget;
26 // set all the default widgets for each state
27 switch (state.status) {
28 case Status.loading:
29 widget = loadingWidget ?? DefaultLoadingWidget();
30 break;
31 case Status.success:
32 widget = successWidgetBuilder(context);
33 break;
34 case Status.empty:
35 widget = emptyWidget ?? DefaultEmptyWidget();
36 break;
37 case Status.failure:
38 widget = errorWidget ?? DefaultErrorWidget();
39 break;
40 }
41
42 return widget;
43 }
44
45 @override
46 Widget build(BuildContext context) {
47 if (listener != null) {
48 return BlocConsumer<B, S>(
49 buildWhen: buildWhen,
50 listener: listener!,
51 builder: builder,
52 );
53 }
54 return BlocBuilder<B, S>(builder: builder, buildWhen: buildWhen);
55 }
56}
Let's break down the CustomBuilder widget:
Now we will create another generic widget called CustomListView as a wrapper to the CustomBuilder widget to show the paginated listview. Similarly, you can create more wrappers to the CustomBuilder widget to show different views like grid view, card view, etc.
1typedef ItemWidgetBuilder<T> = Widget Function(
2 BuildContext context,
3 int index,
4 T item,
5);
6
7class CustomListView<T> extends StatelessWidget {
8 const CustomListView({
9 super.key,
10 required this.itemBuilder,
11 this.paginationLoadingWidget,
12 this.loadingWidget,
13 this.errorWidget,
14 this.emptyWidget,
15 this.controller,
16 this.buildWhen,
17 this.listener,
18 });
19
20 final Widget? errorWidget;
21 final Widget? emptyWidget;
22 final Widget? loadingWidget;
23 /// controller for the listview
24 final ScrollController? Controller;
25 /// loading widget to show when the next subsequent page is being fetched
26 final Widget? paginationLoadingWidget;
27 /// builder widget for the individual list item
28 final ItemWidgetBuilder<T> itemBuilder;
29 final BlocBuilderCondition<FetchListState<T>>? buildWhen;
30 final void Function(BuildContext, FetchListState<T>)? listener;
31
32 @override
33 Widget build(BuildContext context) {
34 return CustomBuilder<FetchListBloc<T>, FetchListState<T>,
35 FetchListEvent>(
36 listener: listener,
37 buildWhen: buildWhen,
38 emptyWidget: emptyWidget,
39 errorWidget: errorWidget,
40 loadingWidget: loadingWidget ?? DefaultLoadingWidget(),
41 onRefresh: () => context
42 .read<FetchListBloc<T>>()
43 .add(const FetchItems(refresh: true)),
44 successWidgetBuilder: (_) {
45 // return your paginated list view
46 return PaginatedListView<T>(
47 controller: controller,
48 itemBuilder: itemBuilder,
49 paginationLoadingWidget: paginationLoadingWidget,
50 );
51 },
52 );
53 }
54}
55
56class PaginatedListView<T> extends StatefulWidget {
57 const PaginatedListView({
58 super.key,
59 required this.itemBuilder,
60 this.paginationLoadingWidget,
61 this.controller,
62 });
63
64 final ScrollController? controller;
65 final Widget? paginationLoadingWidget;
66 final ItemWidgetBuilder<T> itemBuilder;
67
68 @override
69 State<PaginatedListView<T>> createState() => _PaginatedListViewState<T>();
70}
71
72class _PaginatedListViewState<T> extends State<PaginatedListView<T>> {
73 late final FetchListBloc<T> fetchListBloc;
74 late final ScrollController _controller;
75
76 void _onScroll() {
77 if (_controller.hasClients &&
78 _controller.position.maxScrollExtent == _controller.offset &&
79 fetchListBloc.state.hasNext &&
80 fetchListBloc.state.paginationStatus != Status.loading &&
81 // if an error occurs in pagination then stop further pagination calls
82 fetchListBloc.state.error == null) {
83 fetchListBloc.add(const FetchItems());
84 }
85 }
86
87 @override
88 void initState() {
89 super.initState();
90 fetchListBloc = context.read<FetchListBloc<T>>();
91 _controller = widget.controller ?? ScrollController();
92 _controller.addListener(_onScroll);
93 }
94
95 @override
96 void dispose() {
97 _controller
98 ..removeListener(_onScroll)
99 ..dispose();
100 super.dispose();
101 }
102
103 @override
104 Widget build(BuildContext context) {
105 return BlocSelector<FetchListBloc<T>, FetchListState<T>, int>(
106 bloc: fetchListBloc,
107 selector: (state) => state.items.length,
108 builder: (context, length) {
109 return RefreshIndicator(
110 onRefresh: () async =>
111 fetchListBloc.add(const FetchItems(refresh: true)),
112 child: ListView.builder(
113 itemCount: length + 1,
114 controller: _controller,
115 physics: const AlwaysScrollableScrollPhysics(),
116 itemBuilder: (context, index) {
117 // show the pagination status indicators on the last index
118 if (index == length) {
119 return BlocSelector<FetchListBloc<T>, FetchListState<T>, Status>(
120 selector: (state) => state.paginationStatus,
121 builder: (context, paginationStatus) {
122 switch (paginationStatus) {
123 case Status.loading:
124 return paginationLoadingWidget ?? DefaultPaginationLoadingWidget();
125 case Status.failure:
126 return DefaultPaginationErrorWidget();
127 default:
128 return const SizedBox.shrink();
129 }
130 },
131 );
132 }
133 // show the item widget
134 return widget.itemBuilder(
135 context,
136 index,
137 fetchListBloc.state.items[index],
138 );
139 },
140 ),
141 );
142 },
143 );
144 }
145}
Now that we have our generic BLoC class and generic widget, we can use them to fetch and display a paginated list of a specific data type. For example, let’s fetch a list of User objects and display them in a paginated manner.
1class UserFilter extends Filter {
2 const UserFilter({this.premiumUsers = false});
3
4 final bool premiumUsers;
5}
6
7BlocProvider(
8 create: (context) => FetchListBloc<User>(
9 // provide a method that calls the API and returns the users as FetchedList<User>
10 fetchListItems: UserRepoImpl.instance.getUsers,
11 // fetch only premium users
12 filter: UserFilter(premiumUsers: true);
13 ),
14 child: CustomListView<User>(
15 itemBuilder: (context, index, user) {
16 // return your user widget
17 return UserTile(user: user);
18 },
19 ),
20)
Also Read: Creating Real-Time 1-on-1 Chat in Flutter with CometChat SDK
Optimization of performance is important when you want fast mobile apps, especially if you're working with Flutter and implementing paginated list fetching using the generic BLoC Pattern. Here are several ways that will help you enhance your application's speed and responsiveness:
Improve speed with caching
Implementation of caching mechanism is one of the best ways to optimize paginated list fetching. Caching can briefly store the previously fetched data. It also decreases the requirement for repetitive API calls. One can greatly improve the response rate and data consumption minimization by serving cached data especially when the user goes back to a previously visited list.
Boost performance with intelligent data prefetching
Prefetching data is an alternate clever way to boost performance. This helps in delivering an even user experience by forecasting user communications and fetching data proactively before it's even asked for. For example, when the user reaches the end of the current paginated list, you can trigger the prefetching of the next set of data in the background.
Integrating intelligent data prefetching with the generic BLoC pattern requires careful consideration of data consumption and user behavior. It's a balance between improving responsiveness and optimizing resource usage.
Minimize unnecessary API calls
Making unnecessary API calls is a common pitfall in paginated list fetching. For instance, if the user repeatedly scrolls back and forth between pages, you may unintentionally trigger multiple API requests for the same data.
Implementation of techniques like debouncing or throttling can reduce redundant calls. Debouncing delays the API call until the user stops scrolling for a specific time period and throttling confines the number of API calls in a quantified time frame. These techniques confirm that you only draw data when it is extremely essential thereby saving network bandwidth and server resources.
Choosing the right pagination granularity
Making the right choice for the right Pagination granularity impacts performance significantly. However, it is crucial to generate a balance between the number of items that are fetched per page and the loading time of each page. It might take too long to load multiple items per page and fetching low items will reduce the load time and will result in frequent API calls, thus affecting overall performance.
Analysis of user behavior and usage patterns can help modify and refine the pagination granularity to deliver a fine experience.
Render efficiently
Efficient rendering of the paginated list is also critical for performance. You can use Flutters powerful ListView.builder or GridView.builder widgets to extract visible items on the screen so efficiently that off-screen items get postponed until any need arises. This technique will safeguard memory and also increases the speed of the rendering process, especially for larger lists.
Infinite Scroll and Load More Button are two most commonly used navigation techniques for generating paginated list fetching in Flutter using the generic BLoC pattern. We'll discover and find out the circumstances where each option is best suited for a continual user experience. We will also compare and differentiate these two techniques in this section.
Infinite scroll is a limitless prospect, where new data appears without obstruction as users browse through the list. This technique specifically removes any requirement for unambiguous user communication to load more data, thereby enabling an uninterrupted and immersive experience.
The BLoC logically detects the user's scroll position and automatically triggers API calls to fetch more data as required while the execution of infinite scroll using the generic BLoC pattern is ongoing. This method allows us to update the list in real time without any additional effort.
Load more buttons has stark differences in comparison to Infinite Scroll. The Load More button acts as an inspiration as well as a supervising guide. Here, users are required to tap a designated button to fetch more data. This method gives users better control in deciding when to load additional content, which is exclusively useful for lists containing substantial data sets.
By including a button within the UI, the Load More Button can be implemented using the generic BloC pattern. When this button is pressed, it automatically prompts the BloC to draw out the next page of data.
The choice between Infinite Scroll and Load More Button depends on various considerations based on a variety of user experience. Infinite Scroll is an ideal choice for lists with a limitless stream of data, for instance social media feeds because it works nicely and efficiently for an unobstructed meaningful browsing experience for the users. On the contrary, The Load More Button is more perfectly suited for situations where users require more control and need a guided approach to data loading in addition to providing a task conclusion after the data command work is done.
From a performance standpoint, Infinite Scroll may trigger more frequent API calls as the user scrolls through the list rapidly. This could lead to higher data consumption and put additional load on the server. Careful optimization and the use of throttling mechanisms are essential to ensure smooth performance.
Load More Button, however, puts the user in charge of data retrieval, potentially reducing the number of API calls. This approach offers more control over resource management, making it suitable for applications with limited data or those aiming to conserve network bandwidth.
In many cases, a hybrid approach that combines both techniques might be the ideal solution. For example, the Infinite Scroll could be used initially to engage users and provide a smooth initial browsing experience. Once users reach a certain point or scroll depth, the Load More Button could take over to allow users more control over further data fetching.
Also read: Authentication and Navigation in Flutter Web with go_router
When utilizing the generic BLoC pattern in Flutter for paginated list fetching, it is essential to have a deep understanding of the guidelines and best practices to keep in mind. In this section, you will find best practices, dos and don’ts, etc. That will prove helpful in generic BloC pattern implementation in real-world applications.
Single responsibility principle
The fundamental principle of SRP is to be held for creating a generic BloC class. Each BloC should maintain a focus on a specific domain or feature, adhering to the Single Responsibility Principle. This will guarantee well-maintained code that will hold a certain level of clarity and can also be utilized again across different parts of your app.
Scalability with dependency injection
Dependency injection plays a very important role in scaling the app and implementing future updates. With a strong dependency injection framework like get_it or provider, you can manage the occurrences of generic BloC classes and inject them into various screens and widgets.
Emphasizing testability
Testability is the basic foundation of unfailing, result-oriented, and bug-free apps. Testability of the generic BloC class should be considered during design. This separation of business logic from the UI layer enables comprehensive unit testing and widget testing. This will ensure the accuracy of BloC’s behavior.
Error handling with grace
Handling errors is extremely crucial for any real-world application. Therefore, implementing graceful error handling and informative error management capacity in the generic BloC class can help manage API failures and unforeseen events. Demonstrate suitable error messages to users so that they receive guidance while managing potential issues and the resulting disruptions during the user experience.
Also Read: Choosing Between Dart and Kotlin: A Comprehensive Guide for Your App Development
Using a generic BLoC for list fetching in Flutter can greatly simplify your development process, improve code reusability, and enhance the overall architecture of your application. By separating the concerns of data fetching, state management, and UI rendering, you can build robust and maintainable Flutter applications.
In this blog, we created a generic BLoC class capable of fetching various types of data lists and a generic widget to display the fetched list in a paginated manner. With this approach, you can extend and reuse the generic BLoC class and the generic widgets to handle lists of various data types.
Hence, by utilizing the power of generics and implementing the BloC pattern one can create an effective and scalable list-fetching solution in the Flutter project.
For any support and assistance in embracing the BloC pattern in your Flutter app or any help with other Flutter application development related services, our team of experienced developers at Aubergine Solutions can help you. Connect with us today to learn more.