You probably know the TextField
widget and its TextEditingController
, which provides the possibility for the developer to control the behavior of the input (e. g. react to a change or clear the current input). But what if you create your own custom widget? How is it possible to implement such a controller that provides the possibility to control the widget from the outside?
What could be the use case?
Let’s say we have a widget that has a defined animation and we want this animation to start whenever the user taps a button. A concrete example: a circle that changes its color to a random color when we want it to. The control over when this should happen should lie outside of the widget because we want to be able to choose the trigger dynamically (it could be any event like a user tapping a button or the response of an HTTP request).
Basically, we want the widget to behave like this:
What is a controller?
But before we go into the details of how to implement such a thing let’s first look at what a controller actually is.
There are stateless and stateful widgets. A stateless widget is a static widget that does not manage its own state but gets the information injected that influences its appearance. On the other hand, a stateful widget is connected to a state and can change this state which causes the widget to re-render being influenced by the new information.
Sometimes, the widget itself is able to capture user interaction and updates its state accordingly. An example for this is the InkWell widget. The splash effect which this widget shows on tap is managed solely by the InkWell widget itself and we can’t create another widget like a second button and define that tapping this button triggers the splash effect of the InkWell widget.
Then there are widgets like TextField that expect a controller. That’s because the state is tightly coupled to the widget and not directly accessible from outside of the widget. This widget does not only display what it’s getting injected, but rather informs the listener of the controller about changes. If there was no controller, there would be no way to access the text. You could see it being entered into the field but had no possibility to work with it. Also, there’s the possibility to change it from a parent widget, like clearing the text.
Can’t we just provide a controller class that changes the state?
How do native widgets solve this problem?
Let’s look at how it’s done in the context of existing widgets like a ScrollController:
1class ScrollController extends ChangeNotifier
So the controller is a ChangeNotifier. What is that? The docs say:
A class that can be extended or mixed in that provides a change notification API using VoidCallback for notifications.
That looks like an implementation of the well-known observer pattern. Other classes can subscribe to changes to the observable. When the observable (in this case the ChangeNotifier) decides to notify its listeners, their callback is being executed. The ScrollController uses it to notify its listeners when the scoll position changes.
Let’s think about how we can use it to implement our random color changer. What we want is two things:
1.) Let the color changer widget know when it should start the color change animation
2.) Add the ability for other widgets to subscribe to the color change so that they can show the progress
So it’s a two-way communication: communicating the start command to the widget and communicating the current color back to the listener when it changes.
Implementation
1class ColorChangerController extends ChangeNotifier {
2 double value = 0.0;
3 bool isAnimating = false;
4 bool shouldStartAnimation = false;
5
6 void setValue(double value) {
7 this.value = value;
8 notifyListeners();
9 }
10
11 void changeColor() {
12 this.shouldStartAnimation = true;
13 notifyListeners();
14 }
15}
Let’s start by creating the controller of our widget. It has three member variables: value
and isAnimating
and shouldStartAnimation
as well as two public methods called setValue
and changeColor
. value
represents the progress of our animation (double between 0.0 and 1.0), isAnimating
determines whether the animation is currently running and shouldStartAnimation
is used to tell the widget whether it should begin a new animation cycle.
This is crucial because when notifyListeners()
is executed, both our ColorChanger
widget and the parent widget that created the controller instance will be notified. In order to make the ColorChanger
widget only start a new animation when the old one is done, we use the isAnimating
flag. Otherwise, every time the animation changed, it would call setValue()
which would then start a new animation, making it call setValue()
– an endless recursion leading to a stack overflow. shouldStartAnimation
is set to true when the changeColor()
method is called, which is the public API of the controller to trigger a new color animation.
1class _ColorChangerState extends State<ColorChanger> with SingleTickerProviderStateMixin {
2 AnimationController _animationController;
3 Color currentColor;
4 Animation<Color> colorAnimation;
5
6 @override
7 void initState() {
8 currentColor = _getRandomColor();
9
10 _animationController = AnimationController(
11 duration: const Duration(milliseconds: 1000),
12 vsync: this
13 );
14
15 widget.controller.addListener(() {
16 if (widget.controller.shouldStartAnimation && !widget.controller.isAnimating)
17 _startAnimation();
18 });
19
20 super.initState();
21 }
22
23 void _startAnimation() {
24 Color nextColor = _getRandomColor();
25
26 colorAnimation = ColorTween(
27 begin: currentColor,
28 end: nextColor
29 ).animate(_animationController);
30
31 widget.controller.shouldStartAnimation = false;
32 widget.controller.isAnimating = true;
33
34 _animationController.reset();
35 _animationController.forward();
36
37 colorAnimation.addListener(() {
38 widget.controller.setValue(_animationController.value);
39
40 if (colorAnimation.isCompleted) {
41 widget.controller.isAnimating = false;
42 }
43 });
44 }
45
46 Color _getRandomColor() => Color(
47 (Random().nextDouble() * 0xFFFFFF).toInt()
48 ).withOpacity(1.0);
Inside the initState()
method, we basically reset the animation and add a listener to our custom controller. Remember: there are two cases in which the custom controller notifies its listeners:
1.) When we restart the animation
2.) When the color changes
In order to distinguish the two, we check if the animation should start and is not currently playing. Only in this case, we start a new animation.
During the setup of the new animation controller, we take the current color value as the start and a new random color as the end. When the animation is completed, we notify our controller about that by setting isAnimating
to false.
Now we have a color value that is being animated when the controller is being triggered. Yet, we don’t display the color anywhere on the screen.
1@override
2 void dispose() {
3 _animationController.dispose();
4 widget.controller.dispose();
5 super.dispose();
6 }
7
8 @override
9 Widget build(BuildContext context) {
10 return colorAnimation == null ? Container(
11 decoration: BoxDecoration(
12 shape: BoxShape.circle,
13 color: currentColor,
14 )
15 ) : AnimatedBuilder(
16 animation: colorAnimation,
17 builder: (BuildContext context, Widget widget) {
18 return Container(
19 decoration: BoxDecoration(
20 shape: BoxShape.circle,
21 color: colorAnimation == null ? currentColor : colorAnimation.value,
22 ),
23 );
24 },
25 );
26 }
Let’s change that by putting a circle in our widget that displays the current color.
Okay if we start the app, we still see nothing. That’s because we don’t have any trigger to start the animation like a button. This is our test: can we put a button anywhere and let it trigger the start of the animation (communication from outside to the widget) and show the current progress (animation from the widget back to the outside)?
1class ColorChangerTest extends StatefulWidget {
2 @override
3 _ColorChangerTestState createState() => _ColorChangerTestState();
4}
5
6class _ColorChangerTestState extends State<ColorChangerTest> {
7 ScrollController textEditingController = ScrollController();
8 ColorChangerController controller = ColorChangerController();
9 int value = 0;
10 Color color = Colors.transparent;
11
12 @override
13 void initState() {
14 controller.addListener(() {
15 setState(() {
16 value = (controller.value * 100).round();
17 color = controller.color;
18 });
19 });
20 super.initState();
21 }
22
23 @override
24 Widget build(BuildContext context) {
25 return Scaffold(
26 body: Center(
27 child: Column(
28 mainAxisAlignment: MainAxisAlignment.center,
29 children: <Widget>[
30 SizedBox(
31 height: 96,
32 width: 96,
33 child: ColorChanger(
34 controller: controller
35 ),
36 ),
37 SizedBox(height: 8,),
38 Container(
39 height: 64,
40 padding: EdgeInsets.symmetric(horizontal: 24, vertical: 16),
41 child: LinearProgressIndicator(
42 value: value.toDouble() / 100
43 ),
44 ),
45 Text(value < 100 ? '$value %' : 'Done'),
46 SizedBox(height: 16,),
47 ElevatedButton(
48 onPressed: () => controller.changeColor(),
49 child: Text('Animate')
50 )
51 ],
52 ),
53 ),
54 );
55 }
56}
We setup a test screen that contains our widget, a text showing the progress and a button triggering a new animation:
Making it a multi-purpose widget
Looking good. But we can still improve one thing: instead of having the circle hard-wired to the ColorChanger widget, why don’t we also communicate the current color to the outside using the controller so that our ColorChanger widget is nothing more but the holder of the animation?
To do that, we need to do two things. First we add a new member variable to the controller called color:
1class ColorChangerController extends ChangeNotifier {
2 double value = 0.0;
3 Color color = Colors.transparent;
4 bool isAnimating = false;
5 bool shouldStartAnimation = false;
6
7 void setValue(double value) {
8 this.value = value;
9 notifyListeners();
10 }
11
12 void changeColor() {
13 this.shouldStartAnimation = true;
14 notifyListeners();
15 }
16}
The color represents the current color (also during the animation). This will be used for the outside to get it and for the animation to set it.
1colorAnimation.addListener(() {
2 widget.controller.color = colorAnimation.value;
3 widget.controller.setValue(_animationController.value);
4
5 if (colorAnimation.isCompleted) {
6 widget.controller.isAnimating = false;
7 }
8 });
Now we need to set it. The correct moment for this is when the color changes (the colorAnimation
listener is notified).
Okay, now that we made the current color accessible through the controller, what are our new capabilities?
That gives us the possibility to start the animation and let every widget we want inherit the color of the animation. The ColorChanger
widget itself is now only the carrier of the animation with its sole purpose of managing the animation state and communicating the updates to the controller.
Conclusion
Defining a controller for a custom widget is not that difficult. Eventually, it can be nothing more than a ChangeNotifier
with a collection of publicly available variables and methods. It can then be used to communicate from the outside with the widget and from the widget to the outside, just like it’s the case with native widgets like TextField
or scroll widgets.
Lisette
Marc
In reply to Lisette's comment