In this article I’ll discuss coding for dark mode in past, recent and future OS releases when writing apps in Flutter.
Flutter was built with theming, so dealing with OS-level dark mode is pretty straightforward. But it’s not that simple. You also have to:
- Make custom widgets (or decorations like gradients or shadows) and non-platform flutter widgets (from packages like Google Maps, for example) look acceptable in all themes.
- Make sure custom widgets (or decorations) update properly when the user changes the theme on their device.
- Degrade gracefully for users running on older versions of their operating systems and users that prefer to set the theme on individual apps.
This article will discuss how to deal with those issues. I’ll include example code.
The information for this article comes from many sources, including this article by Matt Carroll, which explains how Flutter supports dark mode natively on Android. I expect native support on iOS will arrive shortly after iOS 13 is released. It also includes information from this article by Matthias Schuyten, which gives details on how to style Flutter Google Maps Plugin maps.
Dark Mode is Here; Your App Has To Adjust
The latest versions of iOS and Android both have dark mode. I had to deal with dark mode while testing an Android/iOS NYC transit app I wrote for my family. When I first wrote it, system-wide dark mode wasn’t a thing, and the app was a sea or bright colors. While the app worked well on both iPhone and Android, using the app on phones with dark mode was shocking, especially outdoors at night. While Flutter made updating the app to support dark mode automatic, outside packages and custom components were not as easy. In this case, the flutter google maps package does not (and should not) automatically switch themes with platform-level theme changes. And colored widgets in the app needed some additional redesign for the different modes.
Unless your app is monochrome, dealing with light or dark themes is tricky, especially with text against colored backgrounds. Keep in mind, when users choose a dark theme, they want all of the major elements to be dark and all contrasting elements, like text and icons to be light. Adding colors that makes that tricky. I suspect that’s why Google removed all brand coloring for apps (e.g., a medium dark red, #D44638 and dark red secondary accent #B23121 for gmail) as a first step in supporting dark modes. Keeping the text black to switch to light theme creates a hard to read header:
Let’s start by figuring out how dark mode should work for our apps. I think it’s best to examine this from one perspective: what works best for our users?
How Should It Work?
This isn’t something your user should think about unless they specifically care; it should be easy to understand and work the way they expect; and it should hide any complicated logic. We have a model that works in production apps like Google Calendar: the simplest version of the intersection of theme preferences is clear: LIGHT, DARK and SYSTEM, with system as the default.
I recommend defaulting to System for three reasons. Firstly, most users who care about light and dark modes already chose their preferred mode using the system. The users’ default preference is already defined. Secondly, for users who have the option and don’t care, every other part of the system uses the system value (generally, light mode). Thirdly, it acts like every other app when running on older versions of the OS that don’t support light/dark themes.
How do we implement this? Let’s look at some Flutter code.
Dark Mode is Already Working in Flutter (on Android)
You do have to do a little work to enable it, though. If you are using the MaterialApp class in your application, it allows you to customize the light and dark themes as part of your app’s definition. These themes will be linked to the system’s light/dark mode settings.
You can customize the colors and fonts of each theme:
As you can see, you can modify the default theme however you want and Flutter widgets will update themselves to match the system. But this doesn’t help with custom and customized widgets. What if we have a widget with a deep red background with a Text widget on top of it? When the user selects light mode, the default black text will become unreadable. How do we know what brightness the user has chosen?
Querying the System’s Brightness
If the user is using the most recent versions of the Android, iOS or Fuschia, we would want to know what theme the device is showing on a platform level. The safest way is to use the context’s media query data. If this returns Brightness.dark, that means dark mode is selected.
You can also get the platform brightness directly from the system window if you want to query values at that level. You would do this with code similar to
Window window = WidgetsBinding.instance.window;
(You should never really have to do this. If you find yourself using the Window directly, you’re probably doing something wrong and there is a better way to do it that allows for easier mock and headless testing. In this case, you probably should use MediaQuery.)
You can check this on widget init and set widget values appropriately. But what about when the user changes to light or dark mode (or it happens automatically at a predetermined time, like sunset)? Most widgets will automatically deal with that, but what about your custom components, like the embedded map or custom decorations which won’t update? How do they know when to update?
Listening for Dark Mode Changes
We have to listen for theme changes on a platform level ourselves, usually in the main part of our app. You can use WidgetsBinding in a stateful widget that subclasses (as a mixin) the abstract WidgetsBindingObserver.
WidgetsBinding gives you hooks to changes outside of the usual widget lifecycle methods, like the phone rotating or the user changing the device font size (say you hand your phone to an elder relative who needs larger fonts). For this case, it provides a hook to changes in platform brightness. You add your widget as a listener in initState(). This does require you to release the reference when your widget’s state object is permanently removed from the widget tree by overriding dispose(). For example:
This works well. Flutter’s widgets work automatically and we can set the values for our custom widgets safely. But what about outside widgets? How do we customise them? I’ll use a common package: Google Maps for Flutter.
Implementing Dark Mode in an Embedded Google Map
One of Flutter’s biggest strengths is its open, well-supported package system. There are generally well-written and maintained packages for every need. One of the best (and one I have used in several projects including this one) is the google maps package. The default map view is extremely bright and does not automatically update itself to theme changes. Luckily, maps allows for theming. An excellent article on this can be found here.
Google provides a theming web tool to generate map themes at https://mapstyle.withgoogle.com/.
I used that tool to generate a default light and dark map theme. Maps use a json format to save map styles. You can load the map definitions in real time using flutter asset loading.
First, generate light and a dark json style files using the web tool. Save these in the assets folder. You can load the files as strings using rootBundle:
Once the map is loaded and the map’s controller is set (from onMapCreated), you can safely use the styles when building the map widget:
The map now dynamically updates its style to match the system.
So we have taken care of updating the app to match the device’s theme. But what about users that want to set the app’s theme independent of the system or users on older versions of the operating system? How do we set the theme independently?
Changing the Theme Independent of the System
Setting the theme independently is really two choices (dark/light), a subset of the three choices I mentioned above. The simple UI works well in this case.
But we want to save the user’s choice between sessions. There are several ways to do this, from a properties file, sqlite or remotely. It was designed with use cases like this in mind. I recommend using the Shared Preferences plugin. (I would not store the light/dark mode preference remotely; it is a per-device preference.)
Here’s a Simple Recipe to Save Theme Preferences
To use Shared Preferences, import the Preferences plugin and create a way to safely obtain a reference, which you instantiate in the startup of your app (probably in initState() of the main widget state). Use an enum for theme values.
Second, if you want to have listeners to system theme updates, define a method to receive those updates. In this case I used a typedef, PrefsListener.
In this case, the preference I care about is the application’s theme:
static const String THEME_PREF = “AppTheme”;
You then create a hook for other classes to listen for theme updates and to deregister (addListener() and removeListener()).
The example preferences implementation is below:
Flutter’s support for dark mode:
Styling google maps in flutter: