One of the most exciting announcements at WWDC this year was the introduction of platform-wide dark mode in iOS 13. During WWDC a group of enthusiastic iOS engineers and designers from Instagram’s design systems team banded together to begin plotting out what it would take to adopt dark mode in our app. This week’s update to Instagram includes full support for iOS dark mode. This took months of work and collaboration between numerous design and engineering teams in the company. As such, we wanted to take some time to share how we approached adopting dark mode and some of the obstacles we encountered along the way.
Apple did an excellent job shaping how dark mode works in iOS 13. Most of the heavy lifting is done on your behalf by UIKit. Because of this, one of the key principles we had when building out dark mode support in our app was that we should “stand on the shoulders of giants” and try to stick with Apple’s APIs as much as possible. This is beneficial for several reasons.
- Ease of use — UIKit does most of the work in selecting appropriate colors and transitioning between light mode and dark mode. If we wrote our own APIs we’d have to handle this ourselves.
- Maintainability — Apple maintains the APIs so we don’t have to. Any wrappers we have can ultimately be switched over to just use UIKit APIs as soon as our minimum supported OS version is iOS 13.
- Familiarity — Newcomers to Instagram’s iOS codebase who are familiar with how UIKit does dark mode will feel right at home.
That being said, we didn’t use UIKit’s APIs alone since most developers in the company and our build systems are all still using Xcode 10, and introducing iOS 13 APIs would cause build breakages. We went with the approach of writing thin wrappers around UIKit APIs that are compatible with Xcode 10 and iOS 12.
Another principle we followed was to introduce as few APIs as possible, and only when needed. The key reason for this was to reduce complexity for product teams adopting dark mode: it’s harder to misunderstand or misuse APIs if there are fewer of them. We started off with just wrappers around dynamic colors and a semantic color palette that our design systems team created, then introduced additional APIs over time as the need grew within the company. To increase awareness and ensure steady adoption, whenever we introduced a new API we announced it in an internal dark mode working group and documented it in an internal wiki page for the project.
Primitives and Concepts
Apple defines some handy dark mode primitives and concepts, and since we decided to build on top of their APIs we embraced these as well. Covering them at a high level, we have.
- Dynamic colors — Colors that change in response to light mode/dark mode changes. Also can change in response to “elevation” and accessibility settings.
- Dynamic images — Similar to dynamic colors, these are images that change in response to light mode/dark mode changes.
- Semantic colors — Named dynamic colors that serve a specific purpose. For example “destructive button color” or “link text color”.
- Elevation level — Things presented modally in dark mode change colors very slightly to demonstrate that they’re a layer on top of the underlying UI. This concept largely hasn’t existed in light mode because dark dimming layers are sufficient to differentiate modal layers presented on top of others.
Building UIKit Wrappers
One of the key APIs iOS 13 introduces for dark mode support is UIColor’s +colorWithDynamicProvider: method, which generates colors that automatically adapt to dark mode. This was the very first API we sought to wrap for use within Instagram and is still one of our most used dark mode APIs. We’ll walk through implementing it as a case study in building a backwards-compatible wrapper.
The first step in building such an API is defining a macro that allows us to conditionally compile out code for people that are still using stable versions of Xcode. This is what ours looks like:
Next we declare a wrapper function. Our wrapper for dynamic colors looks like this:
Within this function we use our macro to ensure that developers using older versions of Xcode can still compile. We also introduce a runtime check so that the app continues to function normally on older versions of iOS. If both checks pass we simply call into the iOS 13 +colorWithDynamicProvider: API, otherwise we fall back to the light mode variant.
You may notice that we’re passing an IGTraitCollection into IGColorWithDynamicProvider's block instead of a UITraitCollection. We introduced IGTraitCollection as a struct that contain's UITraitCollection's userInterfaceStyle and userInterfaceLevel values as isLight and isElevated respectively since those properties are only available when linked with newer iOS versions. More on that later.
Now that we have IGColorWithDynamicProvider we can use it everywhere in the app where we need to use dynamic colors. Developers can use this freely without worrying about build failures or run time crashes regardless of what version of Xcode they or their peers are using. Instagram has historically had a semantic color palette that was introduced in our 2016 redesign, and we collaborated with our design systems team to update all the colors in it to support dark mode using IGColorWithDynamicProvider. Here’s an example of one of these colors.
Once we had this pattern defined for wrapping UIKit’s API we continued to add more as they were needed. The set we ended up with is:
- IGColorWithDynamicProvider as shown here
- IGImageWithDynamicProvider for creating “dynamic images“ that automatically adapt to dark mode.
- IGActivityIndicator functions for creating activity indicators with styles that work in light mode, dark mode, and older versions of iOS.
- IGSetOverrideUserInterfaceStyle for forcing views or view controllers into particular interface styles.
- IGSetOverrideElevationLevel for forcing view controllers into particular elevation levels.
Small side note: We discovered towards the end of our dark mode adoption that our implementation of dynamic colors had equality implications because a new instance of UIColor was returned each time and the only thing that was comparable about each was the block passed in. In order to resolve this we modified our API slightly to create single instances of each of semantic colors so that they were comparable. Doing something like dispatch_once-ing your semantic colors or using asset catalog-based colors and +colorNamed: will produce comparable colors if your app is sensitive to color equality.
Fake Dark Mode
One tricky thing when adopting technologies in iOS betas is getting adequate test coverage. Convincing people using the internal build of Instagram to install iOS 13 on their devices isn’t a great idea because it’s unstable and challenging to help set up, and even if we were to get people testing on iOS 13 the builds we distribute internally were still largely being linked against the iOS 12 SDK so the changes wouldn’t show up anyway.
I briefly touched on our IGTraitCollection wrapper for UITraitCollection that came in handy in the course of building out dark mode. One clever testing trick this IGTraitCollection wrapper afforded us is something we’ve come to call “fake dark mode” — which is an internal setting that override IGTraitCollection to become dark even in iOS 12! Nate Stedman, one of our iOS engineers in New York, came up with this setting when we were first working on dark mode.
Our API for generating IGTraitCollections from UITraitCollections came to look like this.
Where _IGIsDarkModeDebugEnabled is backed by an NSUserDefaults flag for fake dark mode. There are of course some limitations with faking out dark mode in iOS 12, most notably
- userInterfaceLevel isn’t available in iOS 12, so “elevated“ dynamic colors never appear in fake dark mode.
- Forcing particular styles via our -setOverrideInterfaceStyle: wrapper has no effect in fake dark mode.
- UIKit components that use their default colors don’t adapt to fake dark mode in iOS 12 since they have no knowledge of dark mode.
With this addition to our dark mode wrappers we were able to get much broader test coverage than we otherwise would have.
Dark mode has been a highly requested featured of ours for quite a while.
We had been a little reluctant in introducing dark mode in the past because it would’ve been a tremendous undertaking, but the excellent tools that Apple provides and their emphasis on dark mode in iOS 13 finally made it possible for us! Of course the actual implementation still wasn’t easy, we’ve been working on this since WWDC and it demanded ample design and engineering deep dives into every part of the app (and admittedly, we have probably missed some). This journey has been worth it, on top of the benefits dark mode provides such as eye strain reduction and battery savings, it makes our app look right at home on iOS 13!
A huge thank you to Jeremy Lawrence, Nate Stedman, Cameron Roth, Ryan Olson, Garrett Olinger, Paula Guzman, Héctor Ramos, Aaron Pang, and numerous others who contributed to our efforts to adopt dark mode. Dark mode is also available in Instagram for Android.