In the previous chapter, we have learned about asynchronous operations and programming, generator functions and the provider
package, which can efficiently propagate information in the widget tree. We have also used this package as a simple state management solution. In this chapter, we will look into handling the results of a Future within the Flutter framework and the provider
package. We will also learn about BLoC, a mainstream design pattern in Flutter development.
As we have seen in the previous chapter, we can use ChangeNotifier
with ChangeNotifierProvider
to easily share a mutable state object in the widget tree. Calling notifyListeners()
on the ChangeNotifier
object will notify every widget observing this state, and so the UI will be automatically refreshed.
The ChangeNotifier
can be also used to handle Future
objects. Take a look at the complete example project flutter_counter_future
.
In this project, we simulate a network request, where the task of the backend is to increase the counter value by one:
class RemoteService {
Future<int> incrementCounter(int currentValue) async {
await Future.delayed(Duration(seconds: 3));
return currentValue + 1;
}
}
final remoteService = RemoteService();
To await the result of this function, we can declare the incrementCounter()
function as async
. However, we must take some precautions when calling an asynchronous operation compared to synchronous operations.
In this case, we should make sure that the user cannot make a new network request while another one is processed. We use the isLoading
variable to check whether there is an ongoing request, and return immediately if there is.
Also, asynchronous operations - like network calls - are more likely to run into some form of error, caused by either bad network conditions or some kind of server error.
It is highly recommended to always wrap network requests in a try-catch-finally
block and handle the possible errors accordingly.
import 'package:flutter/cupertino.dart';
import 'package:flutter_counter_provider/remote_service.dart';
class Counter extends ChangeNotifier {
int count = 0;
bool isLoading = false;
String? error;
void incrementCounter() async {
if (isLoading) return;
isLoading = true;
error = null;
notifyListeners();
try {
count = await remoteService.incrementCounter(count);
} on Exception catch (e) {
error = e.toString();
} finally {
isLoading = false;
notifyListeners();
}
}
String get counterText => error ?? (isLoading ? "Loading" : count.toString());
}
While the implementation of the incrementCounter()
function is considerably more complex now, it is completely hidden from the UI: just as before, the CounterButton
calls the incrementCounter()
function, and the widgets get the actual counter value from the count
and counterText
property. This way, we have separated the UI and the business logic of our application.
However, using ChangeNotifier
for Future
handling is error-prone and complex. As we can see, we need to manually call notifyListeners()
whenever the state of our observed object changes. We also need to make sure that the error states are handled, and that the object will not end up in an inconsistent state. In conclusion, ChangeNotifier
should only be used in simple cases, like we only have few state variables and few asynchronous operations. Also, reacting to events on the UI side (for example, showing a Snackbar
) requires the use of StatefulWidget
to execute code only when the state changes.
Flutter also provides the
FutureBuilder
widget to handle aFuture
object. While the separation of logic and UI is highly recommended,FutureBuilder
is a great alternative if we don't want to use any state management libraries in our application. In this case, always make sure to pass a pre-createdFuture
object toFutureBuilder
widget inside thebuild()
method, and do not create a newFuture
object there. Remember that thebuild()
method can be called on every frame draw; and so, creating a newFuture
object would launch a new network request potentially 60 times per second. Because of this,FutureBuilder
is typically used within aState
object, where we can save theFuture
object of the current request, and use this inside thebuild()
method.
The BLoC (Business Logic Component) design pattern is one of the recommended state management patterns for Dart applications. The structure is similar to the MVVM (Model-View-ViewModel) approach, quoted from bloclibrary.dev:
- The data layer's responsibility is to retrieve/manipulate data from one or more sources.
- The business logic layer's responsibility is to respond to input from the presentation layer with new states. This layer can depend on one or more repositories to retrieve data needed to build up the application state.
- The presentation layer's responsibility is to figure out how to render itself based on the states provided by one or more BLoCs. In addition, it should handle user input and application lifecycle events.
The BLoC design pattern is implemented in the bloc
library in a platform-agnostic way, while flutter_bloc
contains Flutter specific helper widgets. It is also recommended to add the Bloc plugin to our IDE, which can efficiently create Bloc and Cubit classes.
To share a BLoC, we can use the BlocProvider
widget (based on the Provider
class). This exposes the BLoC, which can be observed the same way as seen with the Provider
class. Three classes can also be used to observe the underlying state of the component:
BlocBuilder
: Builds a widget based on the state of the used component.BlocListener
: Listens to the changes in the state of the component. We can use it to detect and act on state changes, such as navigating to another page.BlocConsumer
: CombinesBlocBuilder
andBlocListener
into a single class.
Depending on how the presentation layer communicates with the business layer, we can use either a Bloc or a Cubit.
We can use a Cubit
when we want the presentation layer to simply call functions defined in the component. This will start a task and the Cubit
can emit new UI states during and at the end of the task. This is mostly used when the interactions coming from the view can be easily mapped to a corresponding task.
Take a look into the flutter_counter_cubit
project to see how we can enhance the Counter application with Cubit
s. This project is based on the flutter_counter_future
with the added functionality of showing a SnackBar
message on error.
When we create a cubit with the help of the plugin, two files will be created: one for the state declaration, and one for the logic component. The states are defined as follows:
@immutable
abstract class CounterState extends Equatable {
const CounterState();
}
class CounterInitial extends CounterState {
const CounterInitial();
@override
List<Object> get props => [];
}
class CounterCountState extends CounterState {
final int count;
const CounterCountState(this.count);
@override
List<Object?> get props => [count];
}
class CounterLoadState extends CounterState{
final int count;
const CounterLoadState(this.count);
@override
List<Object?> get props => [count];
}
class CounterErrorEventState extends CounterState {
final String message;
const CounterErrorEvent(this.message);
@override
List<Object?> get props => [message];
}
Using the equatable
package is optional but highly recommended. It helps us by overriding the default equality operator and hashCode
property so that only the values inside the props
field are used for comparison. bloc
compares the new state to be emitted to the old state, and if they match, the widget tree is not updated.
After defining the states, we implement the business logic inside the cubit
file:
class CounterCubit extends Cubit<CounterState> {
CounterCubit() : super(CounterInitial());
void incrementCounter() async {
var state = this.state;
if (state is CounterLoadState) return;
var currentCount = state is CounterCountState ? state.count : 0;
emit(CounterLoadState(currentCount));
try {
var newCount = await remoteService.incrementCounter(currentCount);
emit(CounterCountState(newCount));
} catch (e) {
emit(CounterErrorEventState(e.toString()));
emit(CounterCountState(currentCount));
}
}
}
While the structure of the incrementCounter()
is the same as the ChangeNotifierProvider
version, this version is less error-prone due to the states being encapsulated in separate, well-defined classes. Due to us specifically emitting states, it is much less likely for us to accidentally emit an inconsistent state.
Notice the use of CounterErrorEventState
. This is a special state which will only be used to signal an event to the UI layer. In contrast to normal states, we want events to only have an effect once, when the event is emitted.
To make a Cubit
(or Bloc
) available for widgets, we can use the BlocProvider
widget (similarly to Provider
). It is highly recommended to put the BlocProvider
outside of the scope of the Page
to make testing the UI easier. For this reason, we will modify main.dart
:
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: BlocProvider(
create: (_) => CounterCubit(),
child: MyHomePage(),
),
);
}
}
We can use extension functions like read
from provider
to acces the Cubit
object, and call the incrementCounter()
method:
class CounterButton extends StatelessWidget {
const CounterButton({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
var counterCubit = context.read<CounterCubit>();
return FloatingActionButton(
onPressed: () {
counterCubit.incrementCounter();
},
tooltip: 'Increment',
child: Icon(Icons.add),
);
}
}
Finally, we can observe the underlying state object of the Cubit
:
class CounterText extends StatelessWidget{
const CounterText();
@override
Widget build(BuildContext context) {
return BlocBuilder<CounterCubit, CounterState>(
buildWhen: (_, state) => state is! CounterErrorEventState,
builder: (context, state) {
if (state is CounterLoadState){
return CircularProgressIndicator();
} else if (state is CounterCountState){
return Text(
state.count.toString(),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headline4,
);
} else if (state is CounterInitial){
return Text(
"Press the button!",
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headline4,
);
} else {
return Container();
}
},
);
}
}
AppBar(
title: BlocConsumer<CounterCubit, CounterState>(
listenWhen: (_, state) => state is CounterErrorEventState,
listener: (context, state) {
var errorMessage = (state as CounterErrorEventState).message;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(errorMessage)));
},
buildWhen: (_, state) => state is! CounterErrorEventState,
builder: (context, state) {
var count = 0;
if (state is CounterCountState){
count = state.count;
} else if (state is CounterLoadState){
count = state.count;
}
return Text("My counter application: $count");
},
),
),
Notice the use of buildWhen
and listenWhen
, which filters when the corresponding callbacks are called. With listener
we can handle the "event" states, while the builder
callback handles the normal states.
A Bloc
can be thought as an expanded Cubit
. Instead of the presentation layer directly interacting with the component, it sends events to the component through a Stream
object. Bloc
offers higher traceability due to events being connected to state changes and also provides us a way to interact with the incoming stream.
Take a look into the flutter_counter_bloc
project to see how we use a Bloc
to implement a search functionality.
In this application, we implement a search functionality, where the result of the search is displayed in a list. To simulate the network request, we use the following classes:
class User extends Equatable{
final String name;
final String imageUrl;
User(this.name, this.imageUrl);
@override
List<Object?> get props => [name, imageUrl];
}
class RemoteService {
Future<List<User>> searchUsers(String name) async {
await Future.delayed(Duration(seconds: max(0, 4 - name.length), milliseconds: 100));
if (name == "error"){
throw "No network connection!";
}
return [
for (int i = 0; i < max(0, 4 - name.length) * 10 + 2; i++)
User(
'$name${UsernameGen.generateWith()}',
'https://source.unsplash.com/user/filipp_roman_photography/90x90?${Random().nextInt(1024)}'
),
];
}
}
final remoteService = RemoteService();
Based on the length of the search term, the request can take more time and return more results.
The result of this list is displayed by the following Flutter application (found inside main.dart
for simplicity's sake):
late List<User> userList;
void main() async {
userList = await remoteService.searchUsers("A");
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Search user"),
),
body: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
child: TextFormField(
decoration: const InputDecoration(
labelText: "Search term",
enabledBorder: UnderlineInputBorder(),
),
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(8),
itemBuilder: (context, index) {
var user = userList[index];
return SizedBox(
height: 90,
child: Row(
children: [
Image.network(user.imageUrl, width: 90,),
Text(user.name),
],
),
);
},
itemCount: userList.length,
),
),
],
),
);
}
}
Launching this application will load a list of users from our searchUsers()
function, and display this list, while the search field does nothing.
Our goal for this application is to implement a Bloc
to handle the logic behind searching for users. Similarly to working with a Cubit
, we start off by using the plugin to generate the necessary files. In this case, however, the plugin will generate three files. We start by defining the states of the page:
@immutable
abstract class SearchState extends Equatable{}
class SearchResultListState extends SearchState {
final List<User> users;
SearchResultListState(this.users);
@override
List<Object?> get props => [users];
}
class SearchErrorEventState extends SearchState {
final String message;
SearchErrorEventState(this.message);
@override
List<Object?> get props => [message];
}
In contrast to Cubit
, we declare a separate class for events with which the UI layer will be capable of communicating with the logic layer. Instead of directly calling functions on the Bloc
object, we will create and send instances of these event classes to the Bloc
object. This gives us higher traceability and more sophisticated event handlers within the Bloc
object.
@immutable
abstract class SearchEvent {}
class SearchUpdateNameEvent extends SearchEvent{
final String filterName;
SearchUpdateNameEvent(this.filterName);
}
After the states and events are declared, we can define how events should be processed and mapped to states inside the Bloc
object. We use the on()
method inside the constructor to specify which type of events should be handled and the callback which will be called when a new event is received. The callback receives two parameters: the event itself, and an emitter object, which is used to emit new state objects.
class SearchBloc extends Bloc<SearchEvent, SearchState> {
SearchBloc() : super(SearchResultListState(const [])) {
on<SearchUpdateNameEvent>(
(event, emit) async {
try {
print("Starting request: ${event.filterName}");
var searchResult = await remoteService.searchUsers(event.filterName);
emit(SearchResultListState(searchResult));
} catch (e) {
emit(SearchErrorEventState(e.toString()));
}
},
transformer: (events, mapper) => events.debounceTime(const Duration(milliseconds: 500)).switchMap(mapper),
);
}
}
There is another parameter of the on()
method, transformer
. It is used to handle how multiple events are processed:
- By default, events are processed concurrently. Note that with asynchronous operations, concurrent executions will finish at different times, which can cause older state to be displayed.
- The
bloc_concurrency
contains three other transformers:sequential()
,restartable()
anddroppable()
. Note that if an event handler is canceled, the corresponding asynchronous operation will still finish, but the emitted states will be ignored. - If these are not sufficient, we can write our own transformer function! In our case, we use the
rxdart
package for useful stream operations, such asdebounceTime()
to wait forSearchUpdateNameEvent
s, and only start the request after half a second passed since the last event.
After adding a BlocProvider
to our MaterialApp
, we use the Bloc
object similarly to how Cubit
was used:
We must add a new SearchUpdateNameEvent
event when the text field changes:
TextFormField(
decoration: const InputDecoration(
labelText: "Search term",
enabledBorder: UnderlineInputBorder(),
),
onChanged: (text){
context.read<SearchBloc>().add(SearchUpdateNameEvent(text));
},
),
To show the list and the occasional error message, we use the same structure with BuildConsumer
:
BlocConsumer<SearchBloc, SearchState>(
listenWhen: (_, state) => state is SearchErrorEventState,
listener: (context, state){
if (state is SearchErrorEventState){
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.message)));
}
},
buildWhen: (_, state) => state is SearchResultListState,
builder: (context, state) {
if (state is SearchResultListState) {
return ListView.builder(
padding: const EdgeInsets.all(8),
itemBuilder: (context, index) {
var user = state.users[index];
return /* ... */;
},
itemCount: state.users.length,
);
} else {
return Container();
}
},
),
We can see how the Stream
aspect of the event stream helped us easily control when and how our events are handled. This makes it easier to write more complex applications with less overhead, but a good understanding of Stream
operators are needed. Also, while traceability is not explored in this chapter, due to the explicit event usage and state emission, Bloc
supports various observers, which are capable of logging the specific events and state transitions, making the debugging of specific errors easier.
Note that the
on()
method is a new addition to thebloc
library. If we were to look at olderbloc
examples, we would find themapEventToState()
andtransformEvents()
methods. These are almost equivalent to a singleon()
definition which handles every event (by specifiying the abstract base event class), the only difference beeing thaton()
is concurrent, whilemapEventToState()
is sequential by default. For reference, here is the function call diagram:
In this chapter, we have discussed how we can manage the state of our Flutter application with the help of two popular libraries, provider
and flutter_bloc
, with a focus on asynchronous operations. In the next chapter, we will look into advanced layout widgets, animations and navigation.