Putting build methods on a diet - tips and tricks for cleaner Flutter UI code

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. Since there’s a lot of content, here’s a handy TOC to navigate between the sections:

As a friendly warning: this article is quite code heavy. There will be some ugly code ahead.

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:

Column(
  children: <Widget>[
    Text('First line of text.'),
    const Padding(
      padding: EdgeInsets.only(top: 8.0),
      child: Text('Second line of text.'),
    ),
    const Padding(
      padding: EdgeInsets.only(top: 8.0),
      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: “hidden 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 Paddings with SizedBox widgets. Using SizedBoxes instead of Paddings allows us to decrease the indentation level and line count:

Column(
  children: <Widget>[
    Text('First line of text.'),
    const SizedBox(height: 8.0),
    Text('Second line of text.'),
    const SizedBox(height: 8.0),
    Text('Third line of text.'),
  ],
),

The same approach can also be used with Rows. Since Rows 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: (BuildContext context, int index) {
      var event = events[index];

      return GestureDetector(
        onTap: () {
          // :-(
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (_) => EventDetailsPage(event),
            ),
          );
        },
        child: EventGridItem(event: 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(Event event) {
  Navigator.push(
    context,
    MaterialPageRoute(
      builder: (_) => EventDetailsPage(event),
    ),
  );
}

@override
Widget build(BuildContext context) {
  return GridView.builder(
    ...
    itemBuilder: (BuildContext context, int index) {
      var event = events[index];

      return GestureDetector(
        // :-)
        onTap: () => _openEventDetails(event),
        child: EventGridItem(event: 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;

  Widget _buildStoryline() => Container(...);
  Widget _buildActorList() => ListView(...);

  @override
  Widget build(BuildContext context) {
    var children = <Widget>[
      _buildHeader(),
    ];

    // :-(
    if (event.storyline != null) {
      children.add(_buildStoryline());
    }

    // :-(
    if (event.actors.isNotEmpty) {
      children.add(_buildActorList());
    }

    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: a global 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:

lib/widget_utils.dart

void addIfNonNull(Widget child, List<Widget> 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 ? Container(...) : null;

  Widget _buildActorList() => 
    event.actors.isNotEmpty ? ListView(...) : null;

  @override
  Widget build(BuildContext context) {
    var children = <Widget>[
      _buildHeader(),
    ];

    // :-)
    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) {
  var 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: <Widget>[
            Column(
              children: <Widget>[
                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: <Widget>[
                  Text(
                    show.title, 
                    style: const TextStyle(...),
                  ),
                  const SizedBox(height: 4.0),
                  Text(show.theaterAndAuditorium),
                  const SizedBox(height: 8.0),
                  Container(
                    // Presentation method chip.
                    // Styling redacted for brevity ...
                    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:

A showtime list tile widget in the inKino app.

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”:

Rescuing the princess with Flutter

The above image is modified from "How to save the princess in 8 programming languages" by Toggl & Matt Virkus. Their comics are quite funny - go check them out.

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 A: refactor distinct UI parts into methods

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 methods called _buildLeftPart() and _buildRightPart().

Since the presentation method widget would introduce quite a lot of vertical clutter and deep nesting, we’ll break that into a separate method called _buildPresentationMethod().

...
Widget _buildLeftPart() {
  return Column(
    children: <Widget>[
      Text(
        hoursAndMins.format(show.start),
        style: const TextStyle(...),
      ),
      Text(
        hoursAndMins.format(show.end),
        style: const TextStyle(...),
      ),
    ],
  );
}

Widget _buildRightPart() {
  return Expanded(
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: <Widget>[
        Text(
          show.title,
          style: const TextStyle(...),
        ),
        const SizedBox(height: 4.0),
        Text(show.theaterAndAuditorium),
        const SizedBox(height: 8.0),
        // The presentation method is in the right part.
        // See the method below.
        _buildPresentationMethod(),
      ],
    ),
  );
}

Widget _buildPresentationMethod() {
  return Container(
    // Presentation method chip.
    // Styling redacted for brevity ...
    child: Text(
      show.presentationMethod,
      style: const TextStyle(...),
    ),
  );
}

@override
Widget build(BuildContext context) {
  var 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: <Widget>[
            _buildLeftPart(),
            const SizedBox(width: 20.0),
            _buildRightPart(),
          ],
        ),
      ),
    ),
  );
}

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.

Solution B: refactor distinct UI parts into Widgets

Usually, splitting UI code into well-named methods is enough. However, sometimes it might make more sense to refactor those UI parts into an entirely new widget.

Let’s say the presentation method “chip” is used in more than one place in our UI. In that case, we could make it into a widget:

class PresentationMethodChip extends StatelessWidget {
  PresentationMethodChip(this.show);
  final Show show;

  @override
  Widget build(BuildContext context) {
    return Container(
      // Presentation method chip.
      // Styling redacted for brevity ...
      child: Text(
        show.presentationMethod,
        style: const TextStyle(...),
      ),
    );
  }
}

Now we can use this anywhere in our app by creating a new instance of PresentationMethodChip and passing a Show object to it.

Refactoring UI code to a new widget also makes sense if the UI part can be thought of as a bigger entity, such as list tiles in a ListView. Another case for creating widgets are UI parts that have their own state, for example, a like button - not to mention animated UI components.

Sometimes, it makes sense to extract something into a new Widget just because the UI code is too involved.

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:

Column(
        children:<Widget>[Row(children:
  <Widget>  [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:

Column(
  children: <Widget>[
    Row(
      children: <Widget>[
        Text('Hello'), 
        Text('World'),
      ],
    ),
    Text('!'),
  ],
),

Much better. Formatting our code is a must - always remember your commas and format your code using dartfmt.

Comments