How to implement your design system in a Flutter app (2/2)

Matthieu Regnauld
11 min readMar 24, 2024

--

This article is a follow up of the previous article, that you can find here.

Let’s talk about widgets now!

At this point, you can create your own reusable widgets according to your design system. But I would like to share with you some good practices and tips on how to implement them in an efficient way.

Factory

It sometimes happens in your design system that some components are quite similar, but not identical. A good example of this is the action button, usually a button with a centered text in it. It is quite common in a app design to have a primary and a secondary button (sometimes even tertiary). The primary button is usually used for the main action of a screen, such as the submit button at the bottom of a form. A secondary button is sometimes used for, well, secondary actions, such as an optional action.

So what are the options? Well, you could create a widget for each button type, but you’ll end up duplicating quite a lot of code. You could also use inheritance, but I would avoid this as it’s not good practice and makes your code harder to read and maintain.

The easiest and most versatile option is to use factories. Here are the advantages I found so far:

  • your code is easier to read, since you add widgets like CenteredTextButton.primary(...) in your screens
  • you can easily group common attributes in each factory, instead of repeating them again and again every time you need the same widget in your app
  • you can easily add a new widget variant by just adding a new factory in your widget class (for example, I can add a CenteredTextButton.tertiary factory, with its own specific argument values, and use it right away in my screens)

Let’s see an example below:

class CenteredTextButton extends StatelessWidget
{
final String label;
final bool isPrimary;
final bool isEnabled;
final Function() onTap;
final Color color;
final Color disabledColor;

const CenteredTextButton._internal({
super.key,
required this.label,
required this.isPrimary,
required this.isEnabled,
required this.onTap,
required this.color,
required this.disabledColor,
});

factory CenteredTextButton.primary({
Key? key,
required String label,
bool isEnabled = true,
required Function() onTap,
required BuildContext context,
}) {
return CenteredTextButton._internal(
key: key,
label: label,
isPrimary: true,
isEnabled: isEnabled,
onTap: onTap,
color: Theme.of(context).appColors.backgroundActionPrimary,
disabledColor: Theme.of(context).appColors.backgroundActionPrimaryDisabled,
);
}

factory CenteredTextButton.secondary({
Key? key,
required String label,
bool isEnabled = true,
required Function() onTap,
required BuildContext context,
}) {
return CenteredTextButton._internal(
key: key,
label: label,
isPrimary: false,
isEnabled: isEnabled,
onTap: onTap,
color: Theme.of(context).appColors.backgroundActionSecondary,
disabledColor: Theme.of(context).appColors.backgroundActionSecondaryDisabled,
);
}

@override
Widget build(BuildContext context)
{
// draw your button as you wish
...
}
}

The idea here is to add only the necessary arguments in each factory in order to keep your code simpler and avoid code duplication. Because the background color of the primary button is always Theme.of(context).appColors.backgroundActionPrimary, you don't need to pass it as an argument every time you need to add a primary button in your app. And that is the whole point of factories here.

I also highly recommend you to do so when it comes to texts and text inputs, especially if you have many texts in your app. For example, instead of using Text in your app, you can create a custom AppText widget as follows:

class AppText extends StatelessWidget
{

final String text;
final TextStyle textStyle;
final Color color;
final TextAlign textAlign;
final TextOverflow? textOverflow;


const AppText._internal( this.text, {
super.key,
required this.textStyle,
required this.color,
this.textAlign = TextAlign.start,
this.textOverflow,
});

factory AppText.labelBigEmphasis(String text, {
Key? key,
required BuildContext context,
Color? color,
TextAlign? textAlign,
TextOverflow? textOverflow,
}) => AppText._internal(
text,
key: key,
textStyle: Theme.of(context).appTexts.labelBigEmphasis,
color: color ?? Theme.of(context).appColors.textDefault,
textAlign: textAlign ?? TextAlign.start,
textOverflow: textOverflow,
);

factory AppText.labelDefaultEmphasis(String text, {
Key? key,
required BuildContext context,
Color? color,
TextAlign? textAlign,
TextOverflow? textOverflow,
}) => AppText._internal(
text,
key: key,
textStyle: Theme.of(context).appTexts.labelDefaultEmphasis,
color: color ?? Theme.of(context).appColors.textDefault,
textAlign: textAlign ?? TextAlign.start,
textOverflow: textOverflow,
);

// add more factories according to your needs
...

@override
Widget build(BuildContext context)
{
return Text(
text,
textAlign: textAlign,
overflow: textOverflow,
style: textStyle.copyWith(color: color),
);
}
}

