Simple Themes in Flutter using Riverpod

In this tutorial I show you how to implement a dark and light theme in your Flutter app.

Adding a dark and a light mode is becoming increasingly popular in app development. In this tutorial, we will write a Flutter app with a light and a dark theme and extend Flutter’s ThemeData with custom attributes.

Goal of this Tutorial

In this tutorial, we will have a look at how to add a dark and a light theme to your Flutter project. Further, we will implement the functionality to switch between dark and light mode directly within your app.

Since often it is necessary to have custom, theme-specific attributes, we will have a look on how to solve this problem. This tutorial also shows how to load theme-specific assets.

You can check out the code for this tutorial on GitHub. Use the template branch as a starting point, or create your own project. The finished app is on branch solution.

Prerequisites

Before we start, we need to create a new Flutter project. We will be using a Flutter version supporting null-safety for this tutorial.

Furthermore, we need to add flutter_riverpod as dependency.

Lastly, we need to create an asset folder right in your root directory and reference it in our pubspec.yaml file. This allows us to load images from this directory later.

The pubspec.yaml should look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
name: theming
description: bettercoding.dev - Theming

publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ">=2.12.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^0.14.0+3

dev_dependencies:
  flutter_test:
    sdk: flutter

flutter:
  assets:
    - assets/

Dark and Light Theme: Specifying the look

Next, we create a theme.dart file in our lib folder. This file will contain the heart of our app. Here we will specify our ThemeDatas and create some utility classes and functions.

Let’s start with defining our themes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import 'package:flutter/material.dart';

// THEME PROVIDERS

// THEMES
// light
final _theme = ThemeData();

//dark
final _darkTheme = ThemeData();

// EXTENSIONS AND CLASSES

Here you can be creative how you want to style the app. This is how I defined my _theme and _darkTheme. Just make sure you set the brightness of your ThemeData correctly, so that the correct fallback values can be used.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import 'package:flutter/material.dart';

// THEME PROVIDERS

// THEMES
// light
final _theme = ThemeData(
  primaryColor: Colors.deepPurple,
  elevatedButtonTheme: ElevatedButtonThemeData(
    style: ElevatedButton.styleFrom(
      primary: Colors.deepPurple,
      shape: StadiumBorder(),
    ),
  ),
  textTheme: TextTheme(
      headline1: TextStyle(
        fontSize: 48,
        fontWeight: FontWeight.bold,
        color: Colors.black87,
      ),
      headline2: TextStyle(
        fontSize: 36,
        fontWeight: FontWeight.bold,
        color: Colors.black54,
      )),
);

//dark
final _darkTheme = ThemeData(
  brightness: Brightness.dark,
  primaryColor: Colors.deepPurple,
  elevatedButtonTheme: _theme.elevatedButtonTheme,
  textTheme: _theme.textTheme.apply(
    displayColor: Colors.white,
    bodyColor: Colors.white,
  ),
);

// EXTENSIONS AND CLASSES

Custom Attributes: Extending ThemeData

Often, you want to have additional theme-specific attributes, but extending ThemeData does not really work well ( if at all). For this tutorial, we are going to assume that we want to size our images differently on light and on dark mode.

To solve this problem, we create a new class CustomThemeData right under // EXTENSIONS AND CLASSES and add an imageSize attribute.

The class should look like this:

1
2
3
4
5
6
7
class CustomThemeData {
  final double imageSize;

  CustomThemeData({
    this.imageSize = 100,
  });
}

A size of 100 is used as the default value. Add any attributes to this class that you need in your app.

Now we create instances of CustomThemeData for both, light and dark theme and put them right below their corresponding ThemeData instances.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import 'package:flutter/material.dart';

// THEME PROVIDERS

// THEMES
// light
final _theme = ThemeData(...);

final _customTheme = CustomThemeData(
  imageSize: 150,
);

//dark
final _darkTheme = ThemeData(...);

final _customDarkTheme = CustomThemeData();

// EXTENSIONS AND CLASSES
...

In this case, images will have a size of 150 in the light theme, whereas the dark theme will fall back on the default value of 100.

To glue it all together, we create an extension that makes our CustomThemeData available to use.

1
2
3
4
extension CustomTheme on ThemeData {
  CustomThemeData get custom =>
      brightness == Brightness.dark ? _customDarkTheme : _customTheme;
}

Now, our custom theme attributes can be accessed using:

1
2
3
4
5
6
7
8
Theme.of
(
context
)
.
custom
.
imageSize

Amazing, right?

Providing our themes using Riverpod

After creating the style of our themes, we need to provide them to our MaterialApp widget. We are going to use * Riverpod* for this purpose. If you are not familiar with Riverpod checkout riverpod.dev.

We create following two providers below // THEME PROVIDERS:

1
2
3
// THEME PROVIDERS
final theme = Provider((ref) => _theme);
final darkTheme = Provider((ref) => _darkTheme);

Next, we continue in the main.dart file. A very basic implementation could look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

First, we need to define a ProviderScope. This is required by Riverpod to work. Afterwards, our main function is defined this way:

1
2
3
void main() {
  runApp(ProviderScope(child: MyApp()));
}

So far, the app won’t do much. Let’s replace the Container with something more useful.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer(
      builder: (context, watch, _) =>
          MaterialApp(
            theme: watch(theme),
            darkTheme: watch(darkTheme),
            home: Container(),
          ),
    );
  }
}

