Skip to content

Latest commit

 

History

History
491 lines (396 loc) · 21.9 KB

06.md

File metadata and controls

491 lines (396 loc) · 21.9 KB

Chapter 6: State handling Part II.

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.

Handling the results of a Future with Provider

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 a Future 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-created Future object to FutureBuilder widget inside the build() method, and do not create a new Future object there. Remember that the build() method can be called on every frame draw; and so, creating a new Future object would launch a new network request potentially 60 times per second. Because of this, FutureBuilder is typically used within a State object, where we can save the Future object of the current request, and use this inside the build() method.

State management - BLoC

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: Combines BlocBuilder and BlocListener into a single class.

Depending on how the presentation layer communicates with the business layer, we can use either a Bloc or a Cubit.

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.

Cubit overview Cubit function calls

Take a look into the flutter_counter_cubit project to see how we can enhance the Counter application with Cubits. 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.

Bloc

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.

Bloc overview Bloc function calls

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() and droppable(). 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 as debounceTime() to wait for SearchUpdateNameEvents, 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 the bloc library. If we were to look at older bloc examples, we would find the mapEventToState() and transformEvents() methods. These are almost equivalent to a single on() definition which handles every event (by specifiying the abstract base event class), the only difference beeing that on() is concurrent, while mapEventToState() is sequential by default. For reference, here is the function call diagram: Old Bloc function calls

Conclusion

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.

References, materials, further reading