跳转至

11 Creating Your Own Widget Catalog

By now, you’re probably well aware that Flutter is all about the widgets. In the past few chapters, you’ve seen that a specific project can consist of hundreds — even thousands — of widgets, which can quickly get out of control. In this chapter, you’ll learn about efficiently managing and maintaining all the widgets in real-world projects.

Duplicating your code seems to save you time… until you need to make a change. Now, you need to find all the places you used that code and make the changes over and over again. And if you miss any, it could cause serious and hard-to-diagnose problems. Flutter, on the other hand, lets you write a widget once, reuse it throughout your code, and have just one place to make any necessary changes. Easy!

Reusing already-created widgets can save you a lot of time and effort when creating and maintaining your projects. Many teams reuse widgets not only within a project, but even across numerous apps. That practice allows you to keep maintenance efforts low and makes the process of unifying the brand identity over multiple projects easier than ever. Having a component library with a storybook can be an invaluable tool for reusability of UI components. Although the two terms are often used interchangeably, you’ll learn about the differences between them.

A component library is a package that consists of fairly small components: widgets. You use these widgets as building blocks when creating custom UIs across one or multiple apps. A storybook, on the other hand, allows you to present the components you’ve built to your fellow team members, product managers and designers. It allows them to better understand how a specific component/widget will render across multiple devices and orientations.

Since a component library is a separate package, you can easily use it in multiple products or share it with the world by publishing it to pub.dev. You can even add an example app to it. In your case, this storybook will run across multiple devices as a standalone app.

In this chapter, you’ll learn:

  • Reasons you need a component library and storybook.
  • How to create a reusable component and add it to the component library.
  • How to add a standalone example app to a package.
  • The basic structure of a storybook.
  • How to customize a storybook.

Throughout this chapter, you’ll work on the starter project from this chapter’s assetsfolder.

Why Do You Need a Component Library?

You might be familiar with object-oriented programming (OOP), and widgets are one of the ways to implement it in Flutter. Taking it one step further, component libraries are a real-world approach to taking OOP across modules, apps, organizations and the external world.

Each component/widget in the component library acts like a small building block that you can use to create a more complex custom UI implementation. Breaking code into smaller components allows you to apply quick modifications to the UI with minimal effort. Remember the pain of reimplementing a component — or even a screen — for the fifth time because the design team came up with a brilliant new idea again?

Rather than going through all the appearances of the design feature in your app, you only have to modify a specific attribute of the widget or simply replace it with a new one. In no time, you’re back on track — working on things that really matter.

Furthermore, a single widget for a specific purpose in the app gives the feeling of consistency in the design language. That increases the overall UI quality, which reflects higher user satisfaction.

Customizing a Specific Component

Now that you have a better understanding of what a component library is, it’s time to look at how you implement reusable components in the WonderWords app. Open the starter project and run the app. In the app, navigate to the login screen and look at the design of the Sign In button:

img

Note

If you’re having trouble running the app, you might have forgotten to propagate the configurations you did in the first chapter’s starter project to the following chapters’ materials. If that’s the case, please revisit Chapter 1, “Setting up Your Environment”.

You’ve probably noticed that this button looks quite boring. To spice up the UI, you’ll add a simple icon to the button to further inform the user about this button’s function.

All the reusable components for WonderWords are part of the internal package called components_library. This package is the component library for your app. Open the starter project from the projects folder of this chapter’s downloaded assets. Navigate to the expanded_elevated_button.dart located in the src folder, which is a subfolder of lib in the component_library package.

img

Locate // TODO: replace child with button with icon in the file, and replace Widget’s child — the current implementation of ElevatedButton with the following code:

// 1
child: icon != null
    // 2
    ? ElevatedButton.icon(
        onPressed: onTap,
        label: Text(
          label,
        ),
        icon: icon,
      )
    // 3
    : ElevatedButton(
        onPressed: onTap,
        child: Text(
          label,
        ),
      ),

Here’s what the code above does:

  1. Checks that icon is not null.
  2. Returns ElevatedButton.icon if that icon isn’t null, which takes icon as an attribute.
  3. If icon wasn’t provided to the widget as an attribute, it returns ElevatedButton without icon.

