You are currently viewing Implement In-App Dark Mode Using Swift Observation Protocols

Implement In-App Dark Mode Using Swift Observation Protocols

In iOS 13, Apple introduced dark mode to the world. To enable dark mode, you must use the Settings app on your device and enable this feature system-wide.

But what if instead of following the system-level dark mode setting, you want your app to have its own standalone dark mode setting? Is that archivable? There is certainly a way, and I will show you how in this article.

To have a better understanding of what we are trying to achieve in this article, take a look at the animated GIF below.

In this article, I will not dive into what you should know when adopting dark mode in iOS, if you want to know more, you can check out this article.

Instead, I will be focusing on how you can use a protocol-oriented way to achieve what’s being showcased in the GIF above. With all that said, let’s get started.


Overall Architecture

As mentioned above, we will be using Swift observation protocols to archive in-app dark mode. The diagram below illustrates the overall architecture of our sample app.

In-app dark mode sample app overall architecture
Sample app overall architecture

In our sample app, we will have 2 main components that are in charge of keeping track and updating the user interface style.

  1. User Interface Style Manager:
    • Keep track of the app’s current user interface style.
    • Keep track of all the user interface style observers.
    • In charge of notifying all the observers when interface style changes.
  2. User Interface Style Observer:
    • A protocol that observes interface style changes in the User Interface Style Manager.
    • In charge of updating the view appearance when the user interface style changes.

The idea here is to conform all view controllers to the user interface style observer protocol so that whenever the user turns on / off dark mode within the sample app, the user interface style manager will be updated and notify all the observing view controllers to change their view appearance accordingly.


Implementing User Interface Style Manager

Since the user interface style manager is a centralized component of which we only need one for the entire life cycle of our sample app, we will make it as a singleton struct.

Below is what the UserInterfaceStyleManager struct initially looks like.

struct UserInterfaceStyleManager {
    
    static let userInterfaceStyleDarkModeOn = "userInterfaceStyleDarkModeOn";
    
    // 1
    static var shared = UserInterfaceStyleManager()
    private init() { }
    
    // 2
    private(set) var currentStyle: UIUserInterfaceStyle = UserDefaults.standard.bool(forKey: UserInterfaceStyleManager.userInterfaceStyleDarkModeOn) ? .dark : .light
}

// MARK:- Public functions
extension UserInterfaceStyleManager {
    // 3
    mutating func updateUserInterfaceStyle(_ isDarkMode: Bool) {
        currentStyle = isDarkMode ? .dark : .light
    }
}
  1. Making the UserInterfaceStyleManager singleton by creating a static shared instance and setting its initializer to private.
  2. currentStyle is a private variable that use to keep track of the current user interface style. Here, we obtain its initial value from UserDefaults.
  3. Create public function updateUserInterfaceStyle(_:) to allow other components of the app, in our case will be the UISwitch, to inform our UserInterfaceStyleManager about the user interface style is changed.

To notify all the observers when currentStyle changes, our UserInterfaceStyleManager will need to have a reference for all the observers.

Here we will use a dictionary to keep track of all the observers. Furthermore, we will add two public functions that enable us to add and remove observers from UserInterfaceStyleManager.

struct UserInterfaceStyleManager {
    
    static let userInterfaceStyleDarkModeOn = "userInterfaceStyleDarkModeOn";
    
    // 1
    private var observers = [ObjectIdentifier : WeakStyleObserver]()
    
    static var shared = UserInterfaceStyleManager()
    private init() { }
    
    private(set) var currentStyle: UIUserInterfaceStyle = UserDefaults.standard.bool(forKey: UserInterfaceStyleManager.userInterfaceStyleDarkModeOn) ? .dark : .light
}

// MARK:- Public functions
extension UserInterfaceStyleManager {
    // 2
    mutating func addObserver(_ observer: UserInterfaceStyleObserver) {
        let id = ObjectIdentifier(observer)
        // Create a weak reference observer and add to dictionary
        observers[id] = WeakStyleObserver(observer: observer)
    }
    
    // 3
    mutating func removeObserver(_ observer: UserInterfaceStyleObserver) {
        let id = ObjectIdentifier(observer)
        observers.removeValue(forKey: id)
    }
    
