One of the biggest pains when creating a Flutter app is definitely when you want your app to have a responsive design. But a solution is finally here, and we don’t need to use any third-party package for it! With the release of Flutter 3 we are finally able to extend our ThemeData in an easy way, and it is going to help us to implement responsive designs in our apps!
With a background in web development I am used to creating apps with a responsive design. Responsive design basically means that your application adjusts to various screen sizes. With the switch to Flutter, it became almost immediately clear that it isn’t as straightforward to have global theming adjusted to the screen resolution. There are plenty of packages on pub.dev that try to help you with this but I prefer to have control over behavior myself. Especially when you work on projects where design systems are a thing.
With the addition of the ThemeExtension class in Flutter 3 I suddenly felt that possibilities started to open. This class allows you to define custom additions to a ThemeData object, awesome 🔥!
Creating the theme extensions
To demonstrate this, I will create two theme extensions. One for custom text styles and another for spacing values that I will use to apply margin and/or padding in my application. I’ve kept it really simple in this example but there are no limitations in the implementation! Let’s create them first and I will explain it afterwards.
@immutable
class AppTextStyles extends ThemeExtension {
const AppTextStyles({
required this.headline1,
required this.bodyText1,
});
final TextStyle headline1;
final TextStyle bodyText1;
static const small = AppTextStyles(
headline1: TextStyle(fontSize: 24.0),
bodyText1: TextStyle(fontSize: 16.0),
);
static const medium = AppTextStyles(
headline1: TextStyle(fontSize: 32.0),
bodyText1: TextStyle(fontSize: 18.0),
);
static const large = AppTextStyles(
headline1: TextStyle(fontSize: 36.0),
bodyText1: TextStyle(fontSize: 20.0),
);
@override
ThemeExtension copyWith({
TextStyle? headline1,
TextStyle? bodyText1,
}) {
return AppTextStyles(
headline1: headline1 ?? this.headline1,
bodyText1: bodyText1 ?? this.bodyText1,
);
}
@override
ThemeExtension lerp(
ThemeExtension? other,
double t,
) {
if (other is! AppTextStyles) {
return this;
}
return AppTextStyles(
headline1: TextStyle.lerp(headline1, other.headline1, t)!,
bodyText1: TextStyle.lerp(bodyText1, other.bodyText1, t)!,
);
}
}
@immutable
class AppSpacings extends ThemeExtension {
const AppSpacings({
required this.s,
required this.m,
required this.l,
});
final double s;
final double m;
final double l;
static const small = AppSpacings(s: 4.0, m: 12.0, l: 20.0);
static const medium = AppSpacings(s: 12.0, m: 20.0, l: 28.0);
static const large = AppSpacings(s: 20.0, m: 28.0, l: 36.0);
@override
ThemeExtension copyWith({
double? s,
double? m,
double? l,
}) {
return AppSpacings(
s: s ?? this.s,
m: m ?? this.m,
l: l ?? this.l,
);
}
@override
ThemeExtension lerp(
ThemeExtension? other,
double t,
) {
if (other is! AppSpacings) {
return this;
}
return AppSpacings(
s: lerpDouble(s, other.s, t)!,
m: lerpDouble(m, other.m, t)!,
l: lerpDouble(l, other.l, t)!,
);
}
}
Ok, that is a big chunk of boilerplate-y code 😅. Lets analyze what I did:
- For both extensions I’ve defined three different varieties of the theme.
small
,medium
andlarge
, which represent the breakpoints that I will add later in the app itself. - The
copyWith
method which can be used to create a copy of the extension with the given fields replaced by the non-null parameter values. - The
lerp
method which will ensure smooth transitions of properties when switching themes.
Combining extensions
To make it easier to create and include other theme extensions in the future, I am going to create a root theme extension that will combine all theme extensions that I create.
class AppThemes extends ThemeExtension {
const AppThemes({
required this.appSpacings,
required this.appTextStyles,
});
final AppSpacings appSpacings;
final AppTextStyles appTextStyles;
static const AppThemes small = AppThemes(
appSpacings: AppSpacings.small,
appTextStyles: AppTextStyles.small,
);
static const AppThemes medium = AppThemes(
appSpacings: AppSpacings.medium,
appTextStyles: AppTextStyles.medium,
);
static const AppThemes large = AppThemes(
appSpacings: AppSpacings.large,
appTextStyles: AppTextStyles.large,
);
@override
ThemeExtension copyWith({
AppTextStyles? appTextStyles,
AppSpacings? appSpacings,
}) {
return AppThemes(
appTextStyles: appTextStyles ?? this.appTextStyles,
appSpacings: appSpacings ?? this.appSpacings,
);
}
@override
ThemeExtension lerp(
ThemeExtension? other,
double t,
) {
if (other is! AppThemes) {
return this;
}
return AppThemes(
appSpacings: other.appSpacings,
appTextStyles: other.appTextStyles,
);
}
}
Extending the ThemeData object
Now that I’ve defined the theme extensions that I want to use in my app I can make them available in my theme data. I’ve created a class that will hold all my global theme data information. Let’s take a look at the implementation step by step 🧐.
- A different
ThemeData
object is created for the different screen resolutions that I differentiate:small
,medium
, andlarge
. The root theme extension is referenced, and will include the other extensions. _baseTheme
is the basis for all global theme data information.AppThemes
is added to theextensions
argument. This way I can access the (nested) extension information throughout my entire application likeTheme.of(context).extension<AppThemes>()
.*
*You could argue that there is no need for a theme extension for the text styles since that is added to the global text theme, but if you ever find yourself adding custom text styles that are not part of TextTheme
(e.g. text style for a link), it is super easy to access that via the theme extension.
class ResponsiveTheme {
const ResponsiveTheme._();
static final small = _baseTheme(AppThemes.small);
static final medium = _baseTheme(AppThemes.medium);
static final large = _baseTheme(AppThemes.large);
static ThemeData _baseTheme(AppThemes appThemes) {
final textStyles = appThemes.appTextStyles;
final spacings = appThemes.appSpacings;
return ThemeData(
extensions: [appThemes],
textTheme: TextTheme(
headline1: textStyles.headline1,
bodyText1: textStyles.bodyText1,
),
appBarTheme: AppBarTheme(
titleTextStyle: textStyles.headline1,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ButtonStyle(
padding: MaterialStateProperty.all(EdgeInsets.all(spacings.m)),
),
),
);
}
}
Next, I’ll use the LayoutBuilder
widget to apply the correct theme based on a certain breakpoint. In the ResponsiveTheme
I’ve created three different themes, so there will be three breakpoints with a different ThemeData object based on that breakpoint.
class AppBreakpoints {
const AppBreakpoints._();
static const int small = 320;
static const int medium = 680;
static const int large = 960;
}
class ResponsiveApp extends StatelessWidget {
const ResponsiveApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
late ThemeData themeData;
if (constraints.maxWidth <= AppBreakpoints.small) {
themeData = ResponsiveTheme.small;
} else if (constraints.maxWidth <= AppBreakpoints.medium) {
themeData = ResponsiveTheme.medium;
} else {
themeData = ResponsiveTheme.large;
}
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: themeData,
home: const HomeScreen(),
);
},
);
}
}
Applying the theme data
In my HomeScreen
widget I will now showcase the power of theme extensions. All widgets have some kind of theming applied to it, either in the global theme, or in-line in this widget. Since the ResponsiveApp
returns a different ThemeData
object based on the screen resolution it means that the theme extensions will therefore also change their values.
E.g. Theme.of(context).extension<AppThemes>()!.spacings.l
returns either 20.0
, 28.0
or 36.0
, depending on the theme data.
This means that the font size and the margins/paddings will change whenever the ThemeData
changes in the ResponsiveApp
.
class HomeScreen extends StatelessWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final spacings = Theme.of(context).extension()!.appSpacings;
return Scaffold(
appBar: AppBar(
title: const Text('Responsive App'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'My responsive app',
style: Theme.of(context).textTheme.bodyText1,
),
SizedBox(
height: spacings.l,
),
ElevatedButton(
onPressed: () {},
child: const Text('Click here!'),
)
],
),
),
);
}
}
Let’s see this in action!
As you can see the font size, margin and padding changes when the screen resolution changes. Under the hood it is as simple as changing the ThemeData object 🙃. Very nice!
Accessing a ThemeExtension in your widgets
You might have noticed that accessing the ThemeExtension is a long line of code. Creating an extension on the BuildContext
can make our lives easier here 😄
extension ThemeX on BuildContext {
AppThemes get _appThemes => Theme.of(this).extension()!;
AppSpacings get spacings => _appThemes.appSpacings;
AppTextStyles get textStyles => _appThemes.appTextStyles;
}
Now you can access it directly on the BuildContext
:
SizedBox(height: context.spacings.l),
To conclude
Even when you don’t need to support different themes (yet), it is a good idea to use the power of ThemeExtension
from the start. If in a later stage you find out that you do need to support different screen resolutions, different brands and/or different color themes, it is much less work to add new theme information and you don’t have to make changes to how the theme is applied.
The possibilities of theme extensions are limitless. You can practically store anything in them and extend your theme any way you like.
You can find the complete source on Github.