A Deep Dive into Theme Extensions

A Deep Dive into Theme Extensions

Theme extensions are a powerful way to manage your app's theme. Learn how to use them to manage your app's colors.

Theme Extension

Theming in Flutter can be difficult. Oftentimes, you might want to define app specific colors to access later. You can do this using theme extensions.

Counter with Neutral Colors

Theme Extension

Let’s create a theme extension for the neutral colors from Tailwind, to be used within our app.

After that implementation we will dive into how theme extensions actually work.

To keep it organized, we start by defining the neutral colors is their own class. This class will be our source of truth for all colors in the application. These colors will be for light mode, and for dark mode we will simply invert the values.

We also use static colors, we can also use them directly if we want.

class NeutralColorValues {
const NeutralColorValues();
static const neutral50 = Color(0xFFFAFAFA);
static const neutral100 = Color(0xFFF5F5F5);
static const neutral200 = Color(0xFFE5E5E5);
static const neutral300 = Color(0xFFD4D4D4);
static const neutral400 = Color(0xFFA3A3A3);
static const neutral500 = Color(0xFF737373);
static const neutral600 = Color(0xFF525252);
static const neutral700 = Color(0xFF404040);
static const neutral800 = Color(0xFF262626);
static const neutral900 = Color(0xFF171717);
static const neutral950 = Color(0xFF0A0A0A);
}

Create your ThemeExtension

Next step is defining our ThemeExtension. By creating a ThemeExtension, we will be able to access these colors throughout the app using the theme inherited widget.

We create a class named NeutralColors that extends ThemeExtension. Then we can access this later through the Theme inherited widget.

This ThemeExtension sets up all our colors for the neutral color spectrum from Tailwind and defines the light and dark mode version.

Dark mode in this case just inverts the colors.

@immutable
class NeutralColors extends ThemeExtension<NeutralColors> {
final Color neutral50;
...
const NeutralColors({
required this.neutral50,
...
});
static const light = NeutralColors(
neutral50: NeutralColorValues.neutral50,
...
);
// inverted neutral colors
static const dark = NeutralColors(
neutral50: NeutralColorValues.neutral950,
...
);
/// copyWith
@override
ThemeExtension<NeutralColors> lerp(
covariant ThemeExtension<NeutralColors>? other,
double t,
) {
if (other is! NeutralColors) {
return this;
}
return NeutralColors(
neutral50: Color.lerp(neutral50, other.neutral50, t)!,
...
);
}
}

We create our extension object using the brightness to figure out if we want to generate the dark or light version of the neutral colors.

Within the ThemeData you have a property for extensions., Here you would pass the neutral colors extension.

class AppTheme {
static ThemeData buildTheme(Brightness brightness) {
final isDark = brightness == Brightness.dark;
// here we define the dark or light mode version of the extension
final neutralColors = isDark ? NeutralColors.dark : NeutralColors.light;
return ThemeData(
brightness: brightness,
colorScheme: ColorScheme(
brightness: brightness,
surface: neutralColors.neutral100,
primary: neutralColors.neutral950,
onPrimary: neutralColors.neutral50,
secondary: neutralColors.neutral950,
onSecondary: neutralColors.neutral50,
...
),
scaffoldBackgroundColor: neutralColors.neutral50,
appBarTheme: AppBarTheme(
backgroundColor: neutralColors.neutral100,
foregroundColor: neutralColors.neutral950,
),
// EXTENSION PASSED HERE
extensions: [
neutralColors,
],
);
}
}

How do you access the colors?

How you would go about accessing those colors now in your UI?

Maybe your buttons background should be using neutral200 for light mode and neutral800 for dark mode.

You access this through the Theme.

Now using the neutral colors theme extension we have automatic light and dark mode support for the neutral color spectrum.

What if you have a component that should always be a static color independent of light or dark mode?

Widget build(BuildContext context) {
final neutralColors = Theme.of(context).extension<NeutralColors>();
// ... now we can use neutralColors.neutral200
// In light mode this will be neutral200
// in dark mode it will be the inverse, which means it will be neutral800.
}

If you remember from earlier, we defined a NeutralColorValues class which is the source of truth for our neutral colors.

This can be used directly if you need the static color rather than the automatic dark and light mode switching.

Widget build(BuildContext context) {
final neutral200 = NeutralColorValues.neutral200;
// access through the static property
}

That is all for how to implement custom colors with ThemeExtensions.

Deep Dive Gif

How does it work?

