Outdated Post

Check out my new post, "Supporting Dark Mode on iOS 13", to learn how to support native Dark Mode in iOS 13.

While dark mode is now available for both tvOS and macOS, it is still not available on iOS. In this tutorial, we'll see how to set up a custom in-app theming system which lets the user pick between light and dark mode within the app, providing an alternative to a system dark mode.

We'll also use observers to allow view controllers and other classes to register for appearance updates, so they can update their appearance whenever the theme is changed by the user.

If you'd like to see the final version of this tutorial, feel free to jump to the Conclusion, where you'll find a Swift Playground containing all the code from this tutorial.

Theming

First, we simply need to create a list of the themes that our app supports, which we can then use to know the current and new theme.

We'll create an enum with our theme options, light and dark:

enum Theme: CaseIterable {
    case light
    case dark

    static var current: Theme = .light
}

Observations

Now that our app knows which themes we support, we'll need to add a way to let classes such as view controllers observe the current theme, so when the user switches theme the class can update its UI or appearance accordingly.

Create a protocol named AppearanceObserver, which classes can then conform to so that we can tell them the theme changed. The protocol will have a single function, which we'll call whenever the theme changes.

protocol AppearanceObserver: class {
    func themeDidChange(to theme: Theme)
}

Next, we need a way to store a list of objects that are observing theme changes, so we can call them when needed. It will also allow classes to register or deregister themselves as observers whenever they want.

We'll create a Observation struct, which will contain our list of observers as well as two simple functions to add or remove an observer. When adding or removing an observer, we'll use their ObjectIdentifier, which is a unique value given to each instance of a class, to make sure all of our observers are unique.

struct Observation {
    weak var observer: AppearanceObserver?
    static var observations = [ObjectIdentifier: Observation](<>)

    // Adds an observer
    static func addObserver(_ observer: AppearanceObserver) {
        let id = ObjectIdentifier(observer)
        Observation.observations[id] = Observation(observer: observer)
    }

    // Removes an observer
    static func removeObserver(_ observer: AppearanceObserver) {
        let id = ObjectIdentifier(observer)
        Observation.observations.removeValue(forKey: id)
    }

}

Additionally, we'll add a convenience function to our Observation class, which will handle looping through and notifying each observer. This way, we can call Observation.themeDidChange(to: theme) to quickly notify all the observers.

// Tells each observer that the theme changed
// (If an observer is no longer available, we remove it from the list)
static func themeDidChange(to theme: Theme) {
    for (id, observation) in Observation.observations {
        guard let observer = observation.observer else {
            Observation.observations.removeValue(forKey: id)
            continue
        }

        observer.themeDidChange(to: theme)
    }

}

Finally, we need to call the function we just created whenever the current theme changes, so we'll add a didSet observer on our current variable in the Theme enum we created earlier. To do this, we just need to replace the current variable from earlier with this implementation, which tells Observation to notify all observers whenever the theme changes.

static var current: Theme = .light {
    didSet {
        Observation.themeDidChange(to: current)
    }
}

Additional Improvements

Now that the theming system is in place, any view controller can conform to the AppearanceObserver protocol and register themselves as an observer to receive updates whenever the theme changes.

However, there are still a few optional things we can do to improve the experience as a whole. Again, these improvements are optional, so if you'd like to skip them, feel free to jump directly to the Conclusion.

Easier Theme Switching

The first of the improvements will make it easier for whichever view controller changes the theme, such as a SettingsViewController, to toggle between the themes without a lot of logic, using an extension to CaseIterable. By using an extension, we can avoid putting this logic in our view controller, calling Theme.current.toggle() instead.

Remember: This will not work correctly if you have more than 2 themes!

Please note the following code is taken from Paul Hudson's tweet and all credit goes to him.

// Useful extension to `CaseIterable` to allow easily toggling of a 2 case enum
// Credit: Paul Hudson <https://twitter.com/twostraws/status/1092229498601422848>
extension CaseIterable where Self: Equatable {
    mutating func toggle() {
        self = Self.allCases.first(where: { $0 != self }) ?? self
    }
}

Animating Switches

The other way to improve the theme switching is to animate theme switches, making the transition smoother and more visually pleasing.

This is easily done by using UIView's animate method, as in this example:

UIView.animate(withDuration: 0.6) {
    switch Theme.current {
    case .light:
        self.view.backgroundColor = .groupTableViewBackground
    case .dark:
        self.view.backgroundColor = .black
    }
}

Note that to change the duration of the animation, you can simply change the duration from 0.6 and make it higher or lower (in seconds).

Conclusion

In this tutorial, we've gone through a clean implementation of a theming system, which allows for easy theme changes and notifications.

While iOS still doesn't have a built in dark mode, using a system such as this will make it even easier to eventually utilize a system dark mode, as we can just switch to the system's setting on the new iOS version and keep this implementation for users on older versions.

I've made a Swift Playground which demonstrates the system implemented within this tutorial with a DemoViewController, which shows how to observe theme changes and update the appearance of the app accordingly. You can see a recording of this at the top of this blog post, or download the playground yourself below.

Download Materials

I hope this tutorial helped you out! If you have any questions or feedback, feel free to send them or email [email protected]! Thanks for reading 🙃