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

Matthieu Regnauld
8 min readMar 24, 2024

--

Photo by Amélie Mourichon on Unsplash

When it comes to writing clean code, we often think first about implementing the best design and architectural patterns for the features of our app. This is definitely a good practice, and I can’t encourage you enough to do it.

But what about the frontend code? How could you properly implement a design system in your app? And what is a design system? What is its purpose and value?

This article was partly inspired by the work of Aloïs Deniel, which you can find here.

A design system? What exactly is a design system?

First things first.

A design system is a collection of reusable components, style guides (fonts, colors, dimensions, …) and standards that are organized to promote consistency and efficiency in the design of digital products. It serves as a centralized resource for designers and developers to ensure coherence in visual appearance, behavior, and user experience across various platforms and applications.

For a mobile app, a design system is usually divided into 3 categories (and I will stick to this use case for the rest of this article):

  1. The atomic level: in this level, you will find everything related to the very basics of the design system, such as the colors, the fonts, the shadows, common spacings, the radius of the cards (if any), the icons, and so on.
  2. The molecular level: in this level, you will find the most basic and common widgets, such as buttons, checkboxes, radio boxes, dividers, input fields, and so on.
  3. The cellular level: in this level, you will find more complex widgets, such as appbars, complex cards, or even custom widgets (using CustomPainterfor example), … that can be shared accross the app or even specific to one page.

OK so I can just go with Material Design or Cupertino Design then.

Well, yes… but actually no.

Sure, apps can be design using default theming provided by the Flutter SDK. But you’ll get stuck pretty quickly, and your app will most likely look like a basic app made by a beginner student as an exercise.

For example, you can customize the headlines styles of the text theme of the default theme, but you’ll be limited to 3 levels: large, medium, small. Which can be too restrictive.

Good to know. Where do I start?

Dedicated package

A good place to start is to create a dedicated package for your design system. Although it is not required, and you could totally skip this part even for medium sized projects, I would still advise you to do so for two reasons:

  • you can easily share your implemented design system across multiple apps
  • you are guaranteed that none of your widgets know anything about the logic of your app

Naming convention

If you are working with designers, I would highly recommend you to agree on a naming convention. This is especially true when you start implementing the different screens of your app. Knowing the name of each component of each screen (especially if you’re using Figma or a similar tool) will save you a lot of time because you won’t have to go back and forth between your design system document and your IDE looking for the right widget that corresponds to the component in your document you’re working on.

Now it’s time to implement the design system!

Theme extensions

Theme extensions are a great tool to create your custom theming. They are typically used for colors, but I also use them for custom text theming and, in some cases, dimensions shared across multiple widgets.

Let’s start with colors. Start by creating a new class, named AppColorsTheme, as follows:

class AppColorsTheme extends ThemeExtension<AppColorsTheme>
{
// reference colors:
static const _grey = Color(0xFFB0B0B0);
static const _green = Color(0xFF00C060);
static const _red = Color(0xFFED4E52);

// actual colors used throughout the app:
final Color backgroundDefault;
final Color backgroundInput;
final Color snackbarValidation;
final Color snackbarError;
final Color textDefault;

// private constructor (use factories below instead):
const AppColorsTheme._internal({
required this.backgroundDefault,
required this.backgroundInput,
required this.snackbarValidation,
required this.snackbarError,
required this.textDefault,
});

// factory for light mode:
factory AppColorsTheme.light() {
return AppColorsTheme._internal(
backgroundDefault: _grey,
backgroundInput: _grey,
snackbarValidation: _green,
snackbarError: _red,
textDefault: _grey
);
}

// factory for dark mode:
factory AppColorsTheme.dark() {
return AppColorsTheme._internal(...);
}

@override
ThemeExtension<AppColorsTheme> copyWith({bool? lightMode})
{
if (lightMode == null || lightMode == true) {
return AppColorsTheme.light();
}
return AppColorsTheme.dark();
}

@override
ThemeExtension<AppColorsTheme> lerp(
covariant ThemeExtension<AppColorsTheme>? other,
double t) => this;
}

A few things to mention here:

  • I deliberately separate the base colors from the colors actually used in my app. The reason for this is that in some cases, different widgets can share the same color in one mode, but need different colors in another mode. This is fairly rare, but still.
  • Using factories here greatly simplifies the creation of different modes, so you can very easily select the colors you need for each mode. You can literally add a new mode in minutes! And you’re not limited to just dark and light mode, you can add any color mode you want!
  • I override the lerp() method by just retuning this, but you can use Color.lerp() as shown here if you want to make a smooth transition between different color modes. Not that useful, but hey, I'm not going to judge!