Now that you’ve changed the button, hot reload the app:

img

Go through the app and try to find other appearances of the same button component. You’ll find one in the Profile tab, and you can see it’s been updated accordingly. Now, you have a better idea of the power of using reusable components with a component library.

Adding Components to the Component Library

Go to quote_details_screen.dart located in features/quote_details/lib/src, and look at the following code snippet for a moment:

// TODO: replace with centered circular progress indicator
const Center(
    child: CircularProgressIndicator(),
  ),

Now, go to update_profile_screen.dart located in features/update_profile/lib/src or go to profile_menu_screen.dart in features/profile_menu/lib/src, and you’ll find almost the same code as above:

// TODO: replace with centered circular progress indicator
return const Center(
  child: CircularProgressIndicator(),
),

You know from the theory you’ve already learned that repeating the same snippet of code in multiple places isn’t a good approach to writing reusable code. This is why you’ll try to isolate the code snippet above into a reusable component in the component library.

Go to the src folder in the component_library package. Create a new file, name it centered_circular_progress_indicator.dart, and paste the following code snippet into the file:

import 'package:flutter/material.dart';

class CenteredCircularProgressIndicator extends StatelessWidget {
  const CenteredCircularProgressIndicator({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: CircularProgressIndicator(),
    );
  }
}

With that code, you created a new component that positions the CircularProgressIndicator in the center of the screen. Next, replace // TODO: export central circular progress indicator with the following code snippet in component_library.dart located in the root of the component_library package:

export 'src/centered_circular_progress_indicator.dart';

Now that you’ve exported your new component, you’ll be able to use it by importing the component_library package in other packages and the app itself. Lastly, you have to replace the repetitive code in the files listed above. Locate // TODO: replace with centered circular progress indicator in quote_details_screen.dart, and replace the code below the comment with:

const CenteredCircularProgressIndicator(),

Also, open update_profile_screen.dart and profile_menu_screen.dart files and replace the code below the // TODO: replace with centered circular progress indicator comment with:

return const CenteredCircularProgressIndicator();

It doesn’t seem like a huge change. There’s no visible difference in the app’s UI or performance, but if the UI changes in the future, fixing the CircularProgressIndicator position might save you a lot of time.

Why Do You Need a Storybook?

In learning about the component library, you’ve learned a good practice of writing maintainable, reusable code. But now, imagine the following situation: Suppose you have to build a new feature. To avoid duplicating the code, you have to check whether you or your fellow team members have already implemented a specific design feature. That can get very time-consuming as the project grows. By using a storybook, you can check if the specific component already exists as part of the components_librarypackage, as well as modify some of its attributes to make sure it fulfills all the design requirements. You can also see widgets in different form factors as well as dark and light mode appearances in the cases when you’ve defined it for your widgets.

Note

To get the most out of a storybook, you need to be aware of these good practices: 1.) Before creating a widget, always refer to the storybook. 2.) When you update a widget, update the storybook as well.

Adding a Storybook to a Flutter App

A few open-source solutions allow you to add a customizable storybook to your component library. In this chapter, you’ll use storybook_flutter, as it stands out with the support of multiple features, such as localization, dark mode, device previews and others. It also has a knob panel, which allows you to adjust predefined attributes of a specific widget when testing it in real time.

Note

In the starter project, the component_library package already has an example folder that contains pubspec.yaml and analysis_options.yaml files. To better understand the purpose of the example folder in an individual package, look at the “Creating an Example Project” section in Creating and Publishing a Flutter Package.

Navigate to pubspec.yaml under the component_library‘s example folder, and you’ll see that storybook has already been added into the example project for you:

storybook_flutter: ^0.8.0

Note

From now on, you’ll only work on component_library’s example folder, so every file or folder mentioned in this chapter is contained in that folder.

Basic Structure of Storybook UI

To give you a sneak preview and better understanding of the topic, here’s what the WonderWords storybook will look like at the end of this chapter after running it in the browser:

img

At first glance, the UI can look a bit confusing. It has a flood of tiles and strange controls, but when you take a proper look at it, everything starts to make sense.

Here’s the explanation of the UI above. On the left side is a Stories panel, which consists of stories — or components — and story sections. For example, Buttons is the name of a section that can expand to a list of all buttons that exist in your component_library package. On the other hand, Rounded Choice Chip isn’t part of any component group. In other words, it’s not part of an expandable list.

In the middle is the CurrentStory panel, which shows the currently selected story.

To the right of the CurrentStory panel is the KnobPanel, which allows you to adjust the parameters and appearance of the currently displayed story.

Lastly, on the far right side is a sidebar for device preview selection and themingappearance toggling.

Device Preview and Theming

In the image above, the top circled icon on the right enables you to show the current story in a device preview. You can select a device from a predefined set of devices and change its orientation.

The bottom-most circled icon is to toggle between light, dark or system default themes. The current active theme applies to the storybook UI, but it also changes the theme of the current story:

img

The image above is an example where dark mode is active, and the preview device is an iPhone 13. Isn’t it fascinating having a preview device inside a browser?

You should now have a better understanding of the importance of being able to see and test different configurations of a specific widget across various devices, orientations, theme modes and even localization before even referencing them in an actual project.

Storybook UI on a Mobile App

You already know that you can run the storybook on all three main platforms supported by Flutter: iOS, Android and web. All the storybook panels are also present on its mobile version, with the layout adjusting to the smaller screen size. Check it out:

img

You only need to do one step before you can run the storybook on your own — and also eventually publish it to the Google Play Store, Apple Store or host it on the web. You have to add platform-specific files to your components_library’s example folder so it can run as a standalone app.

Making the Storybook App Runnable

In the terminal, navigate to the example folder of component_library with the following command:

$ cd packages/component_library/example

Try to run the app by pasting the following command into the terminal:

$ flutter run

Did you notice anything strange about it? You probably weren’t successful, and it returned the following stack:

Target file "lib/main.dart" not found.

If you think about that for a moment, it makes total sense. The example folder isn’t a Flutter app yet, as it doesn’t include the main.dart file. By running the flutter runcommand, Flutter tries to find the main.dart file and run the main() method located in that file. So, to run the app, you have to create one.

Navigate to the lib folder, create a new file named main.dart, and add the following content:

import 'package:flutter/material.dart';
// TODO: add missing import

void main() {
  runApp(
    // TODO: replace the MaterialApp placeholder later
    MaterialApp(
      home: Container(color: Colors.grey),
    ),
  );
}

The code above uses Container as a placeholder and will be replaced later with the StoryApp widget.

Try to execute flutter run again, and make sure Chrome, iOS Simulator or Android Emulator is running. See if you’re more successful this time.

By trying to run the app again, you’ll receive the following stack:

No supported devices connected.
The following devices were found, but are not supported by this project:
sdk gphone64 arm64 (mobile) • emulator-5554 • android-arm64  • Android 13 (API 33) (emulator)
macOS (desktop)             • macos         • darwin-arm64   • macOS 12.6 21G115 darwin-arm
Chrome (web)                • chrome        • web-javascript • Google Chrome 105.0.5195.125
If you would like your app to run on android or macos or web, consider running `flutter create .` to generate projects
for these platforms.

Note

The actual terminal output is much longer than the output above. This output is trimmed to make it more readable and understandable.

From the output, you can see that even though all three devices are connected, the app doesn’t run because the package doesn’t contain android, ios or web folders. To fix this, run flutter create . in the terminal. You’ll get the following output:

Recreating project ...
...
...
Wrote 122 files.

All done!

In order to run your application, type:

  $ cd .
  $ flutter run

Your application code is in ./lib/main.dart.

Note

Again, for better readability, the previously created files were replaced with ....

As a result, you’ll be able to find the android, ios and web folders located in the example folder:

img

Note

Additionally, there were also macos, windows and linux folders generated, but you won’t use them in this example.

Now, for the last time, execute flutter run, and you’ll see the app successfully run on any of the supported devices:

img

Understanding Component Storybook

Now that you can successfully run the storybook app, you’ll inspect what the source code from the lib folder does. First, open component_storybook.dart and look at the build() method of a ComponentStorybook widget. In the next few subsections, you’ll learn how to configure a storybook for your needs.

Specifying Stories and an Initial Story

As mentioned, a storybook is a collection of stories — or components — put together in an organized order. Take a closer look at its only required attribute, children, and the initialRoute attribute:

// 1
children: [
  ...getStories(theme),
],
// 2
initialRoute: 'rounded-choice-chip',

Here’s what this code does:

  1. The children attribute gets a List<Story>. You’ll learn about the Storywidget in detail in the next section. The getStories() method is present in the stories.dart file in the lib folder. It’s better to keep the stories in a separate file to avoid duplicating them when you decide to add a CustomStorybook in addition to the default storybook.

Note

The following section will further explain the theme argument.

  1. The flutter_storybook library converts the Story’s name to hyphen-separated lowercase words. For example, if you specify the Story‘s name as Rounded Choice Chip, its route becomes rounded-choice-chip. By giving initialRoute, you ensure a specific story is the current story. If you don’t set initialRoute, you see a Select story message.

Specifying Themes

The first two attributes of the storybook widget are theme and darkTheme:

@override
Widget build(BuildContext context) {
  // 1
  final theme = WonderTheme.of(context);
  return Storybook(
    // 2
    theme: lightThemeData,
    // 3
    darkTheme: darkThemeData,
    // TODO: add localization delegates
    children: [
      ...getStories(theme),
    ],
  );
}

Here’s what the code above does:

  1. Fetches the WonderTheme instance from ancestors. You’ll provide WonderTheme from main.dart later.
  2. Provides light and dark themes to the storybook. The storybook’s widget also allows you to specify themeMode, which is set to ThemeMode.system by default.
  3. Provides the stories with theme. Using the WonderTheme instance from ancestors in the storybook ensures that theme is unified across your main app and the storybook app.

Specifying Localization

Next, you have to locate the localizationDelegates attribute in the storybook widget by replacing // TODO: add localization delegates with the following code snippet:

localizationDelegates: const [
  GlobalMaterialLocalizations.delegate,
  GlobalWidgetsLocalizations.delegate,
  GlobalCupertinoLocalizations.delegate,
  ComponentLibraryLocalizations.delegate,
],

Recall what you learned in Chapter 9, “Internationalizing & Localizing”. Since this is a component storybook, you only need ComponentLibraryLocalizations.delegatealong with the default ones.

Applying StoryApp to runApp() Methods

Lastly, replace // TODO: replace the MaterialApp placeholder later in main.dart’s StoryApp with:

StoryApp()

Here, StoryApp, available in story_app.dart, is another widget that builds WonderTheme, much like the root-level main.dart file. Don’t forget to import a missing import at the top of the file by replacing // TODO: add missing import with the following code:

import 'package:component_library_storybook/story_app.dart';

This is how your main.dart file should look like when you’ve applied those changes:

import 'package:component_library_storybook/story_app.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(StoryApp());
}

Take a look at how StoryApp builds WonderTheme in the story_app.dart file:

@override
  Widget build(BuildContext context) {
    return WonderTheme(
      lightTheme: _lightTheme,
      darkTheme: _darkTheme,
      child: ComponentStorybook(
        lightThemeData: _lightTheme.materialThemeData,
        darkThemeData: _darkTheme.materialThemeData,
      ),
    );
  }

The build() method of the StoryApp widget returns the WonderTheme widget, which takes lightTheme, darkTheme and child as an argument. By wrapping ComponentStorybook with the WonderTheme widget, you ensure that you can get the current theme’s instance using WonderTheme.of(context). This enables dynamic changing of the theme per the currently active theme.

Build and run the storybook app on mobile to get the following screen:

img

Now that you’ve learned about storybook customizations, it’s time to learn how to configure the most important element of a storybook — a story.

Understanding a Story

