How I Theme My Flutter Apps: Introducing "Skinning"

Last year, I wrote two articles about theming in Flutter, sharing my frustrations with ThemeData (part 1 & part 2). While ThemeData is powerful, its extensive properties can be overwhelming, especially when many of them are rarely used. After experimenting with a more intuitive approach, I developed a method called "Skinning." This method simplifies theming by focusing on the essential properties that change based on the device’s theme (light or dark mode) while coupling behavior and styling in custom widgets.

In this article, I’ll walk you through what Skinning is, why I prefer it over ThemeData, and how I implement it in my apps.


The Problem with ThemeData

When I first started using ThemeData, I found it bloated with hundreds of properties that were unnecessary for my projects. I often only needed to change a handful of colors or styles between light and dark modes. Trying to manage those changes through ThemeData made the theming process feel overly complicated. Additionally, ThemeData is primarily concerned with styling individual components, but what if you want to adjust both the behavior and styling in a unified way?

My Solution: "Skinning"

Instead of directly using Flutter’s components like ElevatedButton, AppBar, or Scaffold, I wrap them in custom widgets that encapsulate both behavior and appearance. This way, I have full control over how my components look and function, making global changes easier and cleaner.

Custom Widgets for Unified Behavior and Theming

For instance, when I need a primary button, I don’t use an ElevatedButton directly. Instead, I create a PrimaryButton widget that wraps the ElevatedButton internally, allowing me to manage both behavior and styling in one place.

class PrimaryButton extends StatelessWidget {
  final String label;
  final VoidCallback onPressed;

  const PrimaryButton({required this.label, required this.onPressed});

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: onPressed,
      style: ButtonStyle(
        backgroundColor: WidgetStateProperty.all(context.skin.primaryColor), // Using Skin for color
      ),
      child: Text(label),
    );
  }
}

This makes it easy to update all primary buttons by just modifying the PrimaryButton class. If I need to switch to an OutlinedButton, I do it once in the PrimaryButton class, and the change applies app-wide.

Skin: A Minimalist Alternative to ThemeData

To handle theming across light and dark modes, I use a SkinProvider, an InheritedWidget that provides two Skin objects: lightSkin and darkSkin. The app automatically switches between these based on the device’s theme configuration.

Here’s how the SkinProvider works:

class SkinProvider extends InheritedWidget {
  final Skin lightSkin;
  final Skin darkSkin;

  const SkinProvider({
    required this.lightSkin,
    required this.darkSkin,
    required Widget child,
  }) : super(child: child);

  static Skin of(BuildContext context) {
    final brightness = MediaQuery.of(context).platformBrightness;
    final provider = context.dependOnInheritedWidgetOfExactType<SkinProvider>()!;
    return brightness == Brightness.dark ? provider.darkSkin : provider.lightSkin;
  }

  @override
  bool updateShouldNotify(SkinProvider oldWidget) {
    return lightSkin != oldWidget.lightSkin || darkSkin != oldWidget.darkSkin;
  }
}

And here’s a basic Skin class:

class Skin {
  final Color primaryColor;
  final Color secondaryColor;

  Skin({
    required this.primaryColor,
    required this.secondaryColor,
  });
}

Instead of managing a large ThemeData object, I focus only on the properties that need to change between light and dark modes. In this example, I only define primaryColor and secondaryColor in each Skin, keeping it simple.

Extension Method for Easy Access

To make accessing the current Skin easier, I use an extension method on BuildContext. This allows me to quickly get the active theme in any widget with context.skin:

extension SkinExtension on BuildContext {
  Skin get skin => SkinProvider.of(this);
}

Now, in my custom widgets, I can easily access the current skin:

style: ButtonStyle(
  backgroundColor: WidgetStateProperty.all(context.skins.primaryColor),
),

This makes it easy to theme components based on the active light or dark mode.


Why I Use Skinning

  • Unified Behavior and Theming: By creating custom widgets like PrimaryButton, I can manage both the appearance and behavior of my UI elements in one place. This keeps my code DRY and simplifies maintenance.

  • Automatic Theme Switching: By providing both lightSkin and darkSkin via the SkinProvider, my app automatically switches between the two based on the device’s light or dark mode configuration. No extra logic is needed in each component to handle the mode switching.

  • Simplicity: Instead of dealing with the complexity of ThemeData, I focus on a leaner Skin class that includes only the properties I need. This keeps the theming logic clear and easy to manage.

  • Flexibility: When a design change happens, like switching all primary buttons from ElevatedButton to OutlinedButton, I only need to modify the PrimaryButton class. This change propagates across the entire app, reducing the risk of inconsistencies.


Conclusion

Skinning offers a simpler, more flexible approach to theming Flutter apps. By wrapping components in custom widgets and using a SkinProvider to manage both lightSkin and darkSkin, I can easily switch themes based on device configuration while keeping behavior and appearance tightly coupled.

This method has streamlined my theming process and allowed me to focus on what really matters: the specific styles and behaviors my app needs. If you’re looking for a more intuitive way to theme your Flutter apps, Skinning might be just what you need.