Now, let’s continue with fonts by creating a new class, named AppTextsTheme, as follows:

class AppTextsTheme extends ThemeExtension<AppTextsTheme>
{
static const _baseFamily = "Base";

final TextStyle labelBigEmphasis;
final TextStyle labelBigDefault;
final TextStyle labelDefaultEmphasis;
final TextStyle labelDefaultDefault;

const AppTextsTheme._internal({
required this.labelBigEmphasis,
required this.labelBigDefault,
required this.labelDefaultEmphasis,
required this.labelDefaultDefault,
});

factory AppTextsTheme.main() => AppTextsTheme._internal(
labelBigEmphasis: TextStyle(
fontFamily: _baseFamily,
fontWeight: FontWeight.w400,
fontSize: 18,
height: 1.4,
),
labelBigDefault: TextStyle(
fontFamily: _baseFamily,
fontWeight: FontWeight.w300,
fontSize: 18,
height: 1.4,
),
labelDefaultEmphasis: TextStyle(
fontFamily: _baseFamily,
fontWeight: FontWeight.w400,
fontSize: 16,
height: 1.4,
),
labelDefaultDefault: TextStyle(
fontFamily: _baseFamily,
fontWeight: FontWeight.w300,
fontSize: 16,
height: 1.4,
),
);

@override
ThemeExtension<AppTextsTheme> copyWith()
{
return AppTextsTheme._internal(
labelBigEmphasis: labelBigEmphasis,
labelBigDefault: labelBigDefault,
labelDefaultEmphasis: labelDefaultEmphasis,
labelDefaultDefault: labelDefaultDefault,
);
}

@override
ThemeExtension<AppTextsTheme> lerp(
covariant ThemeExtension<AppTextsTheme>? other,
double t) => this;
}

A few things to mention here:

  • Try to use as few fonts as possible and again, it’s important to agree on a naming convention with the design team on the fonts you use in the app, since you might use them very often. Knowing right in the mock-up what is the right font to use can save you a lot of precious time and energy.
  • The lerp() method is useless here in that use case, so I just return this.
  • For the sake of simplicity, I’ve hardcoded the font sizes here, but we’ll see later how we can add some responsiveness, so the font size can vary depending on the screen size.

Finally, let’s create a new class for dimensions named AppDimensionsTheme, as follows:

class AppDimensionsTheme extends ThemeExtension<AppDimensionsTheme>
{
final double radiusHelpIndication;
final EdgeInsets paddingHelpIndication;

const AppDimensionsTheme._internal({
required this.radiusHelpIndication,
required this.paddingHelpIndication,
});

factory AppDimensionsTheme.main() => AppDimensionsTheme._internal(
radiusHelpIndication: 8,
paddingHelpIndication: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
);

@override
ThemeExtension<AppDimensionsTheme> copyWith()
{
return AppDimensionsTheme._internal(
radiusHelpIndication: radiusHelpIndication,
paddingHelpIndication: paddingHelpIndication,
);
}

@override
ThemeExtension<AppDimensionsTheme> lerp(
covariant ThemeExtension<AppDimensionsTheme>? other,
double t) => this;
}

A few things to mention here:

  • Should you use that class for each and every dimension of your app? Definitely not. If you need to set a dimension only in one place, or even in multiple places inside the same widget, keep it in that widget (as a const for example). Unlike the fonts and colors themes above, I would recommend to use that class only if you need to share dimensions across multiple widgets in your app.
  • As you might guess, it’s also a good place to start implementing some responsiveness, but we’ll come to that later.

Great. How do I use it?

main file

First, go to your main.dart file and, in your MaterialApp() widget, there is an attribute called theme of type ThemeData?. You can simply add your extensions above as follows:

MaterialApp(
...
theme: Theme.of(context).copyWith(
extensions: [
AppDimensionsTheme.main(),
AppColorsTheme.light(),
AppTextsTheme.main(),
],
),
...
),

ThemeData extension

The following step, although not required, will be very helpful to avoid the boilerplate code to access the extension and simplify the syntax of your code. Create a ThemeData extension as follows:

