April 4, 2024
Image: https://flutter.dev
I have seen a lot of tutorials, content, or even coworkers composing material components to achieve the desired style. This made me think, is this the best approach to handle design systems and component customization in Flutter Material?
Imagine you want to create a button with two states: one for normal operation and another for error operation. For the sake of simplicity in this article, let's ignore the DDD folder structure.
To create this button, we'll follow three steps:
// path: /src/domains/colors/my-colors.entity.dart
import 'package:flutter/material.dart';
class MyColors {
static const Color primary = Colors.blue;
static const Color secondary = Colors.indigo;
static const Color background = Colors.white;
static const Color error = Colors.red;
}
// path: /src/domains/buttons/my-button-state.entity.dart
enum MyButtonState { normal, error }
// path: /src/domains/buttons/my-button.component.dart
import 'package:flutter/material.dart';
import 'package:myapp/domains/colors/my-colors.entity.dart';
import 'package:myapp/domains/buttons/my-button-state.entity.dart';
class MyButton extends StatelessWidget {
final MyButtonState state;
final Widget? child;
final VoidCallback onPressed;
const MyButton({
super.key,
this.state = MyButtonState.normal,
this.child,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
Color? backgroundColor;
Color? foregroundColor;
switch (state) {
case MyButtonState.normal:
backgroundColor = MyColors.primary;
foregroundColor = MyColors.background;
case MyButtonState.error:
backgroundColor = MyColors.background;
foregroundColor = MyColors.error;
}
return ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
),
child: child,
);
}
}
This approach is straightforward and simple to implement. Initially, you may not see any problems with it, and for an MVP or a simple project, it's more than enough. However, consider scenarios where you need to change colors dynamically at runtime to implement a light and dark theme, or where your button needs to use clipBehavior
that ElevatedButton
has, but you did not implement it in MyButton
. Furthermore, consider the learning curve a new developer would face if every component in your application is custom.
With these considerations in mind, is there another option? This is where ThemeData comes into play, a powerful tool implemented directly in the Flutter framework that allows you to deeply customize your application without the need to create new customizations.
To use ThemeData, we'll follow 2 steps to implement our ElevatedButton
.
We'll create a Base Theme where all customizations will occur, using another ThemeData as the source. This will allow us to create derived Themes in the future.
// path: /src/core/themes/base.theme.dart
import 'package:flutter/material.dart';
ThemeData baseTheme(ThemeData themeData) => themeData.copyWith(
// Other Components Theme Configuration...
elevatedButtonTheme: ElevatedButtonThemeData(
style: ButtonStyle(
backgroundColor: MaterialStateColor.resolveWith(
(Set<MaterialState> states) {
Color? baseColor = themeData
.elevatedButtonTheme.style?.backgroundColor
?.resolve(states);
if (states.contains(MaterialState.error)) {
return themeData.colorScheme.background;
}
// Default color
return baseColor ?? themeData.colorScheme.primary;
},
),
foregroundColor: MaterialStateColor.resolveWith(
(Set<MaterialState> states) {
Color? baseColor = themeData
.elevatedButtonTheme.style?.backgroundColor
?.resolve(states);
if (states.contains(MaterialState.error)) {
return themeData.colorScheme.error;
}
// Default color
return baseColor ?? themeData.colorScheme.background;
},
),
shape: ButtonStyleButton.allOrNull<OutlinedBorder>(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(5.0)),
),
),
),
);
Thats it, now our application has the same behavior as before, but it uses native components. Even the variable style was provided using MaterialStateController
instead an custom enum.
import 'package:flutter/material.dart';
import 'package:myapp/core/themes/base.theme.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: "My App",
theme: baseTheme(
// THEME COLORS
ThemeData.light(useMaterial3: true),
),
home: Scaffold(
appBar: AppBar(
title: const Text("My App"),
),
body: Center(
child: Column(
children: [
// THIS IS MY NORMAL BUTTON
// WITH HIS STYLE COMMING FROM
// THEME DATA MATERIAL STATE CONTROLLER
ElevatedButton(
child: const Text("My Button"),
onPressed: () {},
),
// THIS IS MY ERROR BUTTON
// WITH HIS STYLE COMMING FROM
// THEME DATA MATERIAL STATE CONTROLLER
ElevatedButton(
statesController: MaterialStatesController({
MaterialState.error,
}),
child: const Text("My Button"),
onPressed: () {},
),
],
),
),
);
}
}
Now, let's address the initial questions.
Solved out of the box. Using native components, you have all the native parameters.
Same as before, you are using native components. Every Flutter programmer should know how to use them, so there's only the learning curve of the business rules and some components that do not exist in the framework.
Initially intimidating, but with this approach, it's quite easy to accomplish using the two simple steps.
In this step, we'll create two variation themes: light and dark.
// path: /src/core/themes/dark.theme.dart
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'base.theme.dart';
ThemeData darkTheme() => baseTheme(
ThemeData.dark(useMaterial3: true).copyWith(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blueAccent),
),
);
// path: /src/core/themes/light.theme.dart
import 'package:flutter/material.dart';
import 'base.theme.dart';
ThemeData lightTheme() => baseTheme(
ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
);
We'll import our new themes in the main app.
import 'package:myapp/core/themes/light.theme.dart';
import 'package:myapp/core/themes/dark.theme.dart';
Then, we'll change the main function to provide the new ThemeData to our App Main Page.
void main() {
final lightThemeData = lightTheme();
final darkThemeData = darkTheme();
runApp(MyApp(
initialTheme: lightThemeData,
lightTheme: lightThemeData,
darkTheme: darkThemeData,
));
}
And finally, we'll implement a state change to MyApp page using ValueNotifier as recommended by the Flutter team.
class MyApp extends StatelessWidget {
final ThemeData lightTheme;
final ThemeData darkTheme;
final ValueNotifier<ThemeData> themeDataNotifier;
MyApp({
super.key,
required ThemeData initialTheme,
required this.lightTheme,
required this.darkTheme,
}) : themeDataNotifier = ValueNotifier(initialTheme);
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
// LISTEN FOR CHANGES ON THE themeDataNotifier
return ValueListenableBuilder(
valueListenable: themeDataNotifier,
// BUILDER IS CALLED EVERY TIME THE NOTIFIER IS EMITED
builder: (context, ThemeData currentTheme, _) {
return MaterialApp(
title: "My App",
theme: currentTheme,
home: Scaffold(
appBar: AppBar(
title: const Text("My App"),
actions: [
// TOGGLE THEME DURING RUNTIME
// THIS IS GONNA RECREATE THE MATERIAL APP
// YOU CAN AVOID LOOSING YOU APP STATE BY
// CREATE THE HOME PAGE OUTSIDE THE
// ValueListenableBuilder COMPONENT
IconButton(
onPressed: () {
if (currentTheme == lightTheme) {
themeDataNotifier.value = darkTheme;
} else {
themeDataNotifier.value = lightTheme;
}
},
icon: currentTheme == lightTheme
? const Icon(Icons.dark_mode)
: const Icon(Icons.light_mode),
)
],
),
body: Center(
child: Column(
children: [
// THIS IS MY NORMAL BUTTON
// WITH HIS STYLE COMMING FROM
// THEME DATA MATERIAL STATE CONTROLLER
ElevatedButton(
child: const Text("My Button"),
onPressed: () {},
),
// THIS IS MY ERROR BUTTON
// WITH HIS STYLE COMMING FROM
// THEME DATA MATERIAL STATE CONTROLLER
ElevatedButton(
statesController: MaterialStatesController({
MaterialState.error,
}),
child: const Text("My Button"),
onPressed: () {},
),
],
),
),
),
);
},
);
}
}
In conclusion, leveraging Material Theme Data in Flutter offers a robust and efficient way to customize and style components in your application. By adopting this approach, developers can streamline their codebase, simplify maintenance, and ensure consistency across their UI elements.
The traditional method of manually customizing components may suffice for smaller projects or MVPs. However, as the project grows or demands for dynamic theming arise, relying on Material Theme Data proves to be more scalable and manageable.
By embracing ThemeData, developers can:
Overall, the power of Material Theme Data in Flutter empowers developers to create more maintainable, scalable, and visually consistent applications with ease. By embracing this approach, teams can focus more on building innovative features and less on repetitive styling tasks.