Now that you know about declarative user interface programming and the difference between ephemeral state and application state , you are ready to learn how to easily manage application state.
We will use the package
provider
. If you're new to Flutter and don't have a compelling reason to choose a different approach (Redux, Rx, hooks, etc.), this is probably the best approach to get started. The package provider
is easy to learn and doesn't require a lot of code. He also operates with concepts that are applicable in all other approaches.
However, if you already have a lot of experience with managing state from other reactive frameworks, you can look for other packages and tutorials listed on the options page .
Example
Consider the following simple application as an example.
The application has two separate screens: catalog and shopping cart (represented by widgets
MyCatalog
and MyCart
respectively). In this case, this is a shopping app, but you can imagine the same structure in a simple social networking app (replace the catalog with โwallโ and cart with โfavoritesโ).
The catalog screen includes a customizable application bar (
MyAppBar
) and a scrolling view of multiple list items ( MyListItems
).
Here is the application in the form of a tree of widgets:
So, we have at least 5 subclasses
Widget
. Many of them need access to state that they do not own. For example, eachMyListItem
should be able to add yourself to the cart. They may also need to check if the currently displayed item is in the cart.
This brings us to our first question: where should we put the current state of the bucket?
Increasing condition
In Flutter, it makes sense to position state above the widgets that use it.
What for? In declarative frameworks like Flutter, if you want to change the user interface, you have to rebuild it. You can't just go and write
MyCart.updateWith(somethingNew)
. In other words, it is difficult to force change the widget from the outside by calling a method on it. And even if you could get it to work, you would be fighting the framework instead of letting it help you.
// :
void myTapHandler() {
var cartWidget = somehowGetMyCartWidget();
cartWidget.updateWith(item);
}
Even if you get the above code to work, you have to deal
MyCart
with the following in the widget :
// :
Widget build(BuildContext context) {
return SomeWidget(
// .
);
}
void updateWith(Item item) {
// - UI.
}
You will need to take into account the current state of the UI and apply the new data to it. It will be difficult to avoid mistakes here.
In Flutter, you create a new widget every time its content changes. Instead of
MyCart.updateWith(somethingNew)
(method call), you use MyCart(contents)
(constructor). Since you can only create new widgets in their parent's build methods, if you want to change contents
it must be in the parent MyCart
or higher.
//
void myTapHandler(BuildContext context) {
var cartModel = somehowGetMyCartModel(context);
cartModel.add(item);
}
Now
MyCart
has only one code execution path to create any version of the user interface.
//
Widget build(BuildContext context) {
var cartModel = somehowGetMyCartModel(context);
return SomeWidget(
// , .
// ยทยทยท
);
}
In our example, it
contents
should be in MyApp
. Each time it changes, it rebuilds the MyCart on top (more on that later). This MyCart
way you don't have to worry about the lifecycle - it just declares what to show for any given contents. When it changes, the old widget MyCart
will disappear and be completely replaced by the new one.
This is what we mean when we say that widgets are immutable. They don't change - they are replaced.
Now that we know where to put the bucket state, let's see how to access it.
State access
When a user clicks on one of the items in the catalog, it is added to the cart. But since the cart is over
MyListItem
, how do we do this?
A simple option is to provide a callback that
MyListItem
can be invoked on click. Dart functions are first class objects, so you can pass them in any way you want. So, internally, MyCatalog
you can define the following:
@override
Widget build(BuildContext context) {
return SomeWidget(
// , .
MyListItem(myTapCallback),
);
}
void myTapCallback(Item item) {
print('user tapped on $item');
}
This works fine, but for the application state that you need to change from many different places, you will have to pass a lot of callbacks, which becomes boring pretty quickly.
Fortunately, Flutter has mechanisms that allow widgets to provide data and services to their descendants (in other words, not only to their descendants, but any downstream widgets). As you would expect from a Flutter, where Everything is a Widget , these mechanisms are merely special kinds of widgets:
InheritedWidget
, InheritedNotifier
, InheritedModel
and others. We will not describe them here because they are slightly out of line with what we are trying to do.
Instead, we're going to use a package that works with low-level widgets but is easy to use. It's called
provider
.
With,
provider
you don't need to worry about callbacks or InheritedWidgets
. But you need to understand 3 concepts:
- ChangeNotifier
- ChangeNotifierProvider
- Consumer
ChangeNotifier
ChangeNotifier
Is a simple class included in the Flutter SDK that provides state change notification to its listeners. In other words, if something is ChangeNotifier
, you can subscribe to its changes. (This is a form of Observable - for those who are unfamiliar with the term.)
ChangeNotifier
In provider
is one way to encapsulate the state of the application. For very simple applications, you can get by with one ChangeNotifier
. In more complex ones, you will have several models and therefore several ChangeNotifiers
. (You don't need to use ChangeNotifier
with at all provider
, but this class is easy to work with.)
In our sample shopping app, we want to manage the state of the cart in
ChangeNotifier
. We create a new class that extends it, for example:
class CartModel extends ChangeNotifier {
/// .
final List<Item> _items = [];
/// .
UnmodifiableListView<Item> get items => UnmodifiableListView(_items);
/// ( , 42 ).
int get totalPrice => _items.length * 42;
/// [item] . [removeAll] - .
void add(Item item) {
_items.add(item);
// , , .
notifyListeners();
}
/// .
void removeAll() {
_items.clear();
// , , .
notifyListeners();
}
}
The only piece of code specific to
ChangeNotifier
is the call notifyListeners()
. Call this method every time the model changes in such a way that it can be reflected in the UI of your application. Everything else in CartModel
is the model itself and its business logic.
ChangeNotifier
is part of flutter:foundation
and does not depend on any higher level classes in Flutter. It's easy to test (you don't even need to use widget testing for that). For example, here's a simple unit test CartModel
:
test('adding item increases total cost', () {
final cart = CartModel();
final startingPrice = cart.totalPrice;
cart.addListener(() {
expect(cart.totalPrice, greaterThan(startingPrice));
});
cart.add(Item('Dash'));
});
ChangeNotifierProvider
ChangeNotifierProvider
Is a widget that provides an instance to ChangeNotifier
its children. It comes in a package provider
.
We already know where to place it
ChangeNotifierProvider
: above the widgets that need access to it. In case CartModel
it implies something above MyCart
and MyCatalog
.
You don't want to post
ChangeNotifierProvider
higher than necessary (because you don't want to pollute the scope). But in our case, the only widget that is over MyCart
and MyCatalog
- it MyApp
.
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => CartModel(),
child: MyApp(),
),
);
}
Note that we are defining a constructor that creates a new instance
CartModel.
ChangeNotifierProvider
smart enough not to rebuild CartModel
unless absolutely necessary. It also automatically calls dispose () on the CartModel when the instance is no longer needed.
If you want to provide more than one class, you can use
MultiProvider
:
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => CartModel()),
Provider(create: (context) => SomeOtherClass()),
],
child: MyApp(),
),
);
}
Consumer
Now that it is
CartModel
provided to the widgets in our application via the declaration ChangeNotifierProvider
at the top, we can start using it.
This is done through a widget
Consumer
.
return Consumer<CartModel>(
builder: (context, cart, child) {
return Text("Total price: ${cart.totalPrice}");
},
);
We have to specify the type of model we want to access. In this case, we need it
CartModel
, so we write Consumer<
CartModel>
. If you don't specify generic ( <
CartModel>
), the package provider
won't be able to help you. provider
is type based and without the type it won't understand what you want.
The only required argument to the widget
Consumer
is builder
. Builder is a function that gets called on change ChangeNotifier
. (In other words, when you call notifyListeners()
on your model, all builder methods of all relevant widgets are Consumer
called.)
The constructor is called with three arguments. The first is
context
, which you also get in every build method.
The second argument to the builder function is an instance
ChangeNotifier
... This is what we asked for from the very beginning. You can use the model data to determine how the user interface should look at any given point.
The third argument is
child
, it is needed for optimization. If you have a large widget subtree under yours Consumer
that doesn't change when the model changes, you can build it once and get it via builder.
return Consumer<CartModel>(
builder: (context, cart, child) => Stack(
children: [
// SomeExhibitedWidget, .
child,
Text("Total price: ${cart.totalPrice}"),
],
),
// .
child: SomeExpensiveWidget(),
);
It is best to place your Consumer widgets as deep as possible in the tree. You don't want to rebuild large parts of the user interface just because some detail has changed somewhere.
//
return Consumer<CartModel>(
builder: (context, cart, child) {
return HumongousWidget(
// ...
child: AnotherMonstrousWidget(
// ...
child: Text('Total price: ${cart.totalPrice}'),
),
);
},
);
Instead of this:
//
return HumongousWidget(
// ...
child: AnotherMonstrousWidget(
// ...
child: Consumer<CartModel>(
builder: (context, cart, child) {
return Text('Total price: ${cart.totalPrice}');
},
),
),
);
Provider.of
Sometimes you don't really need the data in the model to change the user interface, but you still need access to it. For example, a button
ClearCart
allows the user to remove everything from the cart. It is not necessary to display the contents of the cart, just call the method clear()
.
We could use it
Consumer<
CartModel>
for that, but that would be wasteful. We would ask the framework to rebuild the widget, which doesn't need to be rebuilt.
For this use case, we can use
Provider.of
with the parameter listen
set to false
.
Provider.of<CartModel>(context, listen: false).removeAll();
Using the above line in the build method will not rebuild this widget when called
notifyListeners
.
Putting it all together
You can check out the example discussed in this article. If you need something a little simpler, check out what a simple Counter application created with provider looks like .
When you're ready to play with
provider
yourself, remember to add its dependency to yours first pubspec.yaml
.
name: my_name
description: Blah blah blah.
# ...
dependencies:
flutter:
sdk: flutter
provider: ^3.0.0
dev_dependencies:
# ...
Now you can
'package:provider/provider.dart'
; and start building ...