extension ThemeDataExtended on ThemeData
{
AppDimensionsTheme get appDimensions => extension<AppDimensionsTheme>()!;
AppColorsTheme get appColors => extension<AppColorsTheme>()!;
AppTextsTheme get appTexts => extension<AppTextsTheme>()!;
}

Implementation example

Now you can use your theme as shown in the following example:

Text(
"My text example",
style: Theme.of(context).appTexts.labelDefaultEmphasis.copyWith(
color: Theme.of(context).appColors.textDefault,
),
)

You talked earlier about responsiveness, can I implement it in my themes files?

Sure! Let’s dive into this.

FlutterView extension

Again, the following step is not required. However, it can be very helpful to help you implement proper responsiveness in your app. Also, this is just an example, feel free to modify it to suit your needs.

Let’s create a FlutterView extension as follows:

extension FlutterViewExtended on FlutterView
{
static const double responsive360 = 360;
static const double responsive480 = 480;
static const double responsive600 = 600;
static const double responsive800 = 800;
static const double responsive900 = 900;
static const double responsive1200 = 1200;

double get logicalWidth => physicalSize.width / devicePixelRatio;
double get logicalHeight => physicalSize.height / devicePixelRatio;
double get logicalWidthSA => (physicalSize.width - padding.left - padding.right) / devicePixelRatio;
double get logicalHeightSA => (physicalSize.height - padding.top - padding.bottom) / devicePixelRatio;

bool get isSmallSmartphone
{
if (logicalWidthSA < logicalHeightSA)
{
return (logicalWidthSA <= responsive360 || logicalHeightSA <= responsive600);
}
else
{
return (logicalWidthSA <= responsive600 || logicalHeightSA <= responsive360);
}
}

bool get isRegularSmartphoneOrLess
{
if (logicalWidthSA < logicalHeightSA)
{
return (logicalWidthSA <= responsive480 || logicalHeightSA <= responsive800);
}
else
{
return (logicalWidthSA <= responsive800 || logicalHeightSA <= responsive480);
}
}

bool get isSmallTabletOrLess
{
if (logicalWidthSA < logicalHeightSA)
{
return (logicalWidthSA <= responsive600 || logicalHeightSA <= responsive900);
}
else
{
return (logicalWidthSA <= responsive900 || logicalHeightSA <= responsive600);
}
}

bool get isRegularTabletOrLess
{
if (logicalWidthSA < logicalHeightSA)
{
return (logicalWidthSA <= responsive800 || logicalHeightSA <= responsive1200);
}
else
{
return (logicalWidthSA <= responsive1200 || logicalHeightSA <= responsive800);
}
}
}

Although the isSmallSmartphone and subsequent getters are not required, they will help you implement responsiveness in a much simpler and cleaner way than adding multiple breakpoints with many different values each time you want your widgets to be responsive. Been there, done that.

Now, if go back to the AppDimensionsTheme created earlier, we can modify it as follows:

class AppDimensionsTheme extends ThemeExtension<AppDimensionsTheme>
{
...

factory AppDimensionsTheme.main(FlutterView flutterView) => AppDimensionsTheme._internal(
radiusHelpIndication: flutterView.isSmallSmartphone ? 8 : 16, // <- responsive here!
paddingHelpIndication: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
);

...
}

As you might see, we pass an instance of FlutterView as an argument of AppDimensionsTheme.main. Don't forget to import the extension created earlier.

And don’t forget to update the main.dart file as follows:

MaterialApp(
...
theme: Theme.of(context).copyWith(
extensions: [
AppDimensionsTheme.main(View.of(context)),
...
],
),
...
),

Now, every time you use radiusHelpIndication in your app, it will automatically return 8 if we are on a small smartphone, or 16 otherwise. It's that simple.

But wait! Why am I using FlutterView instead of MediaQuery, you may ask? Well, you can actually use either one or the other interchangeably. Almost.

Because there is actually a small difference. Let’s say that you want to use MediaQuery.of(context) instead of View.of(context) in the example above. It works fine, but there are some use cases where, if you use MediaQuery, your widgets will be built more often than if you use FlutterView.

An example of that is when you open and close the virtual keyboard (e.g. when you give or release the focus on a TextFormField). Each time the keyboard is opened or closed, an additional build occurs. Sure, you can use something like MediaQuery.sizeOf(context) instead to fixt that, but I find it less convenient. In the end, it is up to you.

Now you can continue to part 2.

--

--