We need to wrap our MaterialApp with a Consumer widget from the Riverpod package. Consumer offers us the watch function that we will use to read (‘watch’) the values offered by our Providers.

By default, Flutter uses the brightness (light or dark mode) from the underlying system. For now, we leave it like this, but we will return later to implement switching themes within the app ( see: Theme Switching: changing the Theme within the App).

Finally, it is time to create a page that we want to show when the app starts. We create a file main_page.dart and add a StatelessWidget MainPage.

Feel free to be creative here. I’ll go with the following code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import 'package:flutter/material.dart';

class MainPage extends StatelessWidget {
  const MainPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('bettercoding.dev – theming'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            const SizedBox(height: 16),
            Text(
              'Welcome!',
              textAlign: TextAlign.center,
              style: Theme
                  .of(context)
                  .textTheme
                  .headline1,
            ),
            const SizedBox(height: 16),
            Text(
              'This is a tutorial about theming.',
              textAlign: TextAlign.center,
              style: Theme
                  .of(context)
                  .textTheme
                  .headline2,
            ),
            Spacer(),
            ElevatedButton(
              child: Text('Click me!'),
              onPressed: () {},
            )
          ],
        ),
      ),
    );
  }
}

Don’t forget to replace the Container with MainPage at the home attribute of your MaterialApp.

Now, let’s build our app.

Light theme… … and dark theme.

So far, so good. A basic implementation of the app is ready. But there is more.

Theme-specific Assets: Different images for light and dark mode

Very often, it is necessary to use different versions of an image when working with themes. As with the text color, the color of an image in dark theme needs to change.

In the assets folder, upload an image that you want to use for light mode and call it logo.png. Create a subfolder called dark and upload the dark mode version of the image with the same name.

The folder structure should look like this:

assets folder structure

In theme.dart, we add a function imageForName to the CustomTheme extension.

1
2
3
4
5
6
7
8
9
// EXTENSIONS AND CLASSES
extension CustomTheme on ThemeData {
  AssetImage imageForName(String name) {
    final path = brightness == Brightness.dark ? 'assets/dark' : 'assets/';
    return AssetImage('$path/$name');
  }

  ...
}

The function checks the current brightness and builds the path to the asset accordingly. Let’s use this function to add an image to the MainPage.

Add the following lines between Spacer and ElevatedButton:

1
2
3
4
5
6
7
8
9
Image
(
image: Theme.of(context).imageForName('logo.png'),
height: Theme.of(context).custom.imageSize,
),
Spacer
(
)
,

Don’t forget to import your theme.dart file, so your extension functions can be used.

Here we make use of two extension functions we built earlier. imageForName loads a theme-specific image from the assets. Just pass the correct name of the file and the function resolves the correct asset for you.

custom.imageSize lets us access a theme-specific attribute that we defined earlier.

Let’s build the app again and see what we have got.

Light theme with logo Dark theme with logo

We see that our app shows different images and adjusts the size according to what we have specified in our CustomThemeData.

Theme Switching: changing the Theme within the App

Finally, we are going to implement the functionality to switch between light and dark mode by clicking a button. For this, we create a new StateProvider in theme.dart, just below the other providers.

final themeMode = StateProvider((ref) => ThemeMode.light);

A StateProvider is a provider that holds a changeable state. We initialize this state with ThemeMode.light. This means, by default, our app will always open in light mode. This might not be ideal, but we’ll have a look at alternatives in “Persisting the current Theme”.

Next, we go to main.dart and set the themeMode of our MaterialApp widget. The widget should now look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
MaterialApp
(
theme: watch(theme),
darkTheme: watch(darkTheme),
themeMode: watch(themeMode).state
,
home
:
MainPage
(
)
,
)

And finally, let’s implement the onPressed callback of our ElevatedButton in main_page.dart.

1
2
3
4
5
6
7
8
9
ElevatedButton
(
child: Text('Click me!'),
onPressed: () {
final state = context.read(themeMode).state;
context.read(themeMode).state =
state == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
},
)

The context.read function – don’t forget to import the Riverpod library! – allows us to access the StateProvider. First, we read the state to see which mode is currently active. Then we assign a new value to the state – dark mode if we are currently in light mode and vice versa.

Setting the state causes all widgets that are watching the themeMode StateProvider to rebuild, using the new value. In our case, this is the MaterialApp widget.

Clicking the button, switches between light and dark mode.

Voilà! We now can conveniently switch between the different themes from within our app. In a larger app, you would probably add the logic setting the state of our themeMode StateProvider to your settings page.

Persisting the current Theme

One drawback this solution has is that the selected theme is reset once you restart the app. In order to keep the state over time, it is necessary to persist it at some point. Using shared preferences would be a possible solution.

I have implemented a solution in the persist_theme branch of the project repository. If you would like me to add a tutorial on how I did this, let me know by leaving a comment.

Finally

In this tutorial, we saw how light and dark mode can be implemented in Flutter using Riverpod. Additionally, we created a way to extend ThemeData with custom attributes and how to load theme-specific assets.

Like always, there are certainly other possible solutions as well. If you want me to implement this example using different packages than Riverpod, or if you have any suggestions to my solution, write me a comment below.

Built with Hugo
Theme based on Stack designed by Jimmy