Writing apps with Flutter creates great opportunities for choosing architecture. As is often the case, the best answer to the question “Which one should I choose?” is “It depends”. When you get that answer, you can be sure you found an expert in programming.
In this article, we will go through the most popular screens in mobile applications and implement them in the two most popular Flutter architectures: Provider and BLoC. As a result, we will learn the pros and cons of each solution, which will help us choose the right Flutter architecture for our next module or application.
Brief introduction to Flutter architecture
Choosing the architecture when building a Flutter project is of great importance, primarily due to the fact that we are dealing with a less commonly used, declarative programming paradigm. This completely changes the approach to managing the sate that native Android or iOS developers were familiar with, writing the code imperatively. Data available in one place in the application are not so easy to obtain in another. We do not have direct references to other views in the tree, from which we could gain their current state.
What is Provider in Flutter
As the name suggests, Provider is a Flutter architecture that provides the current data model to the place where we currently need it. It contains some data and notifies observers when a change occurs. In Flutter SDK, this type is called a ChangeNotifier. For the object of type ChangeNotifier to be available to other widgets, we need ChangeNotifierProvider. It provides observed objects for all of its descendants. The object which is able to receive current data is Consumer, which has a ChangeNotifier instance in the parameter of its build function that can be used to feed subsequent views with data.
What is BLoC in Flutter
Business Logic Components is a Flutter architecture much more similar to popular solutions in mobile such as MVP or MVVM. It provides separation of the presentation layer from business logic rules. This is a direct application of the declarative approach which Flutter strongly emphasizes i.e. UI = f (state). BLoC is a place where events from the user interface go. Within this layer, as a result of applying business rules to a given event, BLoC responds with a specific state, which then goes back to the UI. When the view layer receives a new state, it rebuilds its view according to what the current state requires.
How to create a list in Flutter
A scrollable list is probably one of the most popular views in mobile applications. Therefore, picking the right Flutter architecture might be crucial here. Theoretically, displaying the list itself is not difficult. The situation gets trickier when, for example, we add the ability to perform a certain action on each element. That should cause a change in different places in the app. In our list, we will be able to select each of the elements, and each of the selected ones will be displayed in a separate list on a different screen.
Therefore, we have to store elements that have been selected, so that they can be displayed on a new screen. In addition, we will need to rebuild the view every time when the checkbox is tapped, to actually show check / uncheck.
The list item model looks very simple:
class SocialMedia { int id; String title; String iconAsset; bool isFavourite; SocialMedia( {@required this.id, @required this.title, @required this.iconAsset, this.isFavourite = false}); void setFavourite(bool isFavourite) { this.isFavourite = isFavourite; } }
How to create a list with Provider
In Provider pattern, the above model must be stored in an object. The object should extend the ChangeNotifier to be able to access SocialMedia from another place in the app.
class SocialMediaModel extends ChangeNotifier { final List<SocialMedia> _socialMedia = [ /* some social media objects */ ]; UnmodifiableListView<SocialMedia> get favourites { return UnmodifiableListView(_socialMedia.where((item) => item.isFavourite)); } UnmodifiableListView<SocialMedia> get all { return UnmodifiableListView(_socialMedia); } void setFavourite(int itemId, bool isChecked) { _socialMedia .firstWhere((item) => item.id == itemId) .setFavourite(isChecked); notifyListeners(); }
Any change in this object, which will require rebuilding on the view, must be signalized using notifyListeners(). In the case of the setFavourite() method to instruct Flutter to re-render the UI fragment, that will observe the change in this object.
Now we can move on to creating the list. To fill the ListView with elements, we will need to get to the SocialMediaModel object, which stores a list of all the elements. You can do it in two ways:
- Provider.of<ModelType>(context, listen: false)
- Consumer
The first one provides the observed object and allows us to decide whether the action performed on the object should rebuild the current widget, using the listen parameter. This behavior will be useful in our case.
class SocialMediaListScreen extends StatelessWidget { SocialMediaListScreen(); @override Widget build(BuildContext context) { var socialMedia = Provider.of<SocialMediaModel>(context, listen: false); return ListView( children: socialMedia.all .map((item) => CheckboxSocialMediaItem(item: item)) .toList(), ); } }
We need a list of all social media but there is no need to rebuild the entire list. Let’s take a look at what the list item widget looks like.
class CheckboxSocialMediaItem extends StatelessWidget { final SocialMedia item; CheckboxSocialMediaItem({Key key, @required this.item}) : super(key: key); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(Dimens.paddingDefault), child: Row( children: [ Consumer<SocialMediaModel>( builder: (context, model, child) { return Checkbox( value: item.isFavourite, onChanged: (isChecked) => model.setFavourite(item.id, isChecked), ); }, ), SocialMediaItem( item: item, ) ], ), ); } }
We listen to the change in the checkbox’s value and update the model based on the check state. The checkbox value itself is set using property from the data model. This means that after selection, the model will change the isFavourite field to true. However, the view will not present this change until we rebuild the checkbox. Here, a Consumer object comes with help. It provides the observed object and rebuilds all of his descendants after receiving information about the change in the model.
It is worth placing Consumer only where it is necessary to update the widget in order to avoid unnecessary rebuild views. Please note that if, for example, the checkbox selection will trigger some additional action like changing the title of the item, Consumer would have to be moved higher in the widget tree, so as to become the parent of the widget responsible for displaying the title. Otherwise, the title view will not be updated.
Creating a favorite social media screen will look similar. We will get a list of favorite items using Provider.
class FavouritesListScreen extends StatelessWidget { FavouritesListScreen(); @override Widget build(BuildContext context) { var list = Provider.of<SocialMediaModel>(context, listen: false).favourites; return ListView( children: list .map((item) => Padding( padding: const EdgeInsets.all(Dimens.paddingDefault), child: SocialMediaItem(item: item))) .toList(), ); } }
When the build method is called, the Provider will return the current list of favorite social media.
How to create a list with BLoC
In our simple application, we have two screens so far. Each of them will have its own BLoC object. However, keep in mind that the selected items on the main screen are to appear on the list of favorite social media. Therefore, we must somehow transfer checkbox selection events outside of the screen. The solution is to create an additional BLoC object that will handle events that affect the state of many screens. Let’s call it global BLoC. Then, BLoC objects assigned to individual screens will listen for changes in global BLoC states and respond accordingly.
Before you create a BLoC object, you should first think about what events the view will be able to send to the BLoC layer and what states it will respond to. In the case of global BLoC, events and states will be as follows:
abstract class SocialMediaEvent {} class CheckboxChecked extends SocialMediaEvent { final bool isChecked; final int itemId; CheckboxChecked(this.isChecked, this.itemId); } abstract class SocialMediaState {} class ListPresented extends SocialMediaState { final List<SocialMedia> list; ListPresented(this.list); }
The CheckboxChecked event must be in the global BLoC, because it will affect the state of many screens – not just one. When it comes to states, we have one in which the list is ready to display. From the point of the global BLoC view, there is no need to create more states. Both screens should display the list and the individual BLoCs dedicated to the specific screen should take care of it. The implementation of the global BLoC itself will look like this:
class SocialMediaBloc extends Bloc<SocialMediaEvent, SocialMediaState> { final SimpleSocialMediaRepository repository; SocialMediaBloc(this.repository); @override SocialMediaState get initialState => ListPresented(repository.getSocialMedia); @override Stream<SocialMediaState> mapEventToState(SocialMediaEvent event) async* { if (event is CheckboxChecked) { yield _mapCheckboxCheckedToState(event); } } SocialMediaState _mapCheckboxCheckedToState(CheckboxChecked event) { final updatedList = (state as ListPresented).list; updatedList .firstWhere((item) => item.id == event.itemId) .setFavourite(event.isChecked); return ListPresented(updatedList); } }
The initial state is ListPresented – we assume that we have already received data from the repository. We only need to respond to one event – CheckboxChecked. So we will update the selected element using the setFavourite method and send the new list wrapped in ListPresented state.
Now we need to send the CheckboxChecked event when taping on the checkbox. To do this, we will need an instance of SocialMediaBloc in a place where we can attach the onChanged callback. We can get this instance using BlocProvider – it looks similar to Provider from the pattern discussed above. For such a BlocProvider to work, higher in the widget tree, you must initialize the desired BLoC object. In our example, it will be done in the main method:
void main() => runApp(BlocProvider( create: (context) { return SocialMediaBloc(SimpleSocialMediaRepository()); }, child: ArchitecturesSampleApp()));
Thanks to this, in the main list code, we can easily call BLoC using BlocProvider.of () and send an event to it using the add method:
class SocialMediaListScreen extends StatefulWidget { _SocialMediaListState createState() => _SocialMediaListState(); } class _SocialMediaListState extends State<SocialMediaListScreen> { @override Widget build(BuildContext context) { return BlocBuilder<SocialMediaListBloc, SocialMediaListState>( builder: (context, state) { if (state is MainListLoaded) { return ListView( children: state.socialMedia .map((item) => CheckboxSocialMediaItem( item: item, onCheckboxChanged: (isChecked) => BlocProvider.of<SocialMediaBloc>(context) .add(CheckboxChecked(isChecked, item.id)), )) .toList(), ); } else { return Center(child: Text(Strings.emptyList)); } }, ); } }
We already have CheckboxChecked event propagation to BLoC, we also know how BLoC will respond to such an event. But actually… what will cause the list to rebuild with the checkbox already selected? Global BLoC does not support changing list states, because it is handled by individual BLoC objects assigned to screens. The solution is the previously mentioned listening to a global BLoC for changing the state and responding according to this state. Below, the BLoC dedicated to the main social media list with a checkbox:
class SocialMediaListBloc extends Bloc<SocialMediaListEvent, SocialMediaListState> { final SocialMediaBloc mainBloc; SocialMediaListBloc({@required this.mainBloc}) { mainBloc.listen((state) { if (state is ListPresented) { add(ScreenStart(state.list)); } }); } @override SocialMediaListState get initialState => MainListEmpty(); @override Stream<SocialMediaListState> mapEventToState( SocialMediaListEvent event) async* { switch (event.runtimeType) { case ScreenStart: yield MainListLoaded((event as ScreenStart).list); break; } } }
When the SocialMediaBloc returns the state ListPresented, SocialMediaListBloc will be notified. Note that ListPresented conveys a list. It’s the one that contains updated information about checking the item with the checkbox.
Similarly, we can create a BLoC dedicated to the favorites social media screen:
class FavouritesListBloc extends Bloc<FavouritesListEvent, FavouritesListSate> { final SocialMediaBloc mainBloc; FavouritesListBloc({@required this.mainBloc}) { mainBloc.listen((state) { if (state is ListPresented) { add(FavouritesScreenStart(state.list)); } }); } @override FavouritesListSate get initialState => FavouritesListEmpty(); @override Stream<FavouritesListSate> mapEventToState(FavouritesListEvent event) async* { if (event is FavouritesScreenStart) { var favouritesList = event.list.where((item) => item.isFavourite).toList(); yield FavouritesListLoaded(favouritesList); } } }
Changing the state in the global BLoC results in firing the FavouritesScreenStart event with the current list. Then, items one marks as favorites are filtered and such a list displays on the screen.
How to create a form with many fields in Flutter
Long forms can be tricky, especially when the requirements assume different validation variants, or some changes on the screen after entering the text. On the example screen, we have a form consisting of several fields and the “NEXT” button. The fields will be automatically validating and the button disabled until the form is fully valid. After clicking the button, a new screen will open with the data entered in the form.
We have to validate each field and check the entire form correction to properly set the button state. Then, the collected data will need to be stored for the next screen.
How to create a form with many fields with Provider
In our application, we will need a second ChangeNotifier, dedicated to the personal info screens. We can therefore use the MultiProvider, where we provide a list of ChangeNotifier objects. They will be available to all descendants of MultiProvider.
class ArchitecturesSampleApp extends StatelessWidget { final SimpleSocialMediaRepository repository; ArchitecturesSampleApp({Key key, this.repository}) : super(key: key); @override Widget build(BuildContext context) { return MultiProvider( providers: [ ChangeNotifierProvider<SocialMediaModel>( create: (context) => SocialMediaModel(repository), ), ChangeNotifierProvider<PersonalDataNotifier>( create: (context) => PersonalDataNotifier(), ) ], child: MaterialApp( title: Strings.architecturesSampleApp, debugShowCheckedModeBanner: false, home: StartScreen(), routes: <String, WidgetBuilder>{ Routes.socialMedia: (context) => SocialMediaScreen(), Routes.favourites: (context) => FavouritesScreen(), Routes.personalDataForm: (context) => PersonalDataScreen(), Routes.personalDataInfo: (context) => PersonalDataInfoScreen() }, ), ); } }
In this case, PersonalDataNotifier will be acting as a business logic layer – he will be validating fields, having access to the data model for its update, and updating the fields on which the view will depend.
The form itself is a very nice API from Flutter, where we can automatically attach validations using the property validator and save the data from the form to the model using the onSaved callback. We will delegate validation rules to PersonalDataNotifier and when the form is correct, we will pass the entered data to it.
The most important thing on this screen will be listening for a change in each field and enabling or disabling the button, depending on the validation result. We will use callback onChange from the Form object. In it, we will first check the validation status and then pass it to PersonalDataNotifier.
Form( key: _formKey, autovalidate: true, onChanged: () => _onFormChanged(personalDataNotifier), child: void _onFormChanged(PersonalDataNotifier personalDataNotifier) { var isValid = _formKey.currentState.validate(); personalDataNotifier.onFormChanged(isValid); }
In PersonalDataNotifier, we will prepare isFormValid variable. We will modify it (do not forget to call notifyListeners()) and in the view, we will change the button state depending on its value. Remember to obtain the Notifier instance with the parameter listen: true – otherwise, our view will not rebuild and the button state will remain unchanged.
var personalDataNotifier = Provider.of<PersonalDataNotifier>(context, listen: true);
Actually, given the fact that we use personalDataNotifier in other places, where reloading the view is not necessary, the above line is not optimal and should have the listen parameter set to false. The only thing we want to reload is the button, so we can wrap it in a classic Consumer:
Consumer<PersonalDataNotifier>( builder: (context, notifier, child) { return RaisedButton( child: Text(Strings.addressNext), onPressed: notifier.isFormValid ? /* action when button is enabled */ : null, color: Colors.blue, disabledColor: Colors.grey, ); }, )
Thanks to this, we don’t force other components to reload each time we use a notifier.
In the view displaying personal data, there will be no more problems – we have access to PersonalDataNotifier and from there, we can download the updated model.
How to create a form with many fields with BLoC
For the previous screen we needed two BLoC objects. So when we’re adding another “double screen”, we’ll have four altogether. As in the case of Provider, we can handle it with MultiBlocProvider, which works almost identically.
void main() => runApp( MultiBlocProvider(providers: [ BlocProvider( create: (context) => SocialMediaBloc(SimpleSocialMediaRepository()), ), BlocProvider( create: (context) => SocialMediaListBloc( mainBloc: BlocProvider.of<SocialMediaBloc>(context))), BlocProvider( create: (context) => PersonalDataBloc(), ), BlocProvider( create: (context) => PersonalDataInfoBloc( mainBloc: BlocProvider.of<PersonalDataBloc>(context)), ) ], child: ArchitecturesSampleApp()), );
As in the BLoC pattern, it’s best to start with the possible states and actions.
abstract class PersonalDataState {} class NextButtonDisabled extends PersonalDataState {} class NextButtonEnabled extends PersonalDataState {} class InputFormCorrect extends PersonalDataState { final PersonalData model; InputFormCorrect(this.model); }
What is changing on this screen is the button state. We therefore need separate states for it. In addition, the InputFormCorrect state will allow us to send the data the form has collected.
abstract class PersonalDataEvent {} class FormInputChanged extends PersonalDataEvent { final bool isValid; FormInputChanged(this.isValid); } class FormCorrect extends PersonalDataEvent { final PersonalData formData; FormCorrect(this.formData); }
Listening to changes in the form is crucial, hence the FormInputChanged event. When the form is correct, the FormCorrect event will be sent.
When it comes to validations, there is a big difference here if you compare it to Provider. If we would like to enclose all validation logic in the BLoC layer, we would have a lot of events for each of the fields. Additionally, many states that would require the view to show validation messages.
This is, of course, possible but it would be like a fight against the TextFormField API instead of using its benefits. Therefore, if there are no clear reasons, you can leave validations in the view layer.
Button state will depend on the state sent to the view by BLoC:
BlocBuilder<PersonalDataBloc, PersonalDataState>( builder: (context, state) { return RaisedButton( child: Text(Strings.addressNext), onPressed: state is NextButtonEnabled ? /* action when button is enabled */ : null, color: Colors.blue, disabledColor: Colors.grey, ); })
Event handling and mapping to states in PersonalDataBloc will be as follows:
@override Stream<PersonalDataState> mapEventToState(PersonalDataEvent event) async* { if (event is FormCorrect) { yield InputFormCorrect(event.formData); } else if (event is FormInputChanged) { yield mapFormInputChangedToState(event); } } PersonalDataState mapFormInputChangedToState(FormInputChanged event) { if (event.isValid) { return NextButtonEnabled(); } else { return NextButtonDisabled(); } }
As for the screen with a summary of personal data, the situation is similar to the previous example. BLoC attached to this screen will retrieve model information from the BLoC of the form screen.
class PersonalDataInfoBloc extends Bloc<PersonalDataInfoEvent, PersonalDataInfoState> { final PersonalDataBloc mainBloc; PersonalDataInfoBloc({@required this.mainBloc}) { mainBloc.listen((state) { if (state is InputFormCorrect) { add(PersonalDataInfoScreenStart(state.model)); } }); } @override PersonalDataInfoState get initialState => InfoEmpty(); @override Stream<PersonalDataInfoState> mapEventToState(PersonalDataInfoEvent event) async* { if (event is PersonalDataInfoScreenStart) { yield InfoLoaded(event.model); } } }
Flutter architecture: notes to remember
The examples above are sufficient to show that there are clear differences between the two architectures. BLoC separates the view layer from business logic very well. This entails better reusability and testability. It seems that to handle simple cases, you need to write more code than in Provider. As you know, in that case, this Flutter architecture will become more useful as the complexity of the application increases.
Provider also separates UI from logic well and does not force the creation of separate states with each user interaction, which means that often you do not have to write a large amount of code to handle a simple case. But this can cause problems in more complex cases.
Click here to check out the entire project.