but let’s also dive in to how it actually works.

Let’s start with just the ThemeExtension itself. This is the class we inherit when creating our ThemeExtensions.

It’s just a basic interface, or in Dart language an abstract class. This enforces the implementation to follow a specific structure for creating a ThemeExtension.

abstract class ThemeExtension<T extends ThemeExtension<T>> {
/// Enable const constructor for subclasses.
const ThemeExtension();
/// The extension's type.
Object get type => T;
/// Creates a copy of this theme extension with the given fields
/// replaced by the non-null parameter values.
ThemeExtension<T> copyWith();
/// Linearly interpolate with another [ThemeExtension] object.
///
/// {@macro dart.ui.shadow.lerp}
ThemeExtension<T> lerp(covariant ThemeExtension<T>? other, double t);
}

Why the abstract class?

The abstract class allows Flutter to make sure that all the implementations of ThemeExtension follows the same blueprint. In other languages it’s also referred to as an Interface .

Let’s take a look at an example why this is important.

If we take a look at the factory ThemeData class you will notice how it has the property extensions .

This is a simple Iterable of the previous mentioned ThemeExtension abstract class.

factory ThemeData({
// ...
Iterable<ThemeExtension<dynamic>>? extensions,
// ...
}

If you remember in the ThemeExtension class we also have a copyWith and lerp method.

abstract class ThemeExtension<T extends ThemeExtension<T>> {
/// Enable const constructor for subclasses.
const ThemeExtension();
/// The extension's type.
Object get type => T;
/// Creates a copy of this theme extension with the given fields
/// replaced by the non-null parameter values.
ThemeExtension<T> copyWith();
/// Linearly interpolate with another [ThemeExtension] object.
///
/// {@macro dart.ui.shadow.lerp}
ThemeExtension<T> lerp(covariant ThemeExtension<T>? other, double t);
}

Lerp and copyWith?

So what is the copyWith and lerp implementations for?

ThemeExtension<T> copyWith();
ThemeExtension<T> lerp(covariant ThemeExtension<T>? other, double t);

If you are unfamiliar with copyWith, it allows you to create a new instance of the object you already have by copying and override certain fields.

When using the copyWith you will maintain all the other fields which you didn’t include in your copyWith method call.

/// Having copyWith
final updatedFlutterCourse = flutterCourse.copyWith(name: 'Best Flutter Course');
print(updatedFlutterCourse.name); // Best Flutter Course
print(updatedFlutterCourse.url); // hungrimind.com
/// Without copyWith
final updatedTheme = FlutterCourse(
name: 'Best Flutter Course',
url: 'hungrimind.com',
);

Theming in Flutter uses this approach by default, hence why you can update small parts of your theme with copyWith.

final colorScheme = ColorScheme(
brightness: brightness,
surface: neutralColors.neutral100,
primary: neutralColors.neutral950,
onPrimary: neutralColors.neutral50,
secondary: neutralColors.neutral950,
onSecondary: neutralColors.neutral50,
error: Colors.red.shade400,
onError: neutralColors.neutral50,
onSurface: neutralColors.neutral950,
surfaceTint: neutralColors.neutral100,
);
final updatedColorScheme = colorScheme.copyWith(error: Colors.red.shade300);

Now what about lerp, what is that and why is it neededfor the ThemeExtension we created?

/// Linearly interpolate with another [ThemeExtension] object.
///
/// {@macro dart.ui.shadow.lerp}
ThemeExtension<T> lerp(covariant ThemeExtension<T>? other, double t);

lerp other wise known as linear interpolation, calculates a smooth transition between two values (in this case themes and colors) by blending them proportionally based on a t value between 0 (start) and 1 (end).

With the context of Flutter themes, you can see linear interpolation working when you switch between light and dark mode. There is a transition period for all the colors when that switch happens.

Linear Interpolation

A last remark is that within the ThemeData, all the extensions we pass are added to a Map.

Because of this we have the simplified syntax of using the type to access the specific extension on the theme.

Widget build(BuildContext context) {
final neutralColors = Theme.of(context).extension<NeutralColors>();
}
/// inside the ThemeData
final Map<Object, ThemeExtension<dynamic>> extensions;
T? extension<T>() => extensions[T] as T?;
Course thumbnail

Want to learn Flutter?

We teach you all you need to confidently build apps at hungrimind.com/learn/flutter

Thank you for reading!

YouTube Video

Get articles right in your inbox

No spam, unsubscribe anytime. We treat your inbox with respect.