You can create a Story a couple different ways — a simple story and a complex story. Choosing the right way varies from case to case, and it’s important from the perspective of how informative your Story will be for the end user. In the case of Storybook, the end user might be a fellow developer, your lead UI designer or even a client. So, it has to be as configurable as possible.

Defining a Simple Story

A simple Story is used in cases when the widget/component has no configuration options. You can figure out whether a specific widget should have configuration options by looking at its attributes. For example, among all the widgets present in component_library, ShareIconButton, LoadingIndicator, SearchBar and RowAppBar are simple widgets. Such widgets don’t have any attributes that would define the way they should render.

Now, it’s time to create a simple Story. Open stories.dart in the lib folder, copy the following code snippet, and replace // TODO: Add Simple Expanded Elevated Button Story here with:

// 1
Story.simple(
  name: 'Simple Expanded Elevated Button',
  section: 'Buttons',
  // 2
  child: ExpandedElevatedButton(
    label: 'Press me',
    onTap: () {},
  ),
  // TODO: add additional attributes to the story later
),

The code above:

  1. Uses simple named constructor and provides a name and section to the Story. This name becomes the title of ListTile in the stories list, and section becomes the title of the ExpansionTile.
  2. As child, you provide the widget you want to show in the storybook. In this specific example, this widget is ExpandedElevatedButton, which has two required attributes.

name and child are the only required attributes of a simple Story widget, but you can also provide some other customization attributes, such as padding and background. Replace // TODO: add additional attributes to the story laterin the file above with the following code:

padding: const EdgeInsets.all(64.0),
background: Colors.cyanAccent,

Apply those two additional attributes and hot reload the app. Navigate to Simple Expanded Elevated Button located in the Buttons section by expending the side drawer hidden in the menu. You’ll see the difference, as shown below. The first screen shows the implementation of simple Story without additional attributes, and the second screen shows the implementation of simple Story with additional attributes from above:

img

Defining a Complex Story

complex Story isn’t much different from simple Story. The only difference between the two is that complex Story uses builder instead of child. Using builder enables you to configure the knob panel for a widget. For example, look at the ExpandedElevatedButton widget that has the following fields:

final String label;
final VoidCallback? onTap;
final Widget? icon;

From the code above, you can see that ExpandedElevatedButton is much more configurable than the widgets listed above. It allows you to specify label, onTapand icon.

Getting back to your implementation of simple Story, you can see that it doesn’t allow you to configure it. Here are two main disadvantages that you face by not configuring the story when this is possible:

  1. When, as a developer, you’re looking for a widget that you can provide with a custom label, RoundedChoiceChip looks useless, although it has a configurable label.
  2. You don’t get a good overview of what you can configure in a specific widget, which makes you visit the codebase. This completely devalues having a storybook in the first place.

To better understand how you offer the configuration options for the components, paste the following code snippet below your implementation of the simple Story for ExpandedElevatedButton and replace // TODO: Add Complex Expanded Elevated Button Story here with:

Story(
  name: 'Expanded Elevated Button',
  section: 'Buttons',
  builder: (_, k) => ExpandedElevatedButton(
    label: k.text(
      label: 'label',
      initial: 'Press me',
    ),
    onTap: k.boolean(
      label: 'onTap',
      initial: true,
    )
        ? () {}
        : null,
    icon: Icon(
      k.options(
      label: 'icon',
        initial: Icons.home,
        options: const [
          Option(
            'Login',
            Icons.login,
          ),
          Option(
            'Refresh',
            Icons.refresh,
          ),
          Option(
            'Logout',
            Icons.logout,
          ),
        ],
      ),
    ),
  ),
),

In the code above, you use builder instead of child, which will add the knob options to this specific widget/story. builder is a type of StoryBuilder, which is used by BuildContext and KnobsBuilder to build the story. KnobsBuilder allows you to configure simple data types such as bool, string, int and double, as well as any other custom data type. The code above will be explained in the following paragraphs.

Adding a Knob for Text

Now, you’ll take the RoundedChoiceChip story in the stories.dart file as a reference. You might notice it has a k.text method:

label: k.text(
  // 1
  label: 'label',
  // 2
  initial: 'I am a Chip!',
),