And then add it in your screen:

AppText.labelDefaultEmphasis("My text example", context: context),

In the same way, you can do the same for input texts. You can create an AppTextField widget, with factories like:

  • AppTextField.text()
  • AppTextField.search()
  • AppTextField.number()
  • AppTextField.email()
  • AppTextField.password()
  • and so on…

Images and icons

There are multiple ways of implementing images and icons in a Flutter app. But here is my approach:

  • I create an AppIconName file, where I list every icon of my app, with their relative path
  • Then I create an AppIcon file, which is the widget that I use to display an icon (again, with factories if needed)

Then, I can add icons on the go in my app, and the process is pretty easy.

Let’s see with an example:

class AppIconName
{
static const _iconsPath = "assets/icons/";

final String name;

const AppIconName(this.name);

// the icons:
static get accountEdit => const AppIconName("${_iconsPath}account_edit.svg");
static get alert => const AppIconName("${_iconsPath}alert.svg");
static get close => const AppIconName("${_iconsPath}close.svg");
static get search => const AppIconName("${_iconsPath}search.svg");
}

And now, the AppIcon widget :

class AppIcon extends StatelessWidget
{
final String name;
final Color? color;
final double? size;

const AppIcon._internal({
super.key,
required this.name,
this.color,
this.size,
});

factory AppIcon.main({
Key? key,
required AppIconName appIconName,
required BuildContext context,
}) => AppIcon._internal(
key: key,
name: appIconName.name,
color: Theme.of(context).appColors.iconDefault,
size: 32,
);

factory AppIcon.reversed({
Key? key,
required AppIconName appIconName,
required BuildContext context,
}) => AppIcon._internal(
key: key,
name: appIconName.name,
color: Theme.of(context).appColors.iconDefaultReversed,
size: 32,
);

@override
Widget build(BuildContext context)
{
return Image.asset(
name,
color: color,
width: size,
height: size,
);
}
}

Now, every time you need to add a new icon as a SVG file, you copy it in yout assets/icons folder, add a getter in the AppIconName class, and that's it! You can now easily use it with any AppIcon factory you want in your app.

Gaps

When implementing a design system, you often have to deal with gaps: between input fields, between buttons, … And instead of overusing padding, you should prefer to use gaps whenever possible, especially when you design forms.

OK but what is a gap? Basically, it’s an empty SizedBox, with a given height (sometimes width if you're in a Row, for example), that you add between widgets. Although it sounds pretty basic, it actually helps you make widgets that are easy to understand, especially when it comes to forms.

Now, here is how I implement gaps. I create a VerticalGap with factories (and I do pretty much the same with a HorizontalGap class):

class VerticalGap extends StatelessWidget
{
final double height;

const VerticalGap._internal({
super.key,
required this.height,
});

factory VerticalGap.formHuge({Key? key}) => VerticalGap._internal(key: key, height: 32);
factory VerticalGap.formBig({Key? key}) => VerticalGap._internal(key: key, height: 24);
factory VerticalGap.formMedium({Key? key}) => VerticalGap._internal(key: key, height: 16);
factory VerticalGap.formSmall({Key? key}) => VerticalGap._internal(key: key, height: 8);
factory VerticalGap.formTiny({Key? key}) => VerticalGap._internal(key: key, height: 4);

// because sometimes you need it:
factory VerticalGap.custom(double height, {Key? key}) => VerticalGap._internal(key: key, height: height);

@override
Widget build(BuildContext context) => SizedBox(height: height);
}

Note that you can also use the gap package if you want.

Now, for example, I can use it in a form like this:

  @override
Widget build(BuildContext context)
{
return Form(
key: widget.formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppText.labelDefaultDefault(
L10n.get(context).add_account_label_enter_email,
context: context,
),
VerticalGap.formMedium(),
AppTextField.email(
controller: _emailController,
hintText: L10n.get(context).add_account_hint_enter_email,
maxLength: 256,
textInputAction: TextInputAction.next,
),
VerticalGap.formBig(),
AppText.labelDefaultDefault(
L10n.get(context).add_account_label_enter_password,
context: context,
),
VerticalGap.formMedium(),
AppTextField.password(
controller: _passwordController,
hintText: L10n.get(context).add_account_hint_enter_password,
maxLength: 256,
textInputAction: TextInputAction.done,
),
VerticalGap.formBig(),
CenteredTextButton.primary(
label: L10n.get(context).common_submit,
onTap: () { ... },
),
],
),
),
);
}

