Putting build methods on a diet
(note: I wrote this article a long time ago. Some of these issues have significantly improved with Dart 2.3. Read more here and here.)
Flutter is great - it rocks.
We have modern, fresh APIs for building complex UIs in a quite small amount of code. Stateful hot reload is great - we can be five screens deep in our navigation hierarchy, do some UI changes, press Cmd + S
and the UI changes in less than a second, without losing the state. But when it comes to creating more full-fledged apps, we will end up having a lot of UI code.
With more code, comes more responsibility to keep it readable. Keeping code readable makes it more maintainable in the long term. Let’s see a couple of quick tips on how to keep our UI code more readable.
Problem #1 - “Padding, Padding, Padding”
The majority of layouts in our apps are based on vertically or horizontally laid out content. This means that many times, we use the Column
or Row
widgets.
Since putting widgets right below or next to each other doesn’t always look good, we’ll want to have some margins between them. One of the obvious ways of having a margin between two widgets is wrapping one of them with a Padding
widget.
Consider the following example:
return Column(
children: [
Text('First line of text.'),
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text('Second line of text.'),
),
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text('Third line of text.'),
),
],
);
We have three Text
widgets in a Column
, which each have 8.0
vertical margins between them.
The issue: obscured widgets
The problem with using Padding
widgets everywhere is that they start to obscure “the business logic” of our UI code. They add some visual clutter in the forms of increasing indentation levels and line count.
We’ll want to make the actual widgets pop up as much as possible. Every additional indentation level counts. If we could reduce the line count along the way, that would be great too.
The solution: use SizedBoxes
To combat the “hidden widget problem”, we can replace all the Padding
s with SizedBox
widgets.
Using SizedBoxes
instead of Padding
s allows us to decrease the indentation level and line count:
return Column(
children: [
Text('First line of text.'),
const SizedBox(height: 8),
Text('Second line of text.'),
const SizedBox(height: 8),
Text('Third line of text.'),
],
);
The same approach can also be used with Row
s. Since Row
s are laying their children horizontally, we can use the width
property on the SizedBox
to have horizontal margins.
Problem #2 - Overly attached callbacks
Taps or touches are arguably the most common way the user interacts with our apps.
To allow the user to tap somewhere in our app, we can use the GestureDetector
widget. When using GestureDetectors
, we wrap our original widget in it and specify a callback to the onTap
constructor argument.
Consider the following example taken from my inKino app:
// ...
final List<Event> events;
@override
Widget build(BuildContext context) {
return GridView.builder(
// ...
itemBuilder: (_, int index) {
final event = events[index];
return GestureDetector(
onTap: () {
// ...
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => EventDetailsPage(event),
),
);
},
child: EventGridItem(event),
);
},
);
}
The inKino app has a grid of movie posters. When the user taps on one of them, they should be taken into the movie details page.
The issue: littering UI code with logic
Our build method should contain only the minimal code related for building the UI of our app. The logic contained in the onTap
callback isn’t related to building UIs at all. It adds unnecessary noise to our build method.
In this case, we can pretty quickly determine that Navigator.push
pushes a new route and it is EventDetailsPage
- so tapping a grid item opens a details page. However, if the onTap
callback is more involved, it might require some more in-depth reading to understand.
The solution: extract logic into a private method
This problem can be solved quite neatly by extracting the onTap
callback into a nicely named private method. In our case, we create a new method called _openEventDetails
:
final List<Event>? events;
// ...
void _openEventDetails(BuildContext context, Event event) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => EventDetailsPage(event),
),
);
}
@override
Widget build(BuildContext context) {
return GridView.builder(
// ...
itemBuilder: (_, int index) {
final event = events![index];
return GestureDetector(
// :-)
onTap: () => _openEventDetails(context, event),
child: EventGridItem(event),
);
},
);
}
This is much nicer.
Since the onTap
callback is now extracted into a well named private method, we don’t have to read through the entire code anymore. It’s now easy to understand what happens when the callback is invoked, just with a single glance.
We also save a lot of precious lines of code in our build method and focus on just reading the UI related code.
Problem #3 - If’s all over the place
Sometimes, all children of our Columns
(or Rows
) are not meant to be visible at all times. For example, if a movie is missing its storyline details for some reason, it makes no sense to display an empty Text
widget in the UI.
A common idiom of conditionally adding children to a Column
(or Row
) looks something like this:
class EventDetailsPage extends StatelessWidget {
EventDetailsPage(this.event);
final Event event;
@override
Widget build(BuildContext context) {
final children = <Widget>[
_HeaderWidget(),
];
// ...
if (event.storyline != null) {
children.add(StorylineWidget(event.storyline));
}
// ...
if (event.actors.isNotEmpty) {
children.add(ActorList(event.actors));
}
return Scaffold(
// ...
body: Column(children: children),
);
}
}
The gist of conditionally adding items to a Column
is quite simple: we initialize a local list of widgets, and if some conditions are met, we add the necessary children to it. Finally, we pass that widget list in the children
parameter of our Column
.
In my case, which is the example above, the Finnkino API didn’t always return the storyline or actors for all movies.
The issue: if’s everywhere
While this works, those if statements get old quite fast.
Although they are quite understandable and straightforward, they take up unnecessary vertical space in our build method. Especially having three or more starts to get quite cumbersome.
The solution: an utility method
To combat the problem, we can create a global utility method which does the conditional widget adding for us. The following is a pattern which is used in the main Flutter framework code as well:
void addIfNonNull(Widget? child, List children) {
if (child != null) {
children.add(child);
}
}
Instead of duplicating the logic for conditionally adding children to a list of widgets, we create a global utility method for it.
Once it’s defined, we’ll import the file and start using the global method:
import 'widget_utils.dart';
class EventDetailsPage extends StatelessWidget {
EventDetailsPage(this.event);
final Event event;
Widget _buildStoryline() =>
event.storyline != null ? StorylineWidget(event.storyline) : null;
Widget? _buildActorList() =>
event.actors.isNotEmpty ? ActorList(event.actors) : null;
@override
Widget build(BuildContext context) {
final children = <Widget>[
_HeaderWidget(),
];
// :-)
addIfNonNull(_buildStoryline(), children);
addIfNonNull(_buildActorList(), children);
return Scaffold(
// ...
body: Column(children: children),
);
}
}
What we did here is that now our _buildMyWidget()
methods return the widget or null
, depending on if our condition is true or not. This allows us to save some vertical space in our build method, especially if we have a lot of conditionally added widgets.
Problem #4 - Bracket hell
Better save the best for the last.
This is probably one of the most prevalent problems in our layout code. A common complaint has been that the Flutter UI code can get to some crazy indentation levels, which in turn produce many brackets.
Consider this following example:
// ...
@override
Widget build(BuildContext context) {
final backgroundColor =
useAlternateBackground ? const Color(0xFFF5F5F5) : Colors.white;
return Material(
color: backgroundColor,
child: InkWell(
onTap: () => _navigateToEventDetails(context),
child: Padding(
padding: const EdgeInsets.symmetric(/* ... */),
child: Row(
children: [
Column(
children: [
Text(
hoursAndMins.format(show.start),
style: const TextStyle(/* ... */),
),
Text(
hoursAndMins.format(show.end),
style: const TextStyle(/* ... */),
),
],
),
const SizedBox(width: 20.0),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
show.title,
style: const TextStyle(/* ... */),
),
const SizedBox(height: 4.0),
Text(show.theaterAndAuditorium),
const SizedBox(height: 8.0),
Container(
// ...
// ...
child: Text(show.presentationMethod),
),
],
),
),
],
),
),
),
);
}
The above example is from my movie app called inKino, and contains the code for building list tiles for movie showtimes. I made it look ugly on purpose. A lot of it is redacted - believe me when I say the full example would have been quite something.
Essentially, this is the code which is used for building these bad boys:
If you’re reading this article with a mobile device, I’m sorry. The above code sample is not pretty to look at even on a larger screen either. Why? I’m pretty sure most of you know already.
The issue: do you even Lisp?
This old programming language, called Lisp, has a syntax that makes you use many brackets. I’ve seen Flutter’s UI markup compared to Lisp several times, and to be honest, I see the similarity.
It’s quite surprising nobody has done this before, so here goes.
“How to rescue the princess with Flutter”:
While the code above works, it isn’t that pretty to look at. The indentation levels are getting quite deep, there’s a lot of vertical clutter, brackets, and it’s hard to keep track of what’s happening and where.
Just look at the ending brackets here:
),
),
),
],
),
),
],
),
),
),
);
}
Due to the deep nesting, even with a good IDE, it’s getting quite hard to add new elements to our layout. Not to mention, actually reading the UI code.
Solution: refactor distinct UI parts into separate widgets
There are two distinct parts that make up our list tiles: the left-hand side and the right side.
The left side contains information about the start and end times of the movie. The right side has information such as the movie title, theater and whether it’s a 2D or 3D movie. To make the code more readable, we’ll start with breaking it into two different widgets, called _LeftPart
and _RightPart
.
Since the presentation method widget would introduce quite a lot of vertical clutter and deep nesting, we’ll break that into a separate widget called _PresentationMethod
.
Note: Don’t split your build methods into separate methods - that is a performance antipattern and deserves an article of it’s own.
class ShowListTile extends StatelessWidget {
ShowListTile(this.show);
// ...
final Show show;
@override
Widget build(BuildContext context) {
final backgroundColor =
useAlternateBackground ? const Color(0xFFF5F5F5) : Colors.white;
return Material(
color: backgroundColor,
child: InkWell(
onTap: () => _navigateToEventDetails(context),
child: Padding(
padding: const EdgeInsets.symmetric(/* ... */),
child: Row(
children: [
_LeftPart(show),
const SizedBox(width: 20.0),
_RightPart(show),
],
),
),
),
);
}
}
class _LeftPart extends StatelessWidget {
_LeftPart(this.show);
final Show show;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
hoursAndMins.format(show.start),
style: const TextStyle(/* ... */),
),
Text(
hoursAndMins.format(show.end),
style: const TextStyle(/* ... */),
),
],
);
}
}
class _RightPart extends StatelessWidget {
_RightPart(this.show);
final Show show;
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
show.title,
style: const TextStyle(/* ... */),
),
const SizedBox(height: 4.0),
Text(show.theaterAndAuditorium),
const SizedBox(height: 8.0),
// ...
// ...
_PresentationMethodChip(show),
],
),
);
}
}
class _PresentationMethodChip extends StatelessWidget {
_PresentationMethodChip(this.show);
final Show show;
@override
Widget build(BuildContext context) {
return Container(
// ...
// ...
child: Text(
show.presentationMethod,
style: const TextStyle(/* ... */),
),
);
}
}
With these changes, the indentation level is now over half less what it used to be. Now it’s easy to scan right through the UI code and see what is happening and where.
Bonus - Inventing your own formatting style
I don’t consider this as a similar problem to those above, but this is still something very important. Why? Let’s see.
To illustrate this problem, see the following code sample:
@override
Widget build(BuildContext context) {
return Column(
children:[Row(children:
[Text('Hello'),Text('World'),
Text('!')])]);
}
That is quite wonky, isn’t it? Certainly not something you see in a good codebase.
The issue: not using dartfmt
The code sample above doesn’t stick to any common Dart formatting conventions - seems like the author of that code invented their own style. This is not good, since reading such code takes some extra attention - it doesn’t use conventions that we’re used to.
Having commonly agreed-upon code style is essential. This allows us to skip the mental gymnastics of getting used to some weird formatting style that no one is familiar with.
The solution: just use dartfmt
Luckily, we have an official formatter, called dartfmt, which takes care of formatting for us. Also, since there’s a “formatter monopoly” in place, we can stop arguing about which is the best formatter and focus on our code instead.
As a rule of thumb, always having commas after every bracket and the running dartfmt goes a long way:
return Column(
children: [
Row(
children: [
Text('Hello'),
Text('World'),
],
),
Text('!'),
],
);
Much better. Formatting our code is a must - always remember your commas and format your code using dartfmt.
13 comments
likes