6

Switch Themes with Flutter Bloc – Dynamic Theming Tutorial (Dark & Light Theme)

Themes are a sure way to add a vibrant touch to your apps. With Flutter's theming support, you can customize literally everything with only a few lines of code. What if you want to let your users change the themes on the fly though? At the very least, every app should support light and dark mode.

One option is to hack something together using StatefulWidgets. Another, and better option, is to use the flutter_bloc library to create an extensible and manageable theme switching framework.

Project Setup

Before we get started building the UI, let's add a few dependencies. We obviously need flutter_bloc and we're also going to add equatable - this is optional, but should we need value equality, this package will do the repetitive work for us.

pubspec.yaml

...
dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^0.20.0
  equatable: ^0.4.0
...

Let's also create the basic scaffolding of this project. The most important part is the global/theme folder - that's where the ThemeBloc and other related code will be located. After all, themes should be applied globally.

The project structure

Making Custom Themes

Before doing anything else, we first have to decide on the themes our app will use. Let's keep it simple with only 4 themes - green and blue, both with light and dark variants. Create a new file app_themes.dart inside the theme folder.

Flutter uses a class ThemeData to, well, store theme data. Since we want to configure 4 distinct instances of ThemeData, we will need a simple way to access them. The best option is to use a map. We could use strings for the keys...

app_themes.dart

final appThemeData = {
  "Green Light": ThemeData(
    brightness: Brightness.light,
    primaryColor: Colors.green,
  ),
  ...
};

As you can imagine, as soon as we add multiple themes, strings will become cumbersome to use. Instead, let's create an enum AppTheme. The final code will look like this:

app_themes.dart

import 'package:flutter/material.dart';

enum AppTheme {
  GreenLight,
  GreenDark,
  BlueLight,
  BlueDark,
}

final appThemeData = {
  AppTheme.GreenLight: ThemeData(
    brightness: Brightness.light,
    primaryColor: Colors.green,
  ),
  AppTheme.GreenDark: ThemeData(
    brightness: Brightness.dark,
    primaryColor: Colors.green[700],
  ),
  AppTheme.BlueLight: ThemeData(
    brightness: Brightness.light,
    primaryColor: Colors.blue,
  ),
  AppTheme.BlueDark: ThemeData(
    brightness: Brightness.dark,
    primaryColor: Colors.blue[700],
  ),
};

Adding the Bloc

Having custom themes created, we can move on to create a way for the UI to initiate and also listen to theme changes. This will all happen inside a ThemeBloc which will receive ThemeChanged events, figure out which theme should be displayed, and then output ThemeState containing the proper ThemeData pulled from the Map created above.

If you're new to the Bloc library or the reactive Bloc pattern in general, check out the following tutorial.

While we could create all the files and classes manually, there's a handy extension for both VS Code and IntelliJ which will spare us some time. At least in VS Code, right click on the theme folder and select Bloc: New Bloc from the menu. The name should be "theme" and select "yes" to use the equatable package. This will generate 4 files. One of them, called "bloc", is a barrel file which just exports all the other files.

ThemeEvent

There will be only one event ThemeChanged which pass the selected AppTheme enum value to the Bloc.

theme_event.dart

import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';

import '../app_themes.dart';

@immutable
abstract class ThemeEvent extends Equatable {
  // Passing class fields in a list to the Equatable super class
  ThemeEvent([List props = const []]) : super(props);
}

class ThemeChanged extends ThemeEvent {
  final AppTheme theme;

  ThemeChanged({
    @required this.theme,
  }) : super([theme]);
}

ThemeState

Similar to the event, there will be only a single ThemeState. We will actually make the generated class concrete (not abstract), instead of creating a new subclass.  There can logically be only one theme in the app. On the other hand, there can be multiple events which cause the theme to change.

This state will hold a ThemeData object which can be used by the MaterialApp.

theme_state.dart

import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:meta/meta.dart';

@immutable
class ThemeState extends Equatable {
  final ThemeData themeData;

  ThemeState({
    @required this.themeData,
  }) : super([themeData]);
}

ThemeBloc

Bloc is the glue which connects events and states together with logic. Because of the way we've set up the app theme data, ThemeBloc will take up only a few simple lines of code.

theme_bloc.dart

import 'dart:async';
import 'package:bloc/bloc.dart';
import '../app_themes.dart';
import './bloc.dart';

class ThemeBloc extends Bloc<ThemeEvent, ThemeState> {
  @override
  ThemeState get initialState =>
      // Everything is accessible from the appThemeData Map.
      ThemeState(themeData: appThemeData[AppTheme.GreenLight]);