As you can see, my form is very easy to read and understand, with gaps and other widgets as seen above.

My personal rule of thumb is: gaps should always be visible in your widgets. So when you code a widget, whether it’s a simple component or an entire screen, you should be able to spot gaps between the different elements you put in. Avoid hidden gaps and spacings inside your subwidgets (again, use Padding only when necessary). I'm not saying that it's possible or that it will make sense 100% of the time, but it will definitely make your life a lot easier.

OK. What about tests?

There are (at least) two approaches:

  • golden tests
  • widget book

Golden tests

How about “printing” every aspect of your design system in a single PNG image? This is what you can do with golden tests, using golden_toolkit for example.

You can achieve that in 3 steps:

  • create a golden test wrapper
  • add every component of your design system you want in a widget
  • print that final widget using golden_toolkit

First, let’s create the GoldenTestWrapper widget. It will be necessary to get access to the theme extensions:

class GoldenTestWrapper extends StatelessWidget
{
final Widget Function(BuildContext) getChild;

const GoldenTestWrapper({
super.key,
required this.getChild,
});

@override
Widget build(BuildContext context)
{
return Theme(
data: Theme.of(context).copyWith(
extensions: [
AppDimensionsTheme.main(View.of(context)),
AppColorsTheme.light(),
AppTextsTheme.main(View.of(context)),
],
),
child: Builder(
builder: (context) => getChild(context),
),
);
}
}

Then, use it to create widgets where you’ll put every component of your choice. For example, you can test your colors as follows, in a helper class that we’ll use later:

class DesignSystemHelper
{
static Widget getColors()
{
return GoldenTestWrapper(
getChild: (context) {
return Wrap(
children: [
Theme.of(context).appColors.backgroundDefault,
Theme.of(context).appColors.backgroundInput,
Theme.of(context).appColors.snackbarValidation,
Theme.of(context).appColors.snackbarError,
Theme.of(context).appColors.textDefault,
].map((color) {
return Container(
color: Colors.white,
child: Padding(
padding: const EdgeInsets.all(4),
child: Container(
width: 48,
height: 48,
color: color,
),
),
);
}).toList(),
);
},
);
}

static getTypos()
{
...
}

...
}

And finally, print your test widgets in a golden test:

void main()
{
testGoldens("Design system", (WidgetTester tester) async
{
final builder = GoldenBuilder.column()
..addScenario("Colors", DesignSystemHelper.getColors())
..addScenario("Typos", DesignSystemHelper.getTypos())
..addScenario("Buttons", DesignSystemHelper.getButtons())
..addScenario("Dividers", DesignSystemHelper.getDividers())
..addScenario("Icons", DesignSystemHelper.getIcons())
..addScenario("Inputs", DesignSystemHelper.getInputs())
..addScenario("BottomSheets", DesignSystemHelper.getBottomSheets())
..addScenario("Dialogs", DesignSystemHelper.getDialogs());
await tester.pumpWidgetBuilder(
builder.build(),
surfaceSize: const Size(1000, 7000),
);
await screenMatchesGolden(tester, "design_system");
});
}

Now, when you run your test, you’ll get a beautiful design_system.png file in a goldens/ directory (relative to your test class above). Don't forget to add the following argument to your test class so it generates the image: --update-goldens.

This is how it looks on my project:

Design system example with golden tests

A few things to mention here:

  • That golden test is useful to see your entire design system in one place, at least to make sure there are no display problems, but also to share with your design team to see if it meets their requirements.
  • Rendering may vary depending on the platform you are running the test on, especially for text. So it may not be suitable for your CI, unless you run that test locally or on a dedicated machine.
  • This can be limiting in some cases. For example, you won’t be able to test animations.

A good practice to keep in mind if you still want to test your widgets with golden testing (and, actually, in general) is to separate the logic from the visual rendering of your widget.

For example, let’s look at the case of dialogs (note that it also works with bottomsheets). What I usually do is:

  • create a widget for the dialog content, that you can easily test in a golden test:
class ProgressDialogContent extends StatelessWidget
{
const ProgressDialogContent({super.key});

@override
Widget build(BuildContext context)
{
return const PopScope(
canPop: false,
child: AlertDialog(
backgroundColor: Colors.transparent,
contentPadding: EdgeInsets.all(0),
elevation: 0,
content: Center(
child: CircularProgressIndicator(),
),
),
);
}
}
  • create a separate function dedicated to display dialogs:
