Design System From Scratch in Flutter
Design System From Scratch in Flutter
Initially, many solutions are available to create a Design System for your app in
Flutter. I want to share my experience with Design System, which we have
implemented in our projects before.
As you can see, the designer divided atomic parts for us (thanks to our designer)
I will not mention all the code; it will be snippets. They are like the following
examples:
Keep in mind that Theme classes are extended from ThemeExtension, in this way, we
can register them as theme extensions and use them with Theme class.
For instance, we can check Theme classes for buttons and text fields:
1 /// {@template app_button_theme}
2 /// Theme class which provides configuration of buttons
3 /// {@endtemplate}
4 class AppButtonTheme extends ThemeExtension<AppButtonTheme> {
5 /// {@macro app_button_theme}
6 const AppButtonTheme({
7 required this.primaryText,
8 required this.primaryDefault,
9 required this.primaryHover,
required this.primaryFocused,
});
10
11
/// {@macro app_button_theme}
12
factory AppButtonTheme.light() {
13
return AppButtonTheme(
14
primaryText: AppColors.white,
15
primaryDefault: AppColors.brand.shade500,
16
primaryHover: AppColors.brand.shade600,
17
primaryFocused: AppColors.brand.shade700,
18
);
19
}
20
21
/// The color of the primary text.
22
final Color primaryText;
23
24
/// The color of the primary button default.
25
final Color primaryDefault;
26
27
/// The color of the primary button hover.
28
final Color primaryHover;
29
30
/// The color of the primary button focused.
31
final Color primaryFocused;
32
33
@override
34
ThemeExtension<AppButtonTheme> copyWith({
35
Color? primaryText,
36
Color? primaryDefault,
37
Color? primaryHover,
38
Color? primaryFocused,
39
}) {
40
return AppButtonTheme(
41
primaryText: primaryText ?? this.primaryText,
42
primaryDefault: primaryDefault ?? this.primaryDefault,
43
primaryHover: primaryHover ?? this.primaryHover,
44
primaryFocused: primaryFocused ?? this.primaryFocused,
45
);
46
}
47
48
@override
49
ThemeExtension<AppButtonTheme> lerp(
50
covariant ThemeExtension<AppButtonTheme>? other,
51
double t,
52
) {
53
if (other is! AppButtonTheme) {
54
return this;
55
}
56
57
return AppButtonTheme(
58
primaryText: Color.lerp(primaryText, other.primaryText, t)!,
59
primaryDefault: Color.lerp(primaryDefault,
60
other.primaryDefault, t)!,
61
primaryHover: Color.lerp(primaryHover, other.primaryHover,
62
t)!,
63
primaryFocused: Color.lerp(primaryFocused,
64
other.primaryFocused, t)!,
65
);
66
}
}
We had many buttons, but I have shared some of them (text buttons)
Therefore, we have created a base class for our text buttons. Here is the code
snippet:
1 /// {@template app_text_button}
2 /// A custom text button widget that adapts to the platform.
3 /// {@endtemplate}
4 abstract class AppTextButton extends StatelessWidget {
5 /// {@macro app_text_button}
6 const AppTextButton({
7 super.key,
8 required this.label,
9 this.onTap,
10 this.leading,
11 this.trailing,
12 this.appButtonSize = AppButtonSize.medium,
13 });
14
15 /// The label for the text button.
16 final String label;
17
18 /// The callback function for the text button.
19 final VoidCallback? onTap;
20
21 /// The leading icon for the text button.
22 final IconBuilder? leading;
23
24 /// The trailing icon for the text button.
25 final IconBuilder? trailing;
26
27 /// The size of the text button.
28 final AppButtonSize appButtonSize;
29
30 /// The background color for the text button.
31 Color backgroundColor(BuildContext context);
32
33 /// The focus color for the text button.
34 Color focusColor(BuildContext context);
35
36 /// The hover color for the text button.
37 Color hoverColor(BuildContext context);
38
39 /// The disabled color for the text button.
40 Color disabledColor(BuildContext context);
41
42 /// The text color for the text button.
43 Color textColor(BuildContext context);
44
45 /// The disabled text color for the text button.
46 Color disabledTextColor(BuildContext context) {
47 return context.buttonTheme.primaryTextDisabled;
48 }
49
50 /// The default border for the text button.
51 BorderSide defaultBorder(BuildContext context) => BorderSide.none;
52
53 /// The focused border for the text button.
54 BorderSide focusedBorder(BuildContext context) => BorderSide.none;
55
56 /// The hover border for the text button.
57 BorderSide hoverBorder(BuildContext context) => BorderSide.none;
58
59 /// The disabled border for the text button.
60 BorderSide disabledBorder(BuildContext context) => BorderSide.none;
61
62 @override
63 Widget build(BuildContext context) {
64 final betweenSpace = switch (appButtonSize) {
65 AppButtonSize.small ||
66 AppButtonSize.xSmall ||
67 AppButtonSize.medium =>
68 AppSpacing.xs,
69 AppButtonSize.large || AppButtonSize.xlarge => AppSpacing.sm,
70 AppButtonSize.xxLarge => AppSpacing.lg,
71 };
72
73 final inputTextColor = WidgetStateProperty.resolveWith(
74 (states) {
75 if (states.contains(WidgetState.disabled)) {
76 return disabledTextColor(context);
77 }
78
79 return textColor(context);
80 },
81 );
82
83 return ElevatedButton(
84 style: ButtonStyle(
85 elevation: WidgetStateProperty.all(0),
86 splashFactory: NoSplash.splashFactory,
87 overlayColor: WidgetStateProperty.resolveWith(
88 (states) {
89 if (states.contains(WidgetState.disabled)) {
90 return disabledColor(context);
91 }
92
93 if (states.contains(WidgetState.hovered)) {
94 return hoverColor(context);
95 }
96
97 if (states.contains(WidgetState.focused)) {
98 return focusColor(context);
99 }
100
101 if (states.contains(WidgetState.pressed)) {
102 return focusColor(context);
103 }
104
105 return backgroundColor(context);
106 },
107 ),
108 shape: WidgetStateProperty.resolveWith(
109 (states) {
110 const shape = RoundedRectangleBorder(
111 borderRadius: BorderRadius.all(AppRadius.md),
112 );
113
114 if (states.contains(WidgetState.disabled)) {
115 return shape.copyWith(side: disabledBorder(context));
116 }
117
118 if (states.contains(WidgetState.focused)) {
119 return shape.copyWith(side: focusedBorder(context));
120 }
121
122 if (states.contains(WidgetState.hovered)) {
123 return shape.copyWith(side: hoverBorder(context));
124 }
125
126 if (states.contains(WidgetState.pressed)) {
127 return shape.copyWith(side: focusedBorder(context));
128 }
129
130 return shape.copyWith(side: defaultBorder(context));
131 },
132 ),
133 backgroundColor: WidgetStateProperty.resolveWith(
134 (states) {
135 if (states.contains(WidgetState.disabled)) {
136 return disabledColor(context);
137 }
138
139 if (states.contains(WidgetState.hovered)) {
140 return hoverColor(context);
141 }
142
143 if (states.contains(WidgetState.focused)) {
144 return focusColor(context);
145 }
146
147 if (states.contains(WidgetState.pressed)) {
148 return focusColor(context);
149 }
150
151 return backgroundColor(context);
152 },
153 ),
154 foregroundColor: inputTextColor,
155 fixedSize: WidgetStateProperty.all(
156 switch (appButtonSize) {
157 AppButtonSize.small ||
158 AppButtonSize.xSmall =>
159 const Size(double.infinity, 36),
160 AppButtonSize.medium => const Size(double.infinity, 40),
161 AppButtonSize.large => const Size(double.infinity, 44),
162 AppButtonSize.xlarge => const Size(double.infinity, 48),
163 AppButtonSize.xxLarge => const Size(double.infinity, 56),
164 },
165 ),
166 padding: WidgetStateProperty.all(
167 switch (appButtonSize) {
168 AppButtonSize.small ||
169 AppButtonSize.xSmall =>
170 const EdgeInsets.symmetric(horizontal: 12),
171 AppButtonSize.medium => const
172 EdgeInsets.symmetric(horizontal: 16),
173 AppButtonSize.large => const EdgeInsets.symmetric(horizontal:
174 16),
175 AppButtonSize.xlarge => const
176 EdgeInsets.symmetric(horizontal: 20),
177 AppButtonSize.xxLarge => const
178 EdgeInsets.symmetric(horizontal: 24),
179 },
180 ),
181 ),
182 onPressed: onTap,
183 child: Row(
184 mainAxisAlignment: MainAxisAlignment.center,
185 mainAxisSize: MainAxisSize.min,
186 children: [
187 if (leading != null) ...[
188 leading!(
189 onTap != null ? textColor(context) :
190 disabledTextColor(context),
191 ),
192 SizedBox(width: betweenSpace),
193 ],
194 Padding(
195 padding: const EdgeInsets.symmetric(horizontal:
196 AppSpacing.xxs),
197 child: Text(
198 label,
199 style: switch (appButtonSize) {
200 AppButtonSize.small ||
201 AppButtonSize.xSmall =>
202 context.typography.buttonSmall,
203 AppButtonSize.medium => context.typography.buttonMedium,
204 AppButtonSize.large => context.typography.buttonLarge,
205 AppButtonSize.xlarge => context.typography.buttonXLarge,
206 AppButtonSize.xxLarge =>
207 context.typography.button2XLarge,
208 },
209 ),
210 ),
211 if (trailing != null) ...[
212 SizedBox(width: betweenSpace),
213 trailing!(
214 onTap != null ? textColor(context) :
disabledTextColor(context),
),
],
],
),
);
}
}
Eventually, with the help of the base AppTextButton class, we can create our child
classes. So, our Primary, Secondary, and Outlined text buttons’ codes will be like
the following:
1 /// {@template primary_text_button}
2 /// A custom primary text button widget that adapts to the
3 platform.
4 /// {@endtemplate}
5 class PrimaryTextButton extends AppTextButton {
6 /// {@macro primary_text_button}
7 const PrimaryTextButton({
8 super.key,
9 required super.label,
10 super.onTap,
11 super.leading,
12 super.trailing,
13 super.appButtonSize,
14 });
15
16 @override
17 Color backgroundColor(BuildContext context) {
18 return context.buttonTheme.primaryDefault;
19 }
20
21 @override
22 Color disabledColor(BuildContext context) {
23 return context.buttonTheme.primaryDisabled;
24 }
25
26 @override
27 Color focusColor(BuildContext context) {
28 return context.buttonTheme.primaryFocused;
29 }
30
@override
31
Color hoverColor(BuildContext context) {
32
return context.buttonTheme.primaryHover;
33
}
34
35
@override
36
Color textColor(BuildContext context) {
37
return context.buttonTheme.primaryText;
38
}
39
}
For the text field, we have created again an independent AppTextField class.
1 /// {@template app_text_field}
2 /// A customizable text field widget with various customization
3 options.
4 /// {@endtemplate}
5 class AppTextField extends StatelessWidget {
6 /// {@macro app_text_field}
7 const AppTextField({
8 super.key,
9 this.controller,
10 this.labelText,
11 this.enabled = true,
12 this.obscureText = false,
13 this.onChanged,
14 this.autovalidateMode = AutovalidateMode.onUserInteraction,
15 this.validator,
16 this.helperText,
17 this.errorText,
18 this.suffixIcon,
19 this.suffixIconConstraints =
20 const BoxConstraints(minHeight: 24, minWidth: 40),
21 this.prefixIcon,
22 this.prefixIconConstraints =
23 const BoxConstraints(minHeight: 24, minWidth: 40),
24 this.autofillHints,
25 this.onEditingComplete,
26 this.inputFormatters,
27 this.keyboardType,
28 this.maxLines = 1,
29 });
30 /// The controller for the text field.
31 final TextEditingController? controller;
32
33 /// The label text for the text field.
34 final String? labelText;
35
36 /// Whether the text field is enabled.
37 final bool enabled;
38
39 /// Whether the text field is obscured.
40 final bool obscureText;
41
42 /// Called when the text field value changes.
43 final ValueChanged<String>? onChanged;
44
45 /// The autovalidate mode for the text field.
46 final AutovalidateMode autovalidateMode;
47
48 /// The validator for the text field.
49 final FormFieldValidator<String>? validator;
50
51 /// The helper text for the text field.
52 final String? helperText;
53
54 /// The error text for the text field.
55 final String? errorText;
56
57 /// The suffix icon for the text field.
58 final Widget? suffixIcon;
59
60 /// The constraints for the suffix icon.
61 final BoxConstraints? suffixIconConstraints;
62
63 /// The prefix icon for the text field.
64 final Widget? prefixIcon;
65
66 /// The constraints for the prefix icon.
67 final BoxConstraints? prefixIconConstraints;
68
69 /// The autofillhints for app text field.
70 final Iterable<String>? autofillHints;
71
72 /// Called when the text field value completed.
73 final VoidCallback? onEditingComplete;
74 /// The input formatters for the text field.
75 final List<TextInputFormatter>? inputFormatters;
76
77 /// The keyboard type for the text field.
78 final TextInputType? keyboardType;
79
80 /// the maximum lines available in text field.
81 final int maxLines;
82
83 @override
84 Widget build(BuildContext context) {
85 return TextFormField(
86 keyboardType: keyboardType,
87 inputFormatters: inputFormatters,
88 onEditingComplete: onEditingComplete,
89 autofillHints: autofillHints,
90 controller: controller,
91 enabled: enabled,
92 obscureText: obscureText,
93 onChanged: onChanged,
94 autovalidateMode: autovalidateMode,
95 validator: validator,
96 maxLines: maxLines,
97 style: WidgetStateTextStyle.resolveWith(
98 (states) {
99 late final Color textColor;
100
101 if (states.contains(WidgetState.error)) {
102 textColor = context.inputTheme.focusedTextDefault;
103 } else if (states.contains(WidgetState.focused)) {
104 textColor = context.inputTheme.focusedTextDefault;
105 } else if (states.contains(WidgetState.disabled)) {
106 textColor = context.inputTheme.disabledText;
107 } else {
108 textColor = context.inputTheme.defaultText;
109 }
110
111 return context.typography.inputPlaceHolder.copyWith(
112 color: textColor,
113 );
114 },
115 ),
116 cursorColor: context.inputTheme.focusedTextDefault,
117 cursorHeight: 16,
118 decoration: InputDecoration(
119 labelText: labelText,
120 labelStyle: WidgetStateTextStyle.resolveWith(
121 (states) {
122 late final Color textColor;
123
124 if (states.contains(WidgetState.error)) {
125 textColor = context.inputTheme.errorTextDefault;
126 } else if (states.contains(WidgetState.focused)) {
127 textColor = context.inputTheme.focusedOnBrand;
128 } else if (states.contains(WidgetState.disabled)) {
129 textColor = context.inputTheme.disabledText;
130 } else {
131 textColor = context.inputTheme.defaultText;
132 }
133
134 return context.typography.inputPlaceHolder.copyWith(
135 color: textColor,
136 );
137 },
138 ),
139 floatingLabelStyle: WidgetStateTextStyle.resolveWith(
140 (states) {
141 late final Color textColor;
142
143 if (states.contains(WidgetState.error)) {
144 textColor = context.inputTheme.errorTextDefault;
145 } else if (states.contains(WidgetState.focused)) {
146 textColor = context.inputTheme.focusedOnBrand;
147 } else {
148 textColor = context.inputTheme.defaultText;
149 }
150
151 return context.typography.inputLabel.copyWith(
152 color: textColor,
153 );
154 },
155 ),
156 filled: true,
157 fillColor: enabled
158 ? context.inputTheme.defaultColor
159 : context.inputTheme.disabledColor,
160 border: MaterialStateOutlineInputBorder.resolveWith(
161 (states) {
162 late final Color borderColor;
163
164 if (states.contains(WidgetState.error)) {
165 borderColor = context.inputTheme.borderError;
166 } else if (states.contains(WidgetState.focused)) {
167 borderColor = context.inputTheme.borderFocused;
168 } else if (states.contains(WidgetState.disabled)) {
169 borderColor = context.inputTheme.borderDisabled;
170 } else if (states.contains(WidgetState.hovered)) {
171 borderColor = context.inputTheme.borderHover;
172 } else {
173 borderColor = context.inputTheme.borderDefault;
174 }
175
176 return OutlineInputBorder(
177 borderRadius: const BorderRadius.all(AppRadius.md),
178 borderSide: BorderSide(
179 color: borderColor,
180 ),
181 );
182 },
183 ),
184 hoverColor: Colors.transparent,
185 focusColor: Colors.transparent,
186 helperText: helperText,
187 helperStyle: WidgetStateTextStyle.resolveWith(
(states) {
188 late final Color textColor;
189
190 if (states.contains(WidgetState.error)) {
191 textColor = context.inputTheme.errorTextDefault;
192 } else if (states.contains(WidgetState.focused)) {
193 textColor = context.inputTheme.focusedOnBrand;
194 } else if (states.contains(WidgetState.disabled)) {
195 textColor = context.inputTheme.disabledText;
196 } else {
197 textColor = context.inputTheme.defaultText;
198 }
199
200 return context.typography.inputHint.copyWith(
201 color: textColor,
202 );
203 },
204 ),
205 errorText: errorText,
206 errorStyle: context.typography.inputHint.copyWith(
207 color: context.inputTheme.errorTextDefault,
208 ),
209 suffixIcon: suffixIcon,
210 prefixIcon: prefixIcon,
211 suffixIconConstraints: suffixIconConstraints,
212 prefixIconConstraints: prefixIconConstraints,
213 ),
214 );
215 }
216 }
ThemeMode handler
For handling the dark, light, and system mode switching process, your can write a
simple controller and initializer as the following:
1 const _kThemeMode = 'themeMode';
2
3 /// {@template theme_scope_widget}
4 /// A class which handles all theme processes
5 ///
6 /// initialize() method should be used as app starter in order to use
7 /// [AppTheme] in the app
8
9 /// {@endtemplate}
10 class ThemeScopeWidget extends StatefulWidget {
11 /// {@macro theme_scope_widget}
12 const ThemeScopeWidget({
13 super.key,
14 required this.child,
15 required this.preferences,
16 });
17
18 /// The child widget
19 final Widget child;
20
21 /// The shared preferences
22 final SharedPreferences preferences;
23
24 /// Initialize the [ThemeScopeWidget] with the given [child] widget
25 static Future<ThemeScopeWidget> initialize(Widget child) async {
26 final preferences = await SharedPreferences.getInstance();
27 return ThemeScopeWidget(
28 preferences: preferences,
29 child: child,
30 );
31 }
32
33 /// In order to use methods of [ThemeScopeWidget] this function
34 /// should be called first. Theme change process will handled by
35 /// [ThemeScopeWidget] automatically.
36 static ThemeScopeWidgetState? of(BuildContext context) {
37 return
context.findRootAncestorStateOfType<ThemeScopeWidgetState>();
}
38
39
@override
40
State<ThemeScopeWidget> createState() => ThemeScopeWidgetState();
41
}
42
43
/// The state for [ThemeScopeWidget].
44
class ThemeScopeWidgetState extends State<ThemeScopeWidget> {
45
ThemeMode? _themeMode;
46
47
/// Change the theme mode
48
Future<void> changeTo(ThemeMode themeMode) async {
49
if (_themeMode == themeMode) return;
50
51
try {
52
final index = ThemeMode.values.indexOf(themeMode);
53
await widget.preferences.setInt(_kThemeMode, index);
54
55
setState(() {
56
_themeMode = themeMode;
57
});
58
} on Exception catch (_) {}
59
}
60
61
@override
62
void didChangeDependencies() {
63
super.didChangeDependencies();
64
65
try {
66
final themeModeIndex =
67
widget.preferences.getInt(_kThemeMode) ?? 0;
68
final themeMode = ThemeMode.values[themeModeIndex];
69
70
_themeMode = themeMode;
71
} on Exception catch (_) {
72
_themeMode = ThemeMode.system;
73
}
74
}
75
76
@override
77
Widget build(BuildContext context) {
78
final brightness = MediaQuery.platformBrightnessOf(context);
79
80
final appTheme = switch (_themeMode!) {
81
ThemeMode.light => AppTheme.light(),
82
ThemeMode.dark => AppTheme.light(),
83
ThemeMode.system =>
84
brightness == Brightness.dark ? AppTheme.light() :
85
AppTheme.light(),
86
};
87
88
return ThemeScope(
89
themeMode: _themeMode!,
90
appTheme: appTheme,
91
child: widget.child,
92
);
93
}
}
There are a few extension methods that allow developers to access any theme,
typography, and just a few lines of code.
context.buttonTheme.linkHove
r;
1 context.checkboxTheme.disabl
2 ed;
3 context.typography.titleSmal
l;
To optimize SVGs, we have used the new way, for all SVGs in the vectors package
will be compiled to optimized version at the build time: