April 5, 2024

The Power of Material Theme Data in Flutter

Pedro Soares

Image: https://flutter.dev

The power of material theme data in Flutter

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?

First, How People Are Doing It

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:

1) Create a Color Palette to Use in My Components

// 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;
}

2) Now Create an Enum to Handle My Button States

// path: /src/domains/buttons/my-button-state.entity.dart
enum MyButtonState { normal, error }

3) At Last, Create a Custom Button Component with its Own Parameters and Style

// 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.

Using ThemeData

To use ThemeData, we'll follow 2 steps to implement our ElevatedButton.

1) Creating a Base Theme

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)),
          ),
        ),
      ),
    );

2) Creating our Application with the Theme

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.

1- Missing clipBehavior parameter

Solved out of the box. Using native components, you have all the native parameters.

2- New developer joins the team

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.

3- And Last but Not Least, Runtime Light and Dark Theme

Initially intimidating, but with this approach, it's quite easy to accomplish using the two simple steps.

Step 1: Create a Derived Theme

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,
      ),
    );
Step 2: Implement the Theme and Theme Switcher

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: () {},
                  ),
                ],
              ),
            ),
          ),
        );
      },
    );
  }
}

Conclusion

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:

  1. Utilize native components, ensuring access to all native parameters and features.
  2. Reduce the learning curve for new team members by sticking to familiar Flutter components.
  3. Seamlessly implement runtime theme changes, such as light and dark themes, without compromising application performance or stability.

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.