class DialogHelper
{
static Future<void> show({
required BuildContext context,
required Widget child,
}) async
{
await showDialog(
context: context,
barrierDismissible: true,
barrierColor: Theme.of(context).appColors.barrierColor,
builder: (_) => child,
);
}
}
  • now you can display the dialog by doing:
DialogHelper.showDialog(context: context, child: const ProgressDialogContent());

And pretty much the same goes for animations:

  • you start by creating a simple widget (usually a StatelessWidget), that represents the content of your widget to be animated, but without any animation logic (it usually takes a progress or value argument, representing the current value of the upcoming animation)
  • then you create another widget with the animation logic in it, that wraps the first widget

Widgetbook

Another more versatile approach is to implement the widgetbook package. This is a very handy tool that allows you to not only display every widget of your design system, but also to interact with them by:

  • setting the parameters to any value you want
  • changing the theme
  • changing the text scale factor
  • displaying them on any device

Before we dive into it, you can see what it looks like here.

In the Widgetbook world, widgets are categorized into folders (WidgetbookFolder). Widgets are called components (WidgetbookComponent), and you can display one or more use cases (WidgetbookUseCase) for each component. A use case here is a variant of a widget. For example, you can display a use case for each factory of a widget of your design system implementation.

There are two ways of implementing Widgetbook in your project: the manual and the generator approach. While the manual approach theoretically gives you more flexibility, you should consider the generator approach as it is more convenient as the project grows. The main advantage of the generator approach is that it automatically mirrors your project folder structure.

First, let’s create an empty widgetbook by adding the following widgetbook.dart file anywhere in your project:

...
import 'package:flutter/material.dart';
import 'package:widgetbook/widgetbook.dart';
import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook;

import 'widgetbook.directories.g.dart';

void main() {
runApp(const WidgetbookApp());
}

@widgetbook.App()
class WidgetbookApp extends StatelessWidget {

const WidgetbookApp({super.key});

@override
Widget build(BuildContext context) {
return Widgetbook.material(
directories: directories,
addons: [
MaterialThemeAddon(
themes: [
WidgetbookTheme(
name: "Light mode",
data: Theme.of(context).copyWith(
extensions: [
BeemColorsTheme.light(),
BeemTextsTheme.main(View.of(context)),
],
),
),
],
),
DeviceFrameAddon(
devices: [
Devices.android.samsungGalaxyS20,
Devices.ios.iPhone13ProMax,
],
),
TextScaleAddon(
scales: [1.0, 1.5, 2.0],
initialScale: 1.0,
),
],
);
}
}

Then run the following command on a terminal, at the root of your project, so it generates the widgetbook.directories.g.dart file:

dart run build_runner build --delete-conflicting-outputs

A few things to mention here:

  • the directories variable, set in the widgetbook.directories.g.dart file, contains all your widgets that you'll add later
  • the addons argument is used here to:
    - set the themes used in your app (necessary here so your widgets can access your theme extensions)
    - add devices (optional, but very useful if you want to see what your widgets look like on different configurations)
    - change the text scale factor (optional, but very useful if you want to make sure that your widgets display properly in every situation)

You can test on a web page with the following command on a terminal:

flutter run lib/widgetbook.dart -d chrome

Now, for each widget you want to display in your widgetbook, you’ll add one or more use cases, according to your needs. Let’s see an example with the CenteredTextButton (as seen above):

class CenteredTextButton extends StatelessWidget {
...
}

@widgetbook.UseCase(
name: "centered text button",
type: CenteredTextButton,
)
Widget getCenteredTextButtonPrimary(BuildContext context)
{
return SafeArea(
child: CenteredTextButton.primary(
label: "Primary",
isEnabled: context.knobs.boolean(label: "is enabled", initialValue: true),
onTap: () {},
context: context,
),
);
}

A few things to mention here:

  • The example above shows an example with only one use case, but you can add as many as you want. Just duplicate the annotation and the function above to create a new use case for the CenteredTextButton (for example, to display the secondary variant).
  • I created the getCenteredTextButtonPrimary() function above, but you can name it the way you want
  • For the isEnabled argument, I use a knob. A knob is a very handy tool, that appears in the right column of your widgetbook and allows you to change the value of that parameter in real time. You should definitely try it.

Run the above build_runner again, then test again on your web page, and you'll see the magic happen.

This is how it looks on my project:

Widgetbook example on Mistikee

If you want to learn more about Widgetbook, here are some useful resources:

Conclusion

Implementing a design system can be a mess if not done right. I hope this 2-part article gives you some good insight on how to do that.

Don’t hesitate to let me know if you have any question.

--

--