Flutter Theming | The Right Way
As light and dark theme options can be seen in almost every app today, it has become a new norm for good user experience. This article will show how to add themes to any Flutter app without using any package for every platform in an easy, maintainable, and efficient way.
Find the updated article here.
Let’s start with creating the theme class.
Controll the overall app theme from this single place.
import 'package:flutter/material.dart';
class AppTheme extends ChangeNotifier {
static final _instance = AppTheme._();
AppTheme._();
factory AppTheme() {
return _instance;
}
}
The AppTheme
class extends ChangeNotifier
to notify listeners whenever the theme changes, which will then update the app with the new theme. The AppTheme
class is a singleton class for a single app theme state.
Add a variable to hold the current theme value in the AppTheme
class.
ThemeMode _themeMode = ThemeMode.system;
ThemeMode get themeMode => _themeMode;
The _themeMode
is a private variable with a getter, to avoid changing its value from outside the class. It can have one of the three values: ThemeMode.system
, ThemeMode.light
, or ThemeMode.dark
.
The _themeMode
is set to ThemeMode.system
initially, to use the system’s default theme when no theme is selected by the user for the app.
Add light and dark ThemeData
getters in the AppTheme
class.
ThemeData get lightTheme => ThemeData(
scaffoldBackgroundColor: Colors.white,
iconTheme: const IconThemeData(color: Colors.black),
textTheme: const TextTheme(
bodyLarge: TextStyle(fontSize: 16, color: Colors.grey),
titleLarge: TextStyle(
fontSize: 24,
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
);
ThemeData get darkTheme => ThemeData(
scaffoldBackgroundColor: Colors.grey[900],
iconTheme: const IconThemeData(color: Colors.white),
textTheme: TextTheme(
bodyLarge: TextStyle(fontSize: 16, color: Colors.grey[200]),
titleLarge: const TextStyle(
fontSize: 24,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
);
Specify all the theme styles in their respective ThemeData
. These styles will be accessible in the MaterialApp
root widget using the lightTheme
and darkTheme
getters.
Add a method to change the theme in the AppTheme
class.
void toggleTheme(BuildContext context){
switch (_themeMode) {
case ThemeMode.light:
_themeMode = ThemeMode.dark;
break;
case ThemeMode.dark:
_themeMode = ThemeMode.light;
break;
case ThemeMode.system:
if (Theme.of(context).scaffoldBackgroundColor == Colors.white) {
_themeMode = ThemeMode.dark;
} else {
_themeMode = ThemeMode.light;
}
}
notifyListeners();
}
The toggleTheme
method toggles the app theme between light and dark modes by changing the _themeMode
value. The logic is as follows.
- If the
_themeMode
was light mode, change it toThemeMode.dark
. - If the
_themeMode
was dark mode, change it toThemeMode.light
. - If the
_themeMode
was system default, then it can either be light or dark. So a condition has been added that checks for the current scaffold color to determine whether the system default is light or dark.if (Theme.of(context).scaffoldBackgroundColor == Colors.white){}
, if true the system default will be light mode else dark mode.
After the _themeMode
value is updated, simply notify the listener to update the app.
Use the AppTheme class's theme values in the MaterialApp root widget.
void main() => runApp(const MyApp());
class MyApp extends StatefulWidget {
const MyApp({Key? key}) : super(key: key);
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final _appTheme = AppTheme();
@override
void initState() {
super.initState();
_appTheme.addListener(() => setState(() {}));
}
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: _appTheme.lightTheme,
darkTheme: _appTheme.darkTheme,
themeMode: _appTheme.themeMode,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Toggle Theme',
style: Theme.of(context).textTheme.bodyLarge,
),
IconButton(
onPressed: () => _appTheme.toggleTheme(context),
icon: const Icon(Icons.settings_display_rounded),
),
],
),
),
),
),
);
}
}
Added a listener to AppTheme
class in the initState
method, which on notification calls setState
to rebuild the MaterialApp
widget with a new theme mode.
An IconButton
is used which calls the toggleTheme
method of the AppTheme
class when pressed and changes the app theme. You can simply use the onPressed
logic anywhere in the app.
The light and dark themes are now added to the app successfully!
But there is one problem, the app is not saving the selected theme state, so whenever the app re-opens it uses the system’s default theme, and the user has to change it every time, and we don’t want that.
Save the selected theme.
Here shared_preferences
is used to store the app theme value, but any other local storage would work fine.
Let’s create a local database helper class.
import 'package:shared_preferences/shared_preferences.dart';
class LocalDB {
SharedPreferences? _prefs;
Future<void> _init() async {
_prefs ??= await SharedPreferences.getInstance();
}
Future<bool?> isDarkMode() async {
await _init();
return _prefs?.getBool('isDarkMode');
}
Future<void> setTheme(bool isDarkMode) async {
await _init();
await _prefs?.setBool('isDarkMode', isDarkMode);
}
}
A boolean value is stored in the local database for the key isDarkMode
, true
indicates that the user has selected the dark mode, false
indicates that the user has selected the light mode, and null
indicates that the user has not selected any theme and that the system's default theme should be used.
Update the local database whenever the theme is toggled in the AppTheme
class.
final _localDb = LocalDB();
Future<void> toggleTheme(BuildContext context) async{
switch (_themeMode) {
case ThemeMode.light:
_themeMode = ThemeMode.dark;
break;
case ThemeMode.dark:
_themeMode = ThemeMode.light;
break;
case ThemeMode.system:
if (Theme.of(context).scaffoldBackgroundColor == Colors.white) {
_themeMode = ThemeMode.dark;
} else {
_themeMode = ThemeMode.light;
}
}
notifyListeners();
await _localDb.setTheme(_themeMode == ThemeMode.dark);
}
await _localDb.setTheme(_themeMode == ThemeMode.dark);
will set the isDarkMode
to true if _themeMode
is changed to ThemeMode.dark
otherwise to false in the local database.
Set the initial app theme based on the local database saved value.
Get the saved theme value from the local database when the AppTheme
class is instantiated for the first time.
AppTheme._() {
_getThemeMode();
}
Future<void> _getThemeMode() async {
final isDarkMode = await _localDb.isDarkMode();
if(isDarkMode == true){
_themeMode = ThemeMode.dark;
notifyListeners();
} else if(isDarkMode == false){
_themeMode = ThemeMode.light;
notifyListeners();
}
}
The _getThemeMode
is called from the private named constructor of the AppTheme
class, which in turn is called when the singleton object is created static final _instance = AppTheme._();
The _getThemeMode
first fetches the isDarkMode
flag value from the local database. If the value turns out to be true
, then _themeMode
is set to ThemeMode.dark
and notifyListeners()
is called to update the app with the dark theme. On the other hand, if the value turns out to be false
, then _themeMode
is set to ThemeMode.light
and notifyListeners()
is called to update the app with the light theme. And if the value turns out to be null
then we don’t have to do anything as _themeMode
was already initialized with ThemeMode.system
and the app uses the system’s default theme.
That’s it! The light and dark themes are added to the app and also user preference for the theme is stored locally and is used every time the app is re-opened.