    mutating func updateUserInterfaceStyle(_ isDarkMode: Bool) {
        currentStyle = isDarkMode ? .dark : .light
    }
}
  1. observers is a dictionary that keeps track of all the observers being added. Note that observers are being added as weak references into the dictionary, this is to avoid retain cycle from happening (more on that later). Furthermore, we are using ObjectIdentifier of the observer as the dictionary key.
  2. A public function that enables other components to add observers to UserInterfaceStyleManager.
  3. A public function that enables other components to remove observers from UserInterfaceStyleManager.

You might notice that UserInterfaceStyleObserver and WeakStyleObserver are being used in the code snippet above.

UserInterfaceStyleObserver is the observation protocol that we have yet to implement, we will get into that later. For now, we will take a look at WeakStyleObserver.

WeakStyleObserver is a wrapper type that keeps track of a UserInterfaceStyleObserver instance with a weak reference.

The reason we need this is because that dictionaries in Swift always have strong references to their elements, this will potentially introduce retain cycle and cause memory leaks.

The following is the implementation of WeakStyleObserver.

struct WeakStyleObserver {
    weak var observer: UserInterfaceStyleObserver?
}

Lastly, we will add a property observer to the currentStyle variable so that UserInterfaceStyleManager will perform an action when the value of currentStyle changes.

private(set) var currentStyle: UIUserInterfaceStyle = UserDefaults.standard.bool(forKey: UserInterfaceStyleManager.userInterfaceStyleDarkModeOn) ? .dark : .light {
    // Property observer to trigger every time value is set to currentStyle
    didSet {
        if currentStyle != oldValue {
            // Trigger notification when currentStyle value changed
            styleDidChanged()
        }
    }
}

The styleDidChanged() function in the snippet above will iterate through the observers dictionary and notify each observer about the user interface style change.

// MARK:- Private functions
private extension UserInterfaceStyleManager {
    mutating func styleDidChanged() {
        for (id, weakObserver) in observers {
            // Do something cool here!
        }
    }
}

We will leave the for-loop body empty for now. We will get back to this once we have implemented the UserInterfaceStyleObserver.


Implementing User Interface Style Observer

The code snippet below shows the definition of the UserInterfaceStyleObserver protocol.

Here we define the UserInterfaceStyleObserver as a class-only (AnyObject) protocol.

This is because we are using ObjectIdentifier as the key of the observers dictionary in UserInterfaceStyleManager, and ObjectIdentifier only support class objects.

Thus we will have to make UserInterfaceStyleObserver a class-only protocol so that only classes can conform to the protocol.

protocol UserInterfaceStyleObserver: AnyObject {
    // 1
    func startObserving(_ userInterfaceStyleManager: inout UserInterfaceStyleManager)
    
    // 2
    func userInterfaceStyleManager(_ manager: UserInterfaceStyleManager, didChangeStyle style: UIUserInterfaceStyle)
}

The functionality of UserInterfaceStyleObserver is fairly straightforward. It only contains two functions.

  1. A function that is in charge of adding observers to UserInterfaceStyleManager.
  2. A function that will trigger when the user interface style changes. Thus this function will take care of changing the view appearance whenever it is needed.

Next, we will conform UIViewController to the UserInterfaceStyleObserver protocol and implement both functions.

We will be using a new UIViewController property introduced in iOS 13 — overrideUserInterfaceStyle to control our view controller’s appearance.

This property, enables us to override the system user interface style and force the view controller to follow the appearance that we desire.

To force a view controller to always display in dark mode, we can do it as follows.

overrideUserInterfaceStyle = .dark

With that in mind, we can now start implementing the UserInterfaceStyleObserver functionalities of UIViewController.

extension UIViewController: UserInterfaceStyleObserver {
    
    func startObserving(_ userInterfaceStyleManager: inout UserInterfaceStyleManager) {
        // Add view controller as observer of UserInterfaceStyleManager
        userInterfaceStyleManager.addObserver(self)
        
        // Change view controller to desire style when start observing
        overrideUserInterfaceStyle = userInterfaceStyleManager.currentStyle
    }
    
