Creating Widgets

Photo by Nathana Rebouças on Unsplash

Creating Widgets

Stephan E.G. Veenstra's photo
Stephan E.G. Veenstra
·Nov 21, 2022·

5 min read

Table of contents

  • Two types of Widgets
  • Conclusion

Structuring your UI in Flutter can be tricky. For me the main reason was because both logic and UI code are written in the same language (Dart). This is something I wasn't used to coming from android native development.

In this article I will share the 'guidelines' I use when I'm creating my widgets. They help me keep widgets clean and reusable.

This article is a more detailed version of tweet that I published a some time ago:

So without any further ado, let's jump right in!

Two types of Widgets

First of all, I distinguish two types of widgets. Both have different, clearly defined, responsibilities.

Destinations

Destinations are widgets that represent a page or screen in your app, a widget you navigate too. So if you find yourself using your widget with a Navigator call, than that's a destination:

Navigator.of(context).push(
  MaterialPageRoute(builder: (context) => const HomePage()),
);

In this example the HomePage is clearly a destination. I will often name them something ending with Screen, or Page like TodoPage or LoginPage, but also DetailsModal and OrderConfirmationDialog I consider to be destinations.

Destinations are very specific to the app. They orchestrate what the screen will look like, often based on some kind of state. They contain most of the logic on what to show on the screen:

// build function of TodoList.dart
Widget build(BuildContext context) {
  if(isLoading) {
    return Center(
      child: CircularProgressIndicator(),
    );
  } else {
    return ListView(
      // show loaded items
    );
  }
}

In the example above you can see that based on isLoading, a different UI is shown. This is what a destination widget does. It decides which other widgets should be used to represent the current state, which brings us to the component widgets.

Component Widgets

These are basically all the other widgets that I create and are not destinations. They follow a strict set of rules.

Don't pass domain objects

Unlike the destination widgets, the component widgets should be as app-independent as possible. When creating these kind of widgets, I will build them in such a way that they should be reusable in any app. They should mainly focus on the how to show the information on the screen.

To accomplish full independence we cannot rely on the domain of the app. This makes passing domain objects as parameters forbidden. In the following example I will show you what I mean.

Let's say we want to create a ProfileHeader widget that shows the user's profile picture, name and city. We could do that like this:

class ProfileHeader extends StatefulWidget {
  final User user;

  const ProfileHeader({required this.user});

  Widget build(BuildContext context) {
    return Column(
      children: [
        Image.network(user.profilePictureUrl),
        Text(user.name),
        Text(user.city),
      ],
    );
  }
}

Here we pass a User object and use its fields to build the widget. This causes this widget to be strongly coupled to the domain of this app. Not only that, it also makes this widget less reusable!

Now take a look at the next approach:

class ProfileHeader extends StatefulWidget {
  final String title;
  final String subtitle;
  final String imageUrl;

  const ProfileHeader({
    required this.title,
    required this.subtitle,
    required this.imageUrl,
  });

  Widget build(BuildContext context) {
    return Column(
      children: [
        Image.network(imageUrl),
        Text(title),
        Text(subtitle),
      ],
    );
  }
}

In this example we pass each of the values as primitives and therefor no longer rely on the domain of the app. Now we cannot only reuse this widget in other apps, but even within the app itself we are able to reuse the ProfileHeader for like a company or group page!

Avoid nesting

When I design my widgets I want to have them as close to the 'root' as possible. As mentioned earlier, destinations control which (component)widgets are shown so they should also tell those widgets what to display.

So, in able to allow for this, destinations should be able to access these widgets. When you are nesting widgets, you kind of delegate the responsibilities to the closest ancestor. However, I want to have control over them in my destination widgets.

So what does that look like? Imagine the following widget tree and code:

- ProfilePage // destination
  - LoadingIndicator // when data is loading
  - ProfileContent // when data has been loaded
    - Header
    - Bio
    - Address
// Our destination
class ProfilePage extends StatelessWidget {

  Widget build(BuildContext context) =>
     isLoading ? ProgressIndicator() : ProfileContent(user);
}

class ProfileContent extends StatelessWidget {
  final User user;

  ProfileContent(this.user);

  Widget build(BuildContext context) =>
     ListView(
      children: [
        Header(user),
        Bio(user.bio),
        Address(user.address),
      ],
     );
}

In this example we have a ProfileContent widget which is shown when the ProfilePage has loaded the data. This ProfileContent widget in its turn is divided in multiple 'sections' like a Header, Bio and Address. In order to pass all the data needed for these sections, we have to pass them to the ProfileContent widget. As you can probably tell, that could be a lot of properties. In these cases we would often pass-in complex objects (like a User object) and let ProfileContent figure it out.

However, that's against our rules!

So instead of the code above, I'd do it like this:

// Our destination
class ProfilePage extends StatelessWidget {

  Widget build(BuildContext context) =>
     isLoading 
      ? ProgressIndicator()
      : ProfileContent(
            header: Header(
              image: user.profileUrl,
              title: user.name,
              subtitle: user.city
            ),
            bio: Bio(
                text: user.bio,
            ),
            address: Address(
              streetName: user.address.streetName,
              houseNumber: user.address.houseNumber,
              ...
            ),
        );
}

class ProfileContent extends StatelessWidget {
  final Widget header;
  final Widget bio;
  final Widget address;

  ProfileContent({
    required this.header,
    required this.bio,
    required this.address,
  });

  Widget build(BuildContext context) =>
     ListView(
      children: [
        header,
        bio,
        address,
      ],
     );
}

In this second example, the ProfilePage is in full control over what is passed into the Header, Bio, and Address widgets. The way we achieved this is by having widgets as arguments for the ProfileContent widget. This could look familiar since the same approach is used for the Scaffold widget, where you have params like appbar and body. So now the only responsibility of ProfileContent is laying out the widgets we pass. This makes the widget so much more flexible and reusable.

Conclusion

So that was it for now. If you find this useful, please let me know so I can share more about my approaches for writing widgets!

Cya!

 
Share this