Every software project that has reached a certain level of complexity and size requires a certain structure in order to stay maintainable.
This applies in a special way to Flutter because the logic and the UI is written in the same language. This is not that common if you think about it:
- In the web there is the separation of frontend and backend, where HTML serves as a markup language and CSS as stylesheet language
- In native Android development there’s XML files for the view layer (which defines structure as well as styling)
- In native iOS development there’s Storyboard if you ignore SwiftUI which is currently still in beta state (and don’t want to have other issues)
A consequence is, that files tend to get big in no time. If you have at least a tiny bit of passion for Clean Code then you’ll very quickly have the need to split everything into smaller pieces.
But what is the right approach to do that?
Let’s consider this example:
1@override
2Widget build(BuildContext context) {
3 return Scaffold(
4 appBar: AppBar(
5 title: Text(widget.title),
6 ),
7 body: Align(
8 alignment: Alignment.bottomCenter,
9 child: Container(
10 padding: EdgeInsets.all(16),
11 child: TextField(
12 controller: _textEditingController,
13 decoration: InputDecoration(
14 hintText: "Enter text",
15 ),
16 )
17 )
18 ),
19 );
20}
This is the build method of a widget that does nothing more than showing a TextField at the bottom that has a hint text.
Because we have to make alignment and padding, we reach quite an indent depth. If you see this code for the first time, you’ll need at least a few seconds to interpret what’s going on.
Two approaches – function and class
Let’s look at two approaches to extract the snippet and make it more readable:
Function approach
1@override
2Widget build(BuildContext context) {
3 return Scaffold(
4 appBar: AppBar(
5 title: Text(widget.title),
6 ),
7 body: _getBody(),
8 );
9}
10
11Align _getBody() {
12 return Align(
13 alignment: Alignment.bottomCenter,
14 child: Container(
15 padding: EdgeInsets.all(16),
16 child: TextField(
17 controller: _textEditingController,
18 decoration: InputDecoration(
19 hintText: "Enter a beautiful text",
20 ),
21 )
22 )
23 );
24}
Class approach
1 @override
2 Widget build(BuildContext context) {
3 return Scaffold(
4 appBar: AppBar(
5 title: Text(widget.title),
6 ),
7 body: WidgetBody(),
8 );
9 }
10
11...
12
13class WidgetBody extends StatefulWidget {
14 @override
15 State<StatefulWidget> createState() {
16 return _WidgetBodyState();
17 }
18
19}
20
21class _WidgetBodyState extends State<WidgetBody> {
22 final TextEditingController _textEditingController = TextEditingController();
23
24 @override
25 Widget build(BuildContext context) {
26 return Container(
27 alignment: Alignment.bottomCenter,
28 padding: EdgeInsets.all(16),
29 child: TextField(
30 controller: _textEditingController,
31 decoration: InputDecoration(
32 hintText: "Enter a beautiful text",
33 ),
34 )
35 )
36 );
37 }
38}
Judging from the LOC I would say: the function approach has won. But apart from issues we’ll discuss in a minute, I have noticed something else while I extracted the code: using the widget method, I had to think about what dependencies I inject into the new child widget and which I let in the parent widget (in this case the hint text and the controller).
One could say that’s an annoying decision to make. But actually, it’s a good sign we have to make this decision because it makes us think about responsibilities.
What do I mean by that? Well, if we look at the function approach, we can see that the TextFieldController of the enclosing widget (namely _textEditingController) is used. So far so good, but if we want to turn the function into something reusable, we’d have to inject the dependency anyways. The class approach does not give us any other option because there is no enclosing widget whose properties can be used. So if we decide to use this particular widget in multiple different contexts throughout our app, we’re ready to go!
Flutter’s awareness
The lacking enforcement of dependency injection is not the only issue.
Let’s think about what a function actually is. What does Wikipedia say?
In computer programming, a subroutine is a sequence of program instructions that performs a specific task, packaged as a unit. This unit can then be used in programs wherever that particular task should be performed.
Subroutines are a powerful programming tool, and the syntax of many programming languages includes support for writing and using them.
Aha, right, so the concept of functions belongs to the Dart language. Whereas the concept of widgets belongs to Flutter. In other words: if we define a function, Flutter has no awareness we’re using this to add a Widget to a subtree. The main consequences are:
- We don’t have a (separate) context
- We can’t define Keys
- We don’t exploit the maximum potential of performance optimization (as unnecessary parts of the UI might be rebuilt)
- We’re unable to use the integrated widget inspector
- It’s not possible to notify other parts of the widget tree that something has changed
Functions: not the root of a tree, but the root of all evil?
Does that mean that there is no use in creating functions that return a widget? Well, that’s a little bit dogmatic and can’t really be said that way.
Example: if we have created a reusable widget and have say five places where this widget is used. Every place requires the widget to have a different padding, alignment, position. One simple solution to meet the requirements while at the same time keeping the code of the containing widget readable is to let a function like _getBody() enclose the widget by the respective padding, alignment and positioning widgets.
Another method would be to let the reusable widget expect these properties as arguments:
Function approach (same as above)
1@override
2Widget build(BuildContext context) {
3 return Scaffold(
4 appBar: AppBar(
5 title: Text(widget.title),
6 ),
7 body: _getBody(),
8 );
9}
10
11Align _getBody() {
12 return Container(
13 alignment: Alignment.bottomCenter,
14 padding: EdgeInsets.all(16),
15 child: TextField(
16 controller: _textEditingController,
17 decoration: InputDecoration(
18 hintText: "Enter a beautiful text",
19 ),
20 )
21 );
22}
Class approach
1 @override
2 Widget build(BuildContext context) {
3 return Scaffold(
4 appBar: AppBar(
5 title: Text(widget.title),
6 ),
7 body: WidgetBody(),
8 );
9 }
10
11...
12
13class WidgetBody extends StatefulWidget {
14 WidgetBody({
15 Key key,
16 this.padding = EdgeInsets.zero,
17 this.alignment = Alignment.topLeft
18 }) : super(key: key);
19
20 final EdgeInsets padding;
21 final Alignment alignment;
22
23 @override
24 State<StatefulWidget> createState() {
25 return _WidgetBodyState();
26 }
27}
28
29class _WidgetBodyState extends State<WidgetBody> {
30 final TextEditingController _textEditingController = TextEditingController();
31
32 @override
33 Widget build(BuildContext context) {
34 return Container(
35 alignment: widget.alignment,
36 padding: widget.padding,
37 child: TextField(
38 controller: _textEditingController,
39 decoration: InputDecoration(
40 hintText: "Enter a beautiful text",
41 ),
42 )
43 );
44 }
45}
Expecting arguments in that way makes sense if you have five places with five different configurations. But if four are completely equally used and only at one place there is a different layout that requires some padding and different alignment, it could feel a little bit too over engineered to do go for the class approach because two arguments are unused in four of five cases. But that’s just a feeling. In fact, when you think about it: that’s what optional arguments are for. And the package that get shipped with flutter always have tons of optional arguments and most of the time, going with the default arguments fits the use case.
For those of you who still favor the function approach there is a package called functional_widgets. The basic idea of this package is: you annotate the functions that return a widget (with @widget
using functional_widget_annotation), start a process (can also automatically be started using a file watcher) and the stateless widgets with a name that is derived from your function names are automatically generated for you.
I have not really worked with it yet, but I like the idea more of having everything under my control.
Wrap up
Although sometimes it may seem like a little bit of unnecessary work, one could say: you can’t go wrong with implementing a separate widget class whenever you want to extract functionality that can be encapsulated in a widget. This also has the advantage of having to think about responsibilities. Also, there can indeed be problems when using functions for that purpose e.g. performance issues or state bugs as it’s not meant by Flutter to do it that way. Because of it’s lacking awareness of your intent.
If you’re lazy though and still want the above mentioned benefits, then there is a package for that. But generally speaking: in doubt, go for a separate class.
Comment this 🤌