    func userInterfaceStyleManager(_ manager: UserInterfaceStyleManager, didChangeStyle style: UIUserInterfaceStyle) {
        // Set user interface style of UIViewController
        overrideUserInterfaceStyle = style
        
        // Update status bar style
        setNeedsStatusBarAppearanceUpdate()
    }
}

Inside the startObserving(_:) function, we will register the view controller as an observer of the UserInterfaceStyleManager. After that, we will set the view controller’s overrideUserInterfaceStyle value.

The userInterfaceStyleManager(_:didChangeStyle:) function will trigger whenever currentStyle of UserInterfaceStyleManager changed, thus this is the best place to set the value of overrideUserInterfaceStyle, so that the view controller appearance will be updated when the user interface style changes.

Note that we also call setNeedsStatusBarAppearanceUpdate() inside userInterfaceStyleManager(_:didChangeStyle:) function. This is to ensure that the status bar appearance will always follow the current user interface style.

With both functions implemented, the UserInterfaceStyleObserver is now ready to be integrated with the UserInterfaceStyleManager.


Revisiting User Interface Style Manager

This is the last bit that needs to be done before we can start integrating UserInterfaceStyleObserver with our sample app’s view controllers.

We will continue from where we left off by implementing styleDidChanged() function of UserInterfaceStyleManager.

// MARK:- Private functions
private extension UserInterfaceStyleManager {
    mutating func styleDidChanged() {
        for (id, weakObserver) in observers {
            // Clean up observer that no longer in memory
            guard let observer = weakObserver.observer else {
                observers.removeValue(forKey: id)
                continue
            }
            
            // Notify observer by triggering userInterfaceStyleManager(_:didChangeStyle:)
            observer.userInterfaceStyleManager(self, didChangeStyle: currentStyle)
        }
    }
}

Within the styleDidChanged() function, we will loop through each and every observer and trigger its userInterfaceStyleManager(_:didChangeStyle:) to notify the observer about the user interface style change.

Do note that, while looping through all the observers, we also take this opportunity to clean up any observers that have been deallocated.

With that, the two core components of our sample app are up and running. We can now go ahead to integrate them with our sample app’s view controllers. 🥳


Integration with View Controllers

This is where the fun started!

We can finally see our UserInterfaceStyleManager and UserInterfaceStyleObserver in action. Below are the two view controllers that we will be working on — MainViewController and SettingsViewController.

MainViewController and SettingsViewController
MainViewController and SettingsViewController

The first thing we need to do is wire up the UISwitch in SettingsViewController and implement its value changed event method.

class SettingsViewController: UIViewController {
    
    @IBOutlet weak var darkModeSwitch: UISwitch!
    
    override func viewDidLoad() {
        super.viewDidLoad()

        // 1
        // Set switch status
        darkModeSwitch.isOn = UserInterfaceStyleManager.shared.currentStyle == .dark
    }

    @IBAction func darkModeSwitchValueChanged(_ sender: UISwitch) {
        
        let darkModeOn = sender.isOn
        
        // 2
        // Store in UserDefaults
        UserDefaults.standard.set(darkModeOn, forKey: UserInterfaceStyleManager.userInterfaceStyleDarkModeOn)
        
        // 3
        // Update interface style
        UserInterfaceStyleManager.shared.updateUserInterfaceStyle(darkModeOn)
    }
}
  1. We need to reflect the current user interface style on the darkModeSwitch‘s state, thus we will set the darkModeSwitch‘s state based on the UserInterfaceStyleManager‘s currentStyle value.
  2. Every time the user toggles the darkModeSwitch, we will track its state in UserDefaults.
  3. Update UserInterfaceStyleManager base on the darkModeSwitch‘s state.

Next up, we will make both MainViewController and SettingsViewController the observer of UserInterfaceStyleManager. What we need to do is call the startObserving(_:) function in viewDidLoad() of both view controllers.

class MainViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        startObserving(&UserInterfaceStyleManager.shared)
    }
}

class SettingsViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        startObserving(&UserInterfaceStyleManager.shared)

        // Set switch status
        darkModeSwitch.isOn = UserInterfaceStyleManager.shared.currentStyle == .dark
    }
}

That’s it! Now, both MainViewController and SettingsViewController will update their appearance when UserInterfaceStyleManager changed.


But Wait…

If you have followed this entire article until this point, you might notice that everything works correctly, except for the navigation bar. It is still in light mode even though we have turned on dark mode in our sample app.

Force navigation bar to dark mode appearance
Navigation bar still in light mode

The reason behind this is because of the navigation controller is not observing the UserInterfaceStyleManager. Since UINavigationController is also a subclass of UIViewController, thus the solution to this problem is quite straightforward, you just need to:

  • Create a subclass of UINavigationController and call the startObserving(_:) function in viewDidLoad().
class MyNavigationController: UINavigationController {

    override func viewDidLoad() {
        super.viewDidLoad()

        startObserving(&UserInterfaceStyleManager.shared)
    }
}
  • Assign the subclass as a custom class of the navigation controller in the storyboard.
Assign custom class of navigation controller
Set custom class to the navigation controller

If you try to build and run your sample app again, you should be able to see the navigation bar appearance being updated when you toggle the UISwitch state. 🎉


Pushing One Step Further

Up until this stage, the implementation of in-app dark mode is considered done. By using the concept that we discussed, we can easily add any view controller to the sample app and make it support in-app dark mode.

However, we will not stop here.

If you refer to Apple documentation, overrideUserInterfaceStyle is also an instance property of a UIView. This means that we can push one step further by improving our UserInterfaceStyleObserver to support UIView as well.

To showcase a UIView that support in-app dark mode, we will create a light-mode-only view controller that contains a subview that supports in-app dark mode. The animated GIF below showcases what we are going to achieve next.

In-app dark mode UIView demo
In-app dark mode for UIView

First, we will have to extend the UIView class by conforming to UserInterfaceStyleObserver protocol.

The implementation of startObserving(_:) and userInterfaceStyleManager(_:didChangeStyle:) is very similar to the implementation of UIViewController.

extension UIView: UserInterfaceStyleObserver {
    
    func startObserving(_ userInterfaceStyleManager: inout UserInterfaceStyleManager) {
        // Add view as observer of UserInterfaceStyleManager
        userInterfaceStyleManager.addObserver(self)
        
        // Change view to desire style when start observing
        overrideUserInterfaceStyle = userInterfaceStyleManager.currentStyle
    }
    
    func userInterfaceStyleManager(_ manager: UserInterfaceStyleManager, didChangeStyle style: UIUserInterfaceStyle) {
        // Set user interface style of UIView
        overrideUserInterfaceStyle = style
    }
}

Next, we will create a subclass of UIView and call startObserving(_:) in draw(_:).

override public func draw(_ rect: CGRect) {
    super.draw(rect)
    
    // Observe user interface style change
    startObserving(&UserInterfaceStyleManager.shared)
}

That’s it, you have made the custom UIView support in-app dark mode. Now add the custom UIView to the light-mode-only view controller to see it in action. 🙌🏻🙌🏻🙌🏻


Last but Not Least

I have uploaded the full sample project to Github. Feel free to download it and play around with it.

I hope this article gives you a very good idea of how to implement in-app dark mode using the protocol-oriented programming way.

If you want to know more about adopting dark mode to your iOS app, check out these 2 articles:

If you want to learn more about observation protocols design patterns, take a look at this article:


Feel free to leave me a comment if you have any questions or thoughts. If you find this article helpful, please share it with your friends. You can follow me on Twitter for more articles related to iOS development.

Thanks for reading and happy coding. 👨🏼‍💻


👋🏻 Hey!

While you’re still here, why not check out some of my favorite Mac tools on Setapp? They will definitely help improve your day-to-day productivity. Additionally, doing so will also help support my work.

  • Bartender: Superpower your menu bar and take full control over your menu bar items.
  • CleanShot X: The best screen capture app I’ve ever used.
  • PixelSnap: Measure on-screen elements with ease and precision.
  • iStat Menus: Track CPU, GPU, sensors, and more, all in one convenient tool.