  @override
  Stream<ThemeState> mapEventToState(
    ThemeEvent event,
  ) async* {
    if (event is ThemeChanged) {
      yield ThemeState(themeData: appThemeData[event.theme]);
    }
  }
}

Changing Themes

To apply a theme to the whole app, we have to change the theme property on the root MaterialApp. The ThemeBloc will also have to be available throughout the whole app. After all, we need to use its state in the aforementioned MaterialApp and also dispatch events from the PreferencePage, which we are yet to create.

Let's wrap the root widget of our app be a BlocProvider and while we're at it, also add a BlocBuilder which will rebuild the UI on every state change. Since we're operating with the ThemeBloc, the whole UI will be rebuilt when a new ThemeState is outputted. 

main.dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import 'ui/global/theme/bloc/bloc.dart';
import 'ui/home/home_page.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      builder: (context) => ThemeBloc(),
      child: BlocBuilder<ThemeBloc, ThemeState>(
        builder: _buildWithTheme,
      ),
    );
  }

  Widget _buildWithTheme(BuildContext context, ThemeState state) {
    return MaterialApp(
      title: 'Material App',
      home: HomePage(),
      theme: state.themeData,
    );
  }
}

Of course, the UI won't ever be rebuilt just yet. For that we need to dispatch the ThemeChanged event to the ThemeBloc. Users will select their preferred theme in the PreferencePage, but first, to make the UI a bit more realistic, let's add a dummy HomePage.

home_page.dart

import 'package:flutter/material.dart';
import '../preference/preference_page.dart';

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Home'),
        actions: <Widget>[
          IconButton(
            icon: Icon(Icons.settings),
            onPressed: () {
              // Navigate to the PreferencePage
              Navigator.of(context).push(MaterialPageRoute(
                builder: (context) => PreferencePage(),
              ));
            },
          )
        ],
      ),
      body: Center(
        child: Container(
          child: Text(
            'Home',
            style: Theme.of(context).textTheme.display1,
          ),
        ),
      ),
    );
  }
}

PreferencePage is where the ThemeChanged events will be dispatched. Again, because of the way we can access all of the app themes, implementing the UI will be as simple as accessing the appThemeData map in a ListView.

preference_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:theme_switching_prep/ui/global/theme/app_themes.dart';
import 'package:theme_switching_prep/ui/global/theme/bloc/bloc.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Preferences'),
      ),
      body: ListView.builder(
        padding: EdgeInsets.all(8),
        itemCount: AppTheme.values.length,
        itemBuilder: (context, index) {
          // Enums expose their values as a list - perfect for ListView
          // Store the theme for the current ListView item
          final itemAppTheme = AppTheme.values[index];
          return Card(
            // Style the cards with the to-be-selected theme colors
            color: appThemeData[itemAppTheme].primaryColor,
            child: ListTile(
              title: Text(
                itemAppTheme.toString(),
                // To show light text with the dark variants...
                style: appThemeData[itemAppTheme].textTheme.body1,
              ),
              onTap: () {
                // This will make the Bloc output a new ThemeState,
                // which will rebuild the UI because of the BlocBuilder in main.dart
                BlocProvider.of<ThemeBloc>(context)
                    .dispatch(ThemeChanged(theme: itemAppTheme));
              },
            ),
          );
        },
      ),
    );
  }
}

Now launch the app and test it out! Of course, once the app is closed, the selected theme won't be remembered on the next launch. You can use a persistence library of any sort, such as simple preferences, SEMBAST or even Moor. Just persist the selected theme inside the ThemeBloc whenever the ThemeChanged event is dispatched.

Conclusion

Changing themes is a feature which every production app should have. With flutter_bloc and some clever design decisions along the way, we managed to painlessly change the themes while keeping the code maintainable, organized and clean.

Matej Rešetár
 

Matej is an app developer with a knack for teaching others. If he's not programming, making tutorials or doing other business, he's mostly working out, listening to audiobooks and taking cold showers.

  • Harsh Borse says:

    pls upload the code for simple preferences for saving the state . so that next time it loads on selected theme.
    can’t get it done

  • Seruja says:

    Very good as usual.
    Please remake your “firebase-firestore-chat-app” in flutter, implementing flutter_bloc & Equatable as well. Thank you in advance.

  • Hello Matej, cool tutorial! I followed with success but i now want to persist the selected theme for the user. I tried using SharedPreferences to store the selected theme as a string, but in the initialState getter of the ThemeBloc i can’t use async function to wait for SharedPreferences to return the stored value. Any pointers on how to go about this? Also I use enum_to_string package to convert the stored string theme value back to enum.

    Thanks!

  • >