Flutter Infinite List
This content is not available in your language yet.
In this tutorial, we’re going to be implementing an app which fetches data over the network and loads it as a user scrolls using Flutter and the bloc library.
- Observe state changes with BlocObserver.
- BlocProvider, Flutter widget which provides a bloc to its children.
- BlocBuilder, Flutter widget that handles building the widget in response to new states.
- Adding events with context.read.
- Prevent unnecessary rebuilds with Equatable.
- Use the
transformEvents
method with Rx.
We’ll start off by creating a brand new Flutter project
flutter create flutter_infinite_list
We can then go ahead and replace the contents of pubspec.yaml with
name: flutter_infinite_listdescription: A new Flutter project.version: 1.0.0+1publish_to: none
environment: sdk: ">=3.6.0 <4.0.0"
dependencies: bloc: ^9.0.0 bloc_concurrency: ^0.3.0 equatable: ^2.0.0 flutter: sdk: flutter flutter_bloc: ^9.0.0 http: ^1.0.0 stream_transform: ^2.0.0
dev_dependencies: bloc_test: ^10.0.0 flutter_test: sdk: flutter mocktail: ^1.0.0
flutter: uses-material-design: true
and then install all of our dependencies
flutter pub get
├── lib| ├── posts│ │ ├── bloc│ │ │ └── post_bloc.dart| | | └── post_event.dart| | | └── post_state.dart| | └── models| | | └── models.dart*| | | └── post.dart│ │ └── view│ │ | ├── posts_page.dart│ │ | └── posts_list.dart| | | └── view.dart*| | └── widgets| | | └── bottom_loader.dart| | | └── post_list_item.dart| | | └── widgets.dart*│ │ ├── posts.dart*│ ├── app.dart│ ├── simple_bloc_observer.dart│ └── main.dart├── pubspec.lock├── pubspec.yaml
The application uses a feature-driven directory structure. This project structure enables us to scale the project by having self-contained features. In this example we will only have a single feature (the post feature) and it’s split up into respective folders with barrel files, indicated by the asterisk (*).
For this demo application, we’ll be using jsonplaceholder as our data source.
Open a new tab in your browser and visit https://jsonplaceholder.typicode.com/posts?_start=0&_limit=2 to see what the API returns.
[ { "userId": 1, "id": 1, "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", "body": "quia et suscipitsuscipit recusandae consequuntur expedita et cumreprehenderit molestiae ut ut quas totamnostrum rerum est autem sunt rem eveniet architecto" }, { "userId": 1, "id": 2, "title": "qui est esse", "body": "est rerum tempore vitaesequi sint nihil reprehenderit dolor beatae ea dolores nequefugiat blanditiis voluptate porro vel nihil molestiae ut reiciendisqui aperiam non debitis possimus qui neque nisi nulla" }]
Great, now that we know what our data is going to look like, let’s create the model.
Create post.dart
and let’s get to work creating the model of our Post object.
import 'package:equatable/equatable.dart';
final class Post extends Equatable { const Post({required this.id, required this.title, required this.body});
final int id; final String title; final String body;
@override List<Object> get props => [id, title, body];}
Post
is just a class with an id
, title
, and body
.
Now that we have our Post
object model, let’s start working on the Business Logic Component (bloc).
Before we dive into the implementation, we need to define what our PostBloc
is going to be doing.
At a high level, it will be responding to user input (scrolling) and fetching more posts in order for the presentation layer to display them. Let’s start by creating our Event
.
Our PostBloc
will only be responding to a single event; PostFetched
which will be added by the presentation layer whenever it needs more Posts to present. Since our PostFetched
event is a type of PostEvent
we can create bloc/post_event.dart
and implement the event like so.
part of 'post_bloc.dart';
sealed class PostEvent extends Equatable { @override List<Object> get props => [];}
final class PostFetched extends PostEvent {}
To recap, our PostBloc
will be receiving PostEvents
and converting them to PostStates
. We have defined all of our PostEvents
(PostFetched) so next let’s define our PostState
.
Our presentation layer will need to have several pieces of information in order to properly lay itself out:
PostInitial
- will tell the presentation layer it needs to render a loading indicator while the initial batch of posts are loadedPostSuccess
- will tell the presentation layer it has content to renderposts
- will be theList<Post>
which will be displayedhasReachedMax
- will tell the presentation layer whether or not it has reached the maximum number of posts
PostFailure
- will tell the presentation layer that an error has occurred while fetching posts
We can now create bloc/post_state.dart
and implement it like so.
part of 'post_bloc.dart';
enum PostStatus { initial, success, failure }
final class PostState extends Equatable { const PostState({ this.status = PostStatus.initial, this.posts = const <Post>[], this.hasReachedMax = false, });
final PostStatus status; final List<Post> posts; final bool hasReachedMax;
PostState copyWith({ PostStatus? status, List<Post>? posts, bool? hasReachedMax, }) { return PostState( status: status ?? this.status, posts: posts ?? this.posts, hasReachedMax: hasReachedMax ?? this.hasReachedMax, ); }
@override String toString() { return '''PostState { status: $status, hasReachedMax: $hasReachedMax, posts: ${posts.length} }'''; }
@override List<Object> get props => [status, posts, hasReachedMax];}
Now that we have our Events
and States
implemented, we can create our PostBloc
.
For simplicity, our PostBloc
will have a direct dependency on an http client
; however, in a production application we suggest instead you inject an api client and use the repository pattern docs.
Let’s create post_bloc.dart
and create our empty PostBloc
.
import 'package:bloc/bloc.dart';import 'package:meta/meta.dart';import 'package:http/http.dart' as http;
import 'package:flutter_infinite_list/bloc/bloc.dart';import 'package:flutter_infinite_list/post.dart';
part 'post_event.dart';part 'post_state.dart';
class PostBloc extends Bloc<PostEvent, PostState> { PostBloc({required this.httpClient}) : super(const PostState()) { /// TODO: register on<PostFetched> event }
final http.Client httpClient;}
Next, we need to register an event handler to handle incoming PostFetched
events. In response to a PostFetched
event, we will call _fetchPosts
to fetch posts from the API.
PostBloc({required this.httpClient}) : super(const PostState()) { on<PostFetched>(_onFetched);}
Future<void> _onFetched(PostFetched event, Emitter<PostState> emit) async { if (state.hasReachedMax) return;
try { final posts = await _fetchPosts(startIndex: state.posts.length);
if (posts.isEmpty) { return emit(state.copyWith(hasReachedMax: true)); }
emit( state.copyWith( status: PostStatus.success, posts: [...state.posts, ...posts], ), ); } catch (_) { emit(state.copyWith(status: PostStatus.failure)); }}
Our PostBloc
will emit
new states via the Emitter<PostState>
provided in the event handler. Check out core concepts for more information.
Now every time a PostEvent
is added, if it is a PostFetched
event and there are more posts to fetch, our PostBloc
will fetch the next 20 posts.
The API will return an empty array if we try to fetch beyond the maximum number of posts (100), so if we get back an empty array, our bloc will emit
the currentState except we will set hasReachedMax
to true.
If we cannot retrieve the posts, we emit PostStatus.failure
.
If we can retrieve the posts, we emit PostStatus.success
and the entire list of posts.
One optimization we can make is to throttle
the PostFetched
event in order to prevent spamming our API unnecessarily. We can do this by using the transform
parameter when we register the _onFetched
event handler.
import 'package:stream_transform/stream_transform.dart';
const throttleDuration = Duration(milliseconds: 100);
EventTransformer<E> throttleDroppable<E>(Duration duration) { return (events, mapper) { return droppable<E>().call(events.throttle(duration), mapper); };}
class PostBloc extends Bloc<PostEvent, PostState> { PostBloc({required this.httpClient}) : super(const PostState()) { on<PostFetched>( _onFetched, transformer: throttleDroppable(throttleDuration), ); }}
Our finished PostBloc
should now look like this:
import 'dart:async';import 'dart:convert';
import 'package:bloc/bloc.dart';import 'package:bloc_concurrency/bloc_concurrency.dart';import 'package:equatable/equatable.dart';import 'package:flutter_infinite_list/posts/posts.dart';import 'package:http/http.dart' as http;import 'package:stream_transform/stream_transform.dart';
part 'post_event.dart';part 'post_state.dart';
const _postLimit = 20;const throttleDuration = Duration(milliseconds: 100);
EventTransformer<E> throttleDroppable<E>(Duration duration) { return (events, mapper) { return droppable<E>().call(events.throttle(duration), mapper); };}
class PostBloc extends Bloc<PostEvent, PostState> { PostBloc({required this.httpClient}) : super(const PostState()) { on<PostFetched>( _onFetched, transformer: throttleDroppable(throttleDuration), ); }
final http.Client httpClient;
Future<void> _onFetched( PostFetched event, Emitter<PostState> emit, ) async { if (state.hasReachedMax) return;
try { final posts = await _fetchPosts(startIndex: state.posts.length);
if (posts.isEmpty) { return emit(state.copyWith(hasReachedMax: true)); }
emit( state.copyWith( status: PostStatus.success, posts: [...state.posts, ...posts], ), ); } catch (_) { emit(state.copyWith(status: PostStatus.failure)); } }
Future<List<Post>> _fetchPosts({required int startIndex}) async { final response = await httpClient.get( Uri.https( 'jsonplaceholder.typicode.com', '/posts', <String, String>{'_start': '$startIndex', '_limit': '$_postLimit'}, ), ); if (response.statusCode == 200) { final body = json.decode(response.body) as List; return body.map((dynamic json) { final map = json as Map<String, dynamic>; return Post( id: map['id'] as int, title: map['title'] as String, body: map['body'] as String, ); }).toList(); } throw Exception('error fetching posts'); }}
Great! Now that we’ve finished implementing the business logic all that’s left to do is implement the presentation layer.
In our main.dart
we can start by implementing our main function and calling runApp
to render our root widget. Here, we can also include our bloc observer to log transitions and any errors.
import 'package:bloc/bloc.dart';import 'package:flutter/widgets.dart';import 'package:flutter_bloc/flutter_bloc.dart';import 'package:flutter_infinite_list/app.dart';import 'package:flutter_infinite_list/simple_bloc_observer.dart';
void main() { Bloc.observer = const SimpleBlocObserver(); runApp(const App());}
In our App
widget, the root of our project, we can then set the home to PostsPage
import 'package:flutter/material.dart';import 'package:flutter_infinite_list/posts/posts.dart';
class App extends MaterialApp { const App({super.key}) : super(home: const PostsPage());}
In our PostsPage
widget, we use BlocProvider
to create and provide an instance of PostBloc
to the subtree. Also, we add a PostFetched
event so that when the app loads, it requests the initial batch of Posts.
import 'package:flutter/material.dart';import 'package:flutter_bloc/flutter_bloc.dart';import 'package:flutter_infinite_list/posts/posts.dart';import 'package:http/http.dart' as http;
class PostsPage extends StatelessWidget { const PostsPage({super.key});
@override Widget build(BuildContext context) { return Scaffold( body: BlocProvider( create: (_) => PostBloc(httpClient: http.Client())..add(PostFetched()), child: const PostsList(), ), ); }}
Next, we need to implement our PostsList
view which will present our posts and hook up to our PostBloc
.
import 'package:flutter/material.dart';import 'package:flutter_bloc/flutter_bloc.dart';import 'package:flutter_infinite_list/posts/posts.dart';
class PostsList extends StatefulWidget { const PostsList({super.key});
@override State<PostsList> createState() => _PostsListState();}
class _PostsListState extends State<PostsList> { final _scrollController = ScrollController();
@override void initState() { super.initState(); _scrollController.addListener(_onScroll); }
@override Widget build(BuildContext context) { return BlocBuilder<PostBloc, PostState>( builder: (context, state) { switch (state.status) { case PostStatus.failure: return const Center(child: Text('failed to fetch posts')); case PostStatus.success: if (state.posts.isEmpty) { return const Center(child: Text('no posts')); } return ListView.builder( itemBuilder: (BuildContext context, int index) { return index >= state.posts.length ? const BottomLoader() : PostListItem(post: state.posts[index]); }, itemCount: state.hasReachedMax ? state.posts.length : state.posts.length + 1, controller: _scrollController, ); case PostStatus.initial: return const Center(child: CircularProgressIndicator()); } }, ); }
@override void dispose() { _scrollController.dispose(); super.dispose(); }
void _onScroll() { if (_isBottom) context.read<PostBloc>().add(PostFetched()); }
bool get _isBottom { if (!_scrollController.hasClients) return false; final maxScroll = _scrollController.position.maxScrollExtent; final currentScroll = _scrollController.offset; return currentScroll >= (maxScroll * 0.9); }}
Moving along, our build method returns a BlocBuilder
. BlocBuilder
is a Flutter widget from the flutter_bloc package which handles building a widget in response to new bloc states. Any time our PostBloc
state changes, our builder function will be called with the new PostState
.
Whenever the user scrolls, we calculate how far you have scrolled down the page and if our distance is ≥ 90% of our maxScrollextent
we add a PostFetched
event in order to load more posts.
Next, we need to implement our BottomLoader
widget which will indicate to the user that we are loading more posts.
import 'package:flutter/material.dart';
class BottomLoader extends StatelessWidget { const BottomLoader({super.key});
@override Widget build(BuildContext context) { return const Center( child: SizedBox( height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 1.5), ), ); }}
Lastly, we need to implement our PostListItem
which will render an individual Post
.
import 'package:flutter/material.dart';import 'package:flutter_infinite_list/posts/posts.dart';
class PostListItem extends StatelessWidget { const PostListItem({required this.post, super.key});
final Post post;
@override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; return ListTile( leading: Text('${post.id}', style: textTheme.bodySmall), title: Text(post.title), isThreeLine: true, subtitle: Text(post.body), dense: true, ); }}
At this point, we should be able to run our app and everything should work; however, there’s one more thing we can do.
One added bonus of using the bloc library is that we can have access to all Transitions
in one place.
The change from one state to another is called a Transition
.
Even though in this application we only have one bloc, it’s fairly common in larger applications to have many blocs managing different parts of the application’s state.
If we want to be able to do something in response to all Transitions
we can simply create our own BlocObserver
.
// ignore_for_file: avoid_print
import 'package:bloc/bloc.dart';
class SimpleBlocObserver extends BlocObserver { const SimpleBlocObserver();
@override void onTransition( Bloc<dynamic, dynamic> bloc, Transition<dynamic, dynamic> transition, ) { super.onTransition(bloc, transition); print(transition); }
@override void onError(BlocBase<dynamic> bloc, Object error, StackTrace stackTrace) { print(error); super.onError(bloc, error, stackTrace); }}
Now every time a Bloc Transition
occurs we can see the transition printed to the console.
That’s all there is to it! We’ve now successfully implemented an infinite list in flutter using the bloc and flutter_bloc packages and we’ve successfully separated our presentation layer from our business logic.
Our PostsPage
has no idea where the Posts
are coming from or how they are being retrieved. Conversely, our PostBloc
has no idea how the State
is being rendered, it simply converts events into states.
The full source for this example can be found here.