Introduction
With more and more applications being made there is a feature that is almost necessary to have today - the famous dark mode that stresses the eyes a lot less than the default bright aesthetic.
In this post we'll be going through the process of creating a toggleable and persistent dark mode in a Flutter app from start to finish.
Getting started
Start off by making a new project using the Flutter CLI: flutter create <project_name>
Then we'll need two more packages, one being Riverpod and the other Shared preferences.
flutter pub add flutter_riverpod && flutter pub add shared_preferences
Riverpod will be used for state management and for notifying the entire app that the change from dark to light mode or vice versa happened. Shared preferences will be used to persist the changes when the app is turned off.
Setting up
By creating a new project, your main.dart
file should look like this:
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
This is the default boilerplate code that comes with a new app, it's up to you but I'll be changing the UI to something more barren just for convenience:
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: Scaffold(
appBar: AppBar(
title: const Text("Dark mode"),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [],
),
),
),
);
}
}
The MaterialApp
widget has a few properties we'll need to examine to get a better understanding of how theming works.
First off there's the themeMode
property which indicates what type of ThemeMode
is active. There are three of these modes, those being: ThemeMode.light
, ThemeMode.dark
and ThemeMode.system
.
You could leave the themeMode
with the system option in cases where you'd like for the phone itself to take control over the theming, but in this case we'll be doing it manualy. Switching between the values triggers the app to force a different theme, these being the light and dark mode themes defined with the theme
and darkTheme
properties, respectively.
Theming the modes
Speaking of the theme
and darkTheme
properties, they define which colors will be used to paint the UI depending on the theme chosen. Both of them use the ThemeData
class that defines many properties that describe the UI like which text will be used, which colors will be used for the text, AppBar
etc.
They also come with a default theme, one for the light (ThemeData.light()
) and the other for the dark (ThemeData.dark()
) mode.
The default dark mode looks like this:
But you can change it to whatever you'd like by editing the darkTheme
property:
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
darkTheme: ThemeData.dark(),
themeMode: ThemeMode.dark,
<...>
);
}
}
State management
In order to notify the app which theme to switch to we'll have to define a Riverpod notifier. A StateNotifier
hosts immutable state that can change according to user actions and furthermore centralizes a lot of business logic in a single place, read more here.
In addition to this we'll have to expose it to the app using a Provider
.
Make a file anywhere with the name darkModeProvider.dart
. The code we'll be using:
import 'package:flutter_riverpod/flutter_riverpod.dart';
class DarkModeNotifier extends StateNotifier<bool> {
DarkModeNotifier() : super(false);
void toggle() {
state = !state;
}
}
final darkModeProvider = StateNotifierProvider<DarkModeNotifier, bool>(
(ref) => DarkModeNotifier(),
);
Our DarkModeNotifier
class extends the generic StateNotifier
class and we set up the starting state by using super
. To change the state we'll need a method called toggle
which just switches from true to false and vice versa.
Finally we'll need to expose the notifier to the app using a provider which just returns an instance of the DarkModeNotifier
.
Import the same package into the main.dart
file and wrap the parameter of the runApp
function with a ProviderScope
widget:
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(const ProviderScope(child: MyApp()));
}
This is a necessary step as the app can't read a provider or watch for changes without it.
Furthermore, we need to spruce up our main view as well:
import 'package:dark_mode/darkModeProvider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
var darkMode = ref.watch(darkModeProvider);
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
darkTheme: ThemeData.dark(),
themeMode: darkMode ? ThemeMode.dark : ThemeMode.light,
<...>
);
}
}
The first thing to notice is that MyApp
now extends ConsumerWidget
and not StatelessWidget
. Both of these serve a similar role, but ConsumerWidget
is special because it allows Riverpod to function properly and it opens up the possibility of subscribing to our dark mode notifier changes.
Widget build(BuildContext context, WidgetRef ref)
Your IDE tools should already warn you about the second parameter the build method now needs and that is ref, WidgetRef
to be precise. This parameter is used for injecting providers into your widget and watching for changes, triggering events etc.
var darkMode = ref.watch(darkModeProvider);
The method we'll need to use here is watch
. As the name implies it watches for changes in the provider and the state of the notifier, reacting accordingly and changing the state that relies on this provider - in our case the themeMode
property.
Changing the state
By setting up the foundations of our state now we can use the notifier method to switch the internal state.
Let's add a Switch
to the app which triggers the toggle
method of the notifier:
import 'package:dark_mode/darkModeProvider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
var darkMode = ref.watch(darkModeProvider);
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
darkTheme: ThemeData.dark(),
themeMode: darkMode ? ThemeMode.dark : ThemeMode.light,
home: Scaffold(
appBar: AppBar(
title: const Text("Dark mode"),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Switch(
value: darkMode,
onChanged: (val) {
ref.read(darkModeProvider.notifier).toggle();
},
),
],
),
),
),
);
}
}
The value of the switch depends on the provider and upon tapping it the toggle
function is triggered toggling the value of the notifier state. This is achieved by using the read
method which uses the notifier property of the provider to expose the notifier methods.
Your app should look like this:
You can also use the dark mode provider in other widgets if you want a custom color or behaviour depending on if dark mode is turned on or not.
Persisting the changes
The changes we made allowed us to control whether dark mode was turned on or not but these changes will not be present if we turn off the app. To combat this we'll be using Shared preferences.
This package uses the already existing SharedPreferences
Android object which stores values as key-pairs locally on your phone akin to a cache.
To get the shared preferences instance you need to await it meaning that most of the methods needed to instantiate it will be asynchronous. To accommodate these changes we'll need to change the dark mode notifier just a tad bit:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
class DarkModeNotifier extends StateNotifier<bool> {
late SharedPreferences prefs;
Future _init() async {
prefs = await SharedPreferences.getInstance();
var darkMode = prefs.getBool("darkMode");
state = darkMode ?? false;
}
DarkModeNotifier() : super(false) {
_init();
}
void toggle() async {
state = !state;
prefs.setBool("darkMode", state);
}
}
final darkModeProvider = StateNotifierProvider<DarkModeNotifier, bool>(
(ref) => DarkModeNotifier(),
);
As we can't use the body of the constructor itself to initialize the state asynchronously we'll have to define a function that does it instead. Our toggle
method has changed a tiny bit as well, using the prefs to set a key-value pair.
Restarting the app now persists the changes.
Conclusion
Even though it can be done with a few other methods, implementing dark mode features into your application is an easy and useful thing to do.
You can find the GitHub repository here:
MatijaNovosel / flutter-dark-mode
🌜 Flutter dark mode tutorial.
Flutter dark mode
Flutter dark mode tutorial, described on my blog and on dev.to.
Requirements
Flutter: 3.0.0 Dart SDK: 2.17.0
Installation
- Add Flutter to your machine
- Open this project folder with Terminal/CMD
- Ensure there's no cache/build leftover by running
flutter clean
in the Terminal - Run in the Terminal
flutter packages get
Additional steps for iOS
- Open ios folder inside Terminal/CMD
- Run in the Terminal
pod install
- Run in the Terminal
pod update
Running the App
- Open Android Emulator or iOS Simulator
- Run
flutter run --flavor {RELEASE_TYPE} --dart-define flavor={RELEASE_TYPE}
- Supported release type:
development
,staging
, andproduction
Build an APK
- Run
flutter build apk --flavor {RELEASE_TYPE} --dart-define flavor={RELEASE_TYPE}
- The apk will be saved under this location:
[project]/build/app/outputs/flutter-apk/
- We can also build appbundle (.aab) by running this command:
flutter build appbundle --flavor {RELEASE_TYPE} --dart-define flavor={RELEASE_TYPE}
Build for iOS
- Follow the tutorial from this link: https://flutter.dev/docs/deployment/ios#create-a-build-archive-with-xcode
- Don't forget to add the…
Top comments (1)
Thank you!