This method:

  1. Provides a label to the text field for the knobs panel.
  2. Gives an initial value to the text field.

As a result, notice the presence of a TextField in the knobs panel:

img

Adding a Knob for a Boolean

In the same RoundedChoiceChip story, also notice a k.boolean method:

isSelected: k.boolean(label: 'isSelected', initial: false),

Since RoundedChoiceChip also has the isSelected attribute, which requires a Boolean value, you can use the k.boolean method. In the code sample above, this value is initially set to false. As a result, the knob panel has a field in the form of CheckBox, which enables you to change a value:

img

Adding a Knob for int or double

Look at storybook on your mobile simulator or in your browser, and locate the Upvote Icon Button story under the Counter Indicator Buttons tile in the app. Go to the knob panel, and you’ll see a slider to change the vote count. That’s the knob for the int or double type field:

img

You can achieve this by adding the following implementation of k.sliderInt to the UpvoteIconButton story by replacing // TODO: replace with implementation of int knob located in stories.dart with the following code snippet:

count: k.sliderInt(
  label: 'count',
  max: 10,
  min: 0,
  initial: 0,
  divisions: 9,
),

After building and running the code, you should see the same result as shown in the image above.

Of all the offered parameters, label is the only one required. The rest are optional, whereas max defaults to 100 with as many divisions.

Take a look at how you can very similarly use k.slider for double values as follows:

k.slider(
  label: 'count',
  max: 10,
  min: 0,
  initial: 0,
),

For double values, you can’t specify divisions because it’s restricted in this library. But, if you’re curious to add it, feel free to create an issue and raise a pull request for the package. The output of slider with and without divisions is shown in the images below:

img

Adding a Knob for Custom Types

So far, you’ve seen how to add knobs for primitive data types. Now, it’s time to learn about the wildcard knob, which you can use for any custom type. Look back at the example of RoundedChoiceChip, which has customizable colors. The color type isn’t a primitive Dart data type. In such scenarios, you should use k.options, as shown below:

backgroundColor: k.options(
  label: 'backgroundColor',
  initial: null,
  options: const [
    Option('Light blue', Colors.lightBlue),
    Option('Red accent', Colors.redAccent),
  ],
),

In the code above, using the option parameter, you can specify an infinite number of colors to select for the background color of the chip. As a result, a drop-down in the knob panel has the specified colors you can choose from. A null initial value ensures that there’s no default value from options for background color:

img

That’s a deal-breaker, isn’t it? However, the k.options method enables the storybook to be more configurable.

Custom Wrapper for a Story

The storybook has one more customization option worth mentioning. You can add a custom wrapper to every single Story widget using wrapperBuilder for Story. So, suppose you want to see how the QuoteCard widget renders itself in ListView. You can easily achieve this by using wrapperBuilder here. Add the following code snippet to a complex Story with Quotes in List in stories.dart. To locate it easier, search for // TODO: add wrapper builder for quotes list:

// 1
wrapperBuilder: (context, story, child) => Padding(
  padding: const EdgeInsets.all(8.0),
  child: ListView.separated(
    itemCount: 15,
    // 2
    itemBuilder: (_, __) => child,
    separatorBuilder: (_, __) => const Divider(height: 16.0),
  ),
),

Here’s what the code above does:

  1. Wraps the QuoteCard in ListView with 15 items.
  2. The widget returned by builder is the initial child.

When you run the app on a mobile device and select Quotes in List or Quotes in Grid in a side drawer, you’ll see the output shown below:

img

Challenge

You accomplished a lot in getting through this chapter, and this is an excellent opportunity for you to test your knowledge.

Go to stories.dart in the starter project and find // TODO: Challenge. Add a new story for the Downvote Icon Button component and make all its attributes configurable in the knob panel. You can check your solution with the one in the challenge project.

Key Points

  • A storybook is a visual representation of the component library.
  • A storybook can be a separate app or an integral part of your main app.
  • Use the flutter create . command to add platform-specific folders.
  • Configure the knob panel for fields you feel the user would like to change and test.