Provider-based Dependency Injection in Flutter
Part 6 in my “Flutter from a complete beginner” series.
Tiger asks … what’s “the flutter way” to do DI?
Dependency Injection (DI) is a bit of a difficult topic for somebody starting with Flutter.
While the need for and benefit of DI are undisputed, the same cannot be said for the tools used.
- some use plain provider
- some use get_it
- some pair get_it with injectable to generate the boilerplate code
- then there’s kiwi
- others prefer to rely on the built-in DI-capabilities of whatever state-management framework they happen to be using, such as
-BlocProvider
for bloc users, or
-Provider
for riverpod users - where yet others combine them with some of the approaches listed earlier
Flutter just hasn’t yet had the time to crystallise a “gold standard” when it comes to dependency injection.
Bloc
and Riverpod
are overkill for a simple first app. I also hope that going without them first will help me understand Flutter better and make understanding Bloc
and Riverpod
easier, if/when the time comes.
That still leaves me with some options, but get_it
and kiwi
“feel” like they are working against the Flutter framework, rather than with it.
I do not want to circumvent Flutter, because doing it the native way
- helps me understand Flutter — after all, it’s probably designed the way it is for a reason
- hopefully will make it easier to understand auxilliary frameworks like
Bloc
and/orRiverpod
when the time comes to look into them because they are built on top of providers - helps me understand how and why other approaches differ and what advantages / disadvantages they have
- hopefully allows Flutter to do more optimisations
With that in mind, let’s do a deep dive into providers-based dependency injection.
I’ll also demonstrate the approach by rewriting the counter demo app we’ve been modifying so far.
The Flutter way of doing things
As a beginner, my understanding of “the Flutter way of doing things” is of course quite rough, but one thing I’ve already learned is that Flutter likes to pull shared state up into the parent widgets. Everything flows downwards.
So in the extreme case of global state, to borrow a picture from Flutter:
Remember, everything is a widget, in Flutter.
So what Fig.1 tells me is that there should be a parent widget somewhere upstream in the widget tree that provides the required services that the widgets further down the widget tree need.
In spite of popular belief, Flutter does actually come with dependency-injection capabilities out of the box. Those take the form of the BuildContext
that can be queried for widgets of a particular type.
Widget Injection
But how do we get those widgets from upstream in the widget tree for us in our own widgets?
Finding a widget from upstream
Widgets query the BuildContext
for an instance from upstream the widget tree, using e.g. BuildContext.dependOnInheritedWidgetOfExactType
.
By convention, we wrap these calls into a static of
/ maybeOf
call s.t. the initialisation logic can be hidden from the clients who can just call MyWidget.of(context)
.
This allows the class to define its own fallback logic in case there isn’t a widget in scope.
Typically,
maybeOf
returnsnull
if the widget isn’t foundof
returns a non-nullable instance and throws an exception if none is found. Typically callsmaybeOf
internally.
And the return type doesn’t necessarily have to be the widget class on which the static methods are defined.
The documentation mentions:
For example,
Theme
is implemented as aStatelessWidget
that builds a private inherited widget;Theme.of
looks for that private inherited widget and then returns theThemeData
inside it.
And if you have trouble imagining it, here’s the source code for Theme.of
:
static ThemeData of(BuildContext context) {
final _InheritedTheme? inheritedTheme = context.dependOnInheritedWidgetOfExactType<_InheritedTheme>();
final MaterialLocalizations? localizations = Localizations.of<MaterialLocalizations>(context, MaterialLocalizations);
final ScriptCategory category = localizations?.scriptCategory ?? ScriptCategory.englishLike;
final ThemeData theme = inheritedTheme?.theme.data ?? _kFallbackTheme;
return ThemeData.localize(theme, theme.typography.geometryThemeFor(category));
}
(cf. https://github.com/flutter/flutter/blob/bae5e49bc2/packages/flutter/lib/src/material/theme.dart#L104 )
Depending on inherited widgets
You’ve probably noticed the dependOnInheritedWidgetOfExactType
, forgot that we briefly talked about them in the first part in this series and wondered “well what’s that now, then?” and what do they mean by “depend”?
This is where I remind you that I have a blog post on the life cycle of state objects and that you may want to look at it for a brief refresher of what happens when. Namely, the important thing to note is
State.didChangeDependencies
is called once to initialise and then again when anInheritedWidget
theState
is dependent on changes or is moved.
How does a state know which widgets it needs to watch and how does the widget know to invoke whose states listeners?
Well, that’s where the InheritedWidget
comes into place. It’s a widget that keeps track of which widgets “depend on” it and will cause those to be rebuilt when it itself changes.
Because this is much more useful than simply finding an arbitrary widget, flutter has optimised the finding functions for inherited widgets in constant time.
BuildContext.getElementForInheritedWidgetOfExactType
finds an upstreamInheritedWidget
but does not register the caller for future updates.BuildContext.dependsOnInheritedWidgetOfExactType
finds an upstreamInheritedWidget
and does register the caller for future updates
Both of these methods
- run in
O(1)
time — “with small constant factor”, as the documentation tells us - should not be called from widget constructors or from
State.initState
, because those methods are not invoked again on a rebuild - should not be called from
State.dispose
because the element tree is no longer stable at that time - are safe to use from
State.deactivate
- are most often called from
State.build
,State.didChangeDependencies
orState.didUpdateWidget
(Note that using different methods to find a widget that isn’t an InheritedWidget
[e.g. BuildContext.findAncestorStateOfType
] runs in O(N)
in the depth of the widget tree and isn’t safe to use from State.deactivate
)
Depending on parts of an InheritedWidget
Getting rebuilt when anything about the InheritedWidget
changes is sometimes overkill.
Often, you’re fine with everything else about a widget’s state changing, so long as that one thing that you actually care about stays the same. And there’s two subclasses that are designed to help with that.
InheritedModel
s are InheritedWidget
s that expose a list of “aspects” that can be individually listened to.
InheritedListener
s are InheritedWidget
s that listen to a Listenable
and update their dependents when
Small InheritedModel
example
So knowing this, how would we use InheritedModel
s? I’ll use the example from the documentation where we have a logo that has a changable size (large or not) and a changeable background colour (any Color
), but I will break it down and rewrite the code a bit, where I think it helps:
- define an enum to define the “aspects” of the widget that should be subscribable
enum LogoAspect { backgroundColor, large }
2. subclass InheritedModel
with that enum and add properties to reflect those aspects
class LogoModel extends InheritedModel<LogoAspect> {
const LogoModel({
super.key,
this.backgroundColor,
this.large,
required super.child,
});
final Color? backgroundColor;
final bool? large;
}
what is that required super.child
?
It’s the widget tree that actually contains the widgets somewhere downstream, that actually use the model. I.e. in this case, it’s very simple:
LogoModel(
backgroundColor: color,
large: large,
child: const BackgroundWidget(
child: LogoWidget(),
),
),
InheritedWidget
s (including InheritedModel
s) extend ProxyWidget
, which is a widget that does not itself build a widget but instead displays its child.
3. add static xyzOf(context)
functions to properly
class LogoModel extends InheritedModel<LogoAspect> {
// constructor
final Color? backgroundColor;
final bool? large;
static Color? backgroundColorOf(BuildContext context) {
return InheritedModel
.inheritFrom<LogoModel>(context, aspect: LogoAspect.backgroundColor)
?.backgroundColor
;
}
static bool sizeOf(BuildContext context) {
return InheritedModel
.inheritFrom<LogoModel>(context, aspect: LogoAspect.large)
?.large
?? false
;
}
}
Note that InheritedModel.inheritFrom
tries to depend on each LogoModel
it finds upstream in turn until it finds one for which InheritedModel.isSupportedAspect
is true
. By default, that will be the first LogoModel
it finds.
4. override updateShouldNotify
method to determine when an update even needs to be propagated
class LogoModel extends InheritedModel<LogoAspect> {
// constructor
final Color? backgroundColor;
final bool? large;
// static xyzOf(context) functions
@override
bool updateShouldNotify(LogoModel oldWidget) {
var backgroundChanged = backgroundColor != oldWidget.backgroundColor;
var sizeChanged = large != oldWidget.large;
return backgroundChanged || sizeChanged;
}
}
5. implement updateShouldNotifyDependent
to determine for each listener whether we need to actually call it
class LogoModel extends InheritedModel<LogoAspect> {
// constructor
final Color? backgroundColor;
final bool? large;
// xyzOf(context) functions
@override
bool updateShouldNotify(LogoModel oldWidget) {
var backgroundChanged = backgroundColor != oldWidget.backgroundColor;
var sizeChanged = large != oldWidget.large;
return backgroundChanged || sizeChanged;
}
@override
bool updateShouldNotifyDependent(
LogoModel oldWidget,
Set<LogoAspect> dependencies,
) {
return dependencies.any((element) => switch(element){
LogoAspect.backgroundColor => backgroundColor != oldWidget.backgroundColor,
LogoAspect.large => large != oldWidget.large,
});
}
}
The difference between updateShouldNotify
and updateShouldNotifyDependent
should be clear:
updateShouldNotify
: “has something changed that requires me to notify a subscriber?”updateShouldNotifyDependent
: “has something changed that requires me to notify that subscriber?”
6. finally subscribe to the aspects
We have seen earlier, that the widget subtree starting from the LogoModel
will look like
LogoModel(
backgroundColor: color,
large: large,
child: const BackgroundWidget(
child: LogoWidget(),
),
),
the BackgroundWidget
and LogoWidget
then use the xyzOf(context)
methods to listen only to those changes that are relevant to them. I.e.
class LogoWidget extends StatelessWidget {
const LogoWidget({super.key});
@override
Widget build(BuildContext context) {
final bool largeLogo = LogoModel.sizeOf(context);
return AnimatedContainer(
padding: const EdgeInsets.all(20.0),
duration: const Duration(seconds: 2),
curve: Curves.fastLinearToSlowEaseIn,
alignment: Alignment.center,
child: FlutterLogo(size: largeLogo ? 200.0 : 100.0),
);
}
}
only listens to LogoModel.large
because that’s all it needs to know about, and
class BackgroundWidget extends StatelessWidget {
const BackgroundWidget({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final Color color = LogoModel.backgroundColorOf(context)!;
return AnimatedContainer(
padding: const EdgeInsets.all(12.0),
color: color,
duration: const Duration(seconds: 2),
curve: Curves.fastOutSlowIn,
child: child,
);
}
}
only listens to LogoModel.backgroundColor
because that’s all it cares about.
Note, that even though LogoWidget
and BackgroundWidget
change based on the state contained in LogoModel
, they are both StatelessWidgets
.
7. actually build the widget tree
class InheritedModelExample extends StatefulWidget {
const InheritedModelExample({super.key});
@override
State<InheritedModelExample> createState() => _InheritedModelExampleState();
}
class _InheritedModelExampleState extends State<InheritedModelExample> {
bool large = false;
Color color = Colors.blue;
@override
Widget build(BuildContext context) {
return Scaffold(
body: LogoModel(
backgroundColor: color,
large: large,
child: const BackgroundWidget(
child: LogoWidget(),
),
),
);
}
}
Key thing to note, here:
- the
LogoModel
itself is stateless and is switched out when the state changes
This confused me for a moment because widgets are supposed to be immutable. I.e. switching out the LogoModel
here should also create a new BackgroundWidget
with a new LogoWidget
in the widget tree.
But then if they are created anew, what’s the point in subscribing to LogoModel
changes by depending on it?
This is one of the instances where we need to remember that flutter language isn’t beginner friendly:
Widgets represent the configuration of Elements.
Each Element has a widget, specified in Element.widget. The term “widget” is often used when strictly speaking “element” would be more correct.While an Element has a current Widget, over time, that widget may be replaced by others.
SO what I expect to happen is that:
- the widgets do indeed get swapped out, but the elements do not.
- Because the widget subtree under
LogoModel
isconst
, swapping out those widgets does not actually trigger a rebuild on that subtree. - Swapping out the
LogoModel
widget itself, however, triggers a change in its underlying element’s state, - which is then propagated to the elements instantiated by the
BackgroundWidget
andLogoWidget
, because they declared a dependency on changed aspects of theLogoModel
- which then triggers a rebuild on the element tree,
- which then asks the widgets how to update itself
A bit confusing, but I think I’m starting to make sense of this.
Using providers
As you may have noticed, using InheritedWidget
directly is a bit cumbersome.
The provider package was specifically designed to deal with that. Quote:
A wrapper around
InheritedWidget
to make them easier to use and more reusable.By using provider instead of manually writing
InheritedWidget
, you get:- simplified allocation/disposal of resources
- lazy-loading
- a vastly reduced boilerplate over making a new class every time
- devtool friendly — usingProvider
, the state of your application will be visible in the Flutter devtool
- a common way to consume theseInheritedWidgets
(SeeProvider.of
/Consumer
/Selector
)
- increased scalability for classes with a listening mechanism that grows exponentially in complexity (such asChangeNotifier
, which is O(N) for dispatching notifications).
So let’s have a closer look at how providers simplify things.
Provider
The value held by an InheritedWidget
must be final and constructor-initialised.
A Provider
voids that limitation by exposing the value to the flutter lifecycle without having to create a full new subclass of a StatefulWidget
.
class MyModel {
void dispose() {}
}
class MySomething extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Provider<MyModel>(
create: (context) => MyModel(),
dispose: (context, value) => value.dispose(),
child: ...,
);
}
}
Here, the value held by the provider is of type MyModel
. It is initialised in the create
function and when the provider is removed from the widget tree, a clean-up can be performed with the dispose
function.
lazy loading
By default, the provider’s create
function is called the first time the value is read — i.e. “lazily” — rather than when the provider is inserted into the.
To change that, you can set lazy: false
in the Provider
constructor.
Provider.of
Provider.of
takes two arguments: a BuildContext
and a listen
boolean.
Provider(
create: (context) {
return MyModel(Provider.of<Something>(context, listen: false)),
},
)
Behind the scenes, providers use the InheritedWidget
, so this function abstracts the two ways of getting such anInheritedWidget
:
listen:true
=>dependOnInheritedWidgetOfExactType
, which subscribes to changeslisten:false
=>getElementForInheritedWidgetOfExactType
, which doesn’t
Important: if a provider cannot be found, an exception will be thrown.
Provider.value
If a value is already created, but not exposed, the Provider.value
can be used to construct a provider that returns the value and does nothing when Provider.dispose
is called.
final foo = MockFoo();
await tester.pumpWidget(
Provider<Foo>.value(
value: foo,
child: TestedWidget(),
),
);
The documentation notes on this particular example:
Since we used a mocked class (typically using
mockito
), [we] have to downcast the mock to the type of the mocked class. Otherwise, the type inference will resolve toProvider<MockFoo>
instead ofProvider<Foo>
, which will causeProvider.of<Foo>
to fail.
ChangeNotifier
In Flutter, Listenable
s are objects that provide notification there’s two main implementations of the interface:
ChangeNotifier
: listeners are void callbacksValueNotifier
: extendsChangeNotifier
, listeners receive “current value” in the callbacks
class CounterModel with ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count += 1;
notifyListeners();
}
}
Important:
- the
ChangeNotifier
can be mixed into a class, theValueNotifier
must be extended ValueNotifier
should be used with immutable values because it only notifies when its value’s identity changes
(i.e. aValueNotifier<List<int>>
would not notify if its list‘s’ contents is changed rather than the list swapped out)
ChangeNotifierProvider
- listens to a
ChangeNotifier
- exposes it to descendants
- rebuilds dependents on
ChangeNotifier.notifyListeners
The ChangeNotifier
is either
- created in the
ChangeNotifierProvider
constructor’screate
function
ChangeNotifierProvider(
create: (_) => new MyChangeNotifier(),
child: ...
)
or
- re-used by being passed to the
ChangeNotifierProvider.value
constructor function
MyChangeNotifier variable;
ChangeNotifierProvider.value(
value: variable,
child: ...
)
Consumer
A Consumer<T>
calls Provider.of<T>(context)
and passes the so received value to its builder
function.
It has two purposes:
- obtain a value from a provider when we don’t have a
BuildContext
that is a descendant of said provider (and therefore cannot useProvider.of
) - help performance optimization by providing more granular rebuilds
How?
Pass on provider
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => Foo(),
child: Consumer<Foo>(
builder: (_, foo, __) => Text(foo.value),
},
);
}
Here, if we had put child: Text(Provider.of<Foo>(context))
, the context
instance provided to the build
function cannot find the ChangeNotifierProvider
created in its body. By using a Consumer
, the builder
function is called with the Consumer
's BuildContext
instance — which because it’s a descendant of the ChangeNotifierProvider
, can find the provider.
Performance optimisations
@override
Widget build(BuildContext context) {
return Consumer<Foo>(
builder: (context, foo, child) => FooWidget(foo: foo, child: child),
child: BarWidget(),
);
}
when Foo
changes upstream, only the builder
is rebuilt, not the child
.
Selector
Selector
is similar to Consumer
in that it calls Provider.of
on its context. But rather than passing the provided value on to the builder
function directly (as a Consumer
would), it first passes it on to a selector
function that returns a value or a collection of values that the builder
function then uses.
Selector<Foo, ({String item1, String item2})>(
selector: (_, foo) => (item1: foo.item1, item2: foo.item2),
builder: (_, data, __) {
return Text('${data.item1} ${data.item2}');
},
);
that way, builder
is only called when at least one of the selected properties changed.
By default, it uses DeepCollectionEquality
to determine whether the builder needs to be called again, but this can be changed by setting the shouldRebuild
function in the constructor.
MultiProvider
Very simple: instead of
Provider<Foo>(
create: (_) => Foo(),
child : Consumer<Foo>(
builder: (context, foo, child) =>
Provider.value(value: foo.bar, child: child),
child: Provider<Baz>(
create: (_) => Baz(),
child: ...,
),
),
),
we can just do
MultiProvider(
providers: [
Provider(create: (_) => Foo()),
Consumer<Foo>(
builder: (context, foo, child) =>
Provider.value(value: foo.bar, child: child),
),
Provider(create: (_) => Baz()),
],
);
Other providers you should have heard of
ProxyProvider
— a provider thatupdate
s itself whenever a provider it depends on is updatedChangeNotifierProxyProvider
— likeProxyProvider
, but updating aChangeNotifier
StreamProvider
— exposes a stream’s contents to descendantsFutureProvider
— listens to and exposes aFuture
An over-engineered example
So far, we’ve had a lot of theory, I think it’s time we tried to apply what we talked about.
We still have the counter demo app lying around, let’s make use of it and implement a very over-engineered example that makes use of as many providers as we can reasonably fit.
The idea is simple:
- providers for globally available services are at the very top of the widget tree
- the repository guards access to the data (here the count)
- the service coordinates all repositories it needs (here just the one) to provide service functions to client models
- the models connects UI functions to appropriate service calls and performs calculations on the results, if necessary
How would that look?
InMemoryCountRepository
abstract class CountRepository {
int getCount();
void incrementCount();
}
class InMemoryCountRepository implements CountRepository {
int _count = 0;
@override
int getCount() {
return _count;
}
@override
void incrementCount() {
_count++;
}
}
CountService
import 'count_repository.dart';
class CountService {
final CountRepository _countRepository;
const CountService({
required CountRepository countRepository,
}) : _countRepository = countRepository;
int getCount() => _countRepository.getCount();
void incrementCount() => _countRepository.incrementCount();
}
Sigh … I miss Kotlin. That constructor is ugly af (and it’s going to get even worse, the more parameters there are).
Sadly, all I can do is use this opportunity to raise awareness for the corresponding github issue.
Still, we get a CountRepository
from the constructor and use it to provide service-level functions to the application.
Global
How do we get the repository into the service?
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'count_repository.dart';
import 'count_service.dart';
class Global extends StatelessWidget {
final Widget _child;
const Global({
super.key,
required Widget child,
}) : _child = child;
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider<CountRepository>(create: (_) => InMemoryCountRepository()),
Consumer<CountRepository>(
builder: (_, repository, child) => Provider(
create: (_) => CountService(countRepository: repository),
child: child,
),
),
],
child: _child,
);
}
}
Because Global
creates the provider for the InMemoryCountRepository
, the context
it is given does not know of it. We need to use a Consumer
and use its BuildContext
in its builder
function to retrieve the repository and construct our service provider.
Note that the CountService
knows nothing of the InMemoryCountRepository
, it just knows the CountRepository
interface. We can swap out the implementation as we please, with zero changes to the CountService
.
For this to work, both Provider
and Consumer
need to explicitly declare the <CountRepository>
, otherwise the provider resolves to Provider<InMemoryCountRepository>
, which cannot get picked up by a Consumer<CountRepository>
.
CounterPageModel
Ok, so we have a service through which we can increment and get the count, we have a repository that implements the interaction with whatever is holding the count (here itself).
Now we need something that holds the state of the counter page. Namely, we need a message and a message level.
import 'package:flutter/material.dart';
import 'package:widgets/text/messagelevel.dart';
import 'count_service.dart';
class CounterPageModel with ChangeNotifier {
final CountService _countService;
int _count;
CounterPageModel({
required CountService countService,
}) : _countService = countService,
_count = countService.getCount();
String get message => '$_count';
MessageLevel get level => switch (_count % 10) {
0 => MessageLevel.warning,
_ => MessageLevel.info,
};
void increment() {
_countService.incrementCount();
_count = _countService.getCount();
notifyListeners();
}
}
Easy enough: everything is handled by the service. We fetch the current count from the service and calculate our
CounterPage
Now we need the actual page itself.
class CounterPageController with ChangeNotifier {
void increment() {
notifyListeners();
}
}
class CounterPage extends StatelessWidget {
final CounterPageController? controller;
const CounterPage({super.key, this.controller});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ChangeNotifierProvider(
create: (_) {
var model = CounterPageModel(
countService: Provider.of<CountService>(context),
);
controller?.addListener(() {
model.increment();
});
return model;
},
child: Selector<CounterPageModel, (String, MessageLevel)>(
selector: (_, model) => (model.message, model.level),
builder: (_, data, __) => InfoPanel(
message: data.$1,
level: data.$2,
),
),
)
],
),
);
}
}
I will explain why we need the CounterPageController
in a moment.
For now, just note that the Selector
could just as well be a Consumer
, it’s just there to demonstrate how to use it.
The app
With all parts in place, we can now replace the default dummy app with our own:
import 'package:flutter/material.dart';
import 'package:tiger_tasks/provider_demo/global.dart';
import 'provider_demo/counter_page.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return Global(
child: MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Provider-based Counter Demo'),
),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
Widget build(BuildContext context) {
var controller = CounterPageController();
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(title),
),
body: CounterPage(
controller: controller,
),
floatingActionButton: FloatingActionButton(
onPressed: () {
controller.increment();
},
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
Here, we have a FloatingActionButton
that is not below the CounterPage
. Therefore, it cannot use a consumer to get to the CounterPageModel
encapsulated therein.
As was the case in the “Controlling the widget” section of part 5 in this series, this situation requires a controller.
Getting rid of the controller
If the FloatingActionButton
had been part of the CounterPage
, we wouldn’t need a controller. Can we move it there? Of course we can.
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => CounterPageModel(
countService: Provider.of<CountService>(context),
),
child: Stack(
alignment: AlignmentDirectional.bottomEnd,
children: [
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Selector<CounterPageModel, (String, MessageLevel)>(
selector: (_, model) => (model.message, model.level),
builder: (_, data, __) => InfoPanel(
message: data.$1,
level: data.$2,
),
),
],
),
),
Builder(
builder: (context) => Padding(
padding: const EdgeInsets.all(16),
child: FloatingActionButton(
onPressed: () {
Provider.of<CounterPageModel>(context, listen: false)
.increment();
},
tooltip: 'Increment',
child: const Icon(Icons.add),
),
),
),
],
),
);
}
}
The first thing to note is that the ChangeNotifierProvider
moved to the very top of the sub-tree returend by the CounterPage
's build
function.
I think it’s good practice anyway to have the providers at the top of the sub-tree, but here it’s actually necessary so both the display and the floating button can access it.
Next, we use a Stack
widget to lay the FloatingActionButton
on top of the rest of the interface and use its alignment:
property to control where we want to place our button.
Finally, we need to use the Builder
widget because again, the context
provided to the CounterPage
's build
function does not yet know about the provider created in the CounterPage
.
Note, that with the controller out of the way, we can now declare the entire MyHomePage
widget instance const
in our app:
import 'package:flutter/material.dart';
import 'package:tiger_tasks/provider_demo/global.dart';
import 'provider_demo/counter_page.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return Global(
child: MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Provider-based Counter Demo'),
),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(title),
),
body: const CounterPage(),
);
}
}
Side-note: update your stateful widgets
While implementing this, I had run into an interesting issue:
My notifiers were working as intended, my builder functions were being called, the correct values were propagated … but not displayed in the running app.
This issue took a while for me to understand. Why doesn’t my widget get repainted to show the updated state?
The thing is … it does. The problem is that the state wasn’t updated.
I intentionally over-engineered the widget in part 5 of this series (and I’m quite thankful I did because it allowed me to run into this in a small example). The InfoPanel
is stateful, even though it has no reason to be:
class _InfoPanelState extends State<InfoPanel> {
late InfoPanelController _controller;
late MessageLevel _level;
late String _message;
@override
void initState() {
assert(widget.message.isNotEmpty, 'InfoPanel message must not be empty.');
_level = widget.level;
_message = widget.message;
_controller = widget.controller ?? InfoPanelController();
_controller.addListener(() {
setState(() {
_level = _controller._level;
_message = _controller._message;
});
});
super.initState();
}
static const double _edgeInsets = 8;
static const double _borderRadius = 16;
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(_edgeInsets),
decoration: BoxDecoration(
color: theme._backgroundColor(_level),
border: Border.all(color: theme._borderColor(_level)),
borderRadius: BorderRadius.circular(_borderRadius),
),
child: Text(
_message,
style: theme._messageStyle(_level),
),
);
}
}
the problem now was that when we rebuilt the InfoPanel
widget, we updated the “initial state” on the widget (read in State.initState
), but we never propagated that change to the _InfoPanelState
.
What was missing was a State.didUpdateWidget
implementation
@override
void didUpdateWidget(InfoPanel oldWidget) {
super.didUpdateWidget(oldWidget);
_level = widget.level;
_message = widget.message;
}
Conclusion
Providers are value wrappers that allow listeners to subscribe to updates to the values they wrap. They are built on top of Flutter’s InheritedWidget
s and make working with them significantly easier.
Flutter comes with dependency injection capabilities that takes largely the form of querying the BuildContext
for a particular provider. The querying
We have also seen that using a provider-based approach can significantly reduce the amount of stateful widgets we need to implement in our app.
When designing a widget, keep in mind:
- always start with a provider (or
MultiProvider
) of your model(s), keep the UI in itschild:
property - the context given to the
build
function of the widget that creates the providers cannot be used to query for those providers (can only find other providers further up the widget tree) - use the
Builder
widget (or thebuilder
function of providers), where necessary, to access theBuildContext
that contains the created providers - use controllers if you need to yield control up the widget tree
- if you are forced to use controllers, double-check if there isn’t a better way to go about things. sometimes it’s fine, sometimes it’s a code smell
- for stateful widgets, don’t forget to implement
didDependenciesChange
anddidUpdateWidget
, where appropriate