Skip to main content

Responsive design in Flutter, the easy way

·7 mins
Flutter Theming
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>

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<AppTextStyles> {
  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<AppTextStyles> copyWith({
    TextStyle? headline1,
    TextStyle? bodyText1,
  }) {
    return AppTextStyles(
      headline1: headline1 ?? this.headline1,
      bodyText1: bodyText1 ?? this.bodyText1,
    );
  }

  @override
  ThemeExtension<AppTextStyles> lerp(
    ThemeExtension<AppTextStyles>? 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<AppSpacings> {
  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<AppSpacings> copyWith({
    double? s,
    double? m,
    double? l,
  }) {
    return AppSpacings(
      s: s ?? this.s,
      m: m ?? this.m,
      l: l ?? this.l,
    );
  }

  @override
  ThemeExtension<AppSpacings> lerp(
    ThemeExtension<AppSpacings>? 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 and large, 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<AppThemes> {
  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<AppThemes> copyWith({
    AppTextStyles? appTextStyles,
    AppSpacings? appSpacings,
  }) {
    return AppThemes(
      appTextStyles: appTextStyles ?? this.appTextStyles,
      appSpacings: appSpacings ?? this.appSpacings,
    );
  }

  @override
  ThemeExtension<AppThemes> lerp(
    ThemeExtension<AppThemes>? other,
    double t,
  ) {
    if (other is! AppThemes) {
      return this;
    }
    return AppThemes(
      appSpacings: other.appSpacings,
      appTextStyles: other.appTextStyles,
    );
  }
}
Extending the ThemeData object>

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, and large. 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 the extensions argument. This way I can access the (nested) extension information throughout my entire application like Theme.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>

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<AppThemes>()!.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!>

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!

app

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<AppThemes>()!;

  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>

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.




comments powered by Disqus