Implementing Dark Mode in iOS

Mohammed Imthathullah
4 min readNov 4, 2019
Pic credits: Apple

Last week, we made our app respond to theme changes in iOS 13 and now we will solve some of the challenges involved in implementing dark mode and extend support to earlier versions of iOS as well.

We have our app in sync with the system’s theme. That’s cool. But isn’t is better to leave the choice to the user?

Let’s add a new enum to achieve the same.

enum MIUserInterfaceStyle {
case dark
case light

@available(iOS 13.0, *)
case system
}

There you go. Now users can choose any of the styles based on their OS version. By default, we can set system theme for iOS 13 and light theme for earlier versions.

class MITheme {
// other properties and methods

static var style: MIUserInterfaceStyle = _style

private static var _style: MIUserInterfaceStyle {
get {
if #available(iOS 13.0, *) {
return .system
}

return .light
}
}
}

So whenever user changes the theme, we update this static style property in MITheme and then call the themeChanged function from the active viewController.

static func setStyle(_ style: MIUserInterfaceStyle) {
MITheme.style = style
switch style {
case .dark:
current = dark
case .light:
current = light
case .system:
print("App's current theme will be updated based on the system's theme")
}
}

We already have a method to update theme based on the system’s theme. Now we will change the method to work only when the user has set system as the preferred theme.

@available(iOS 13.0, *)
static func update(basedOn userInterfaceStyle: UIUserInterfaceStyle) {

guard MITheme.style == .system else { return }

switch userInterfaceStyle {
case .dark:
current = dark
case .light, .unspecified:
current = light
@unknown default:
assertionFailure("Support not provided for new theme yet.")
}
}

The guard statement will do the job for us.

We’ve set colors to all elements in a view controller. But there are elements on the screen which are not part of the view controller like the navigation bar, the navigation controller and the status bar. Let’s write an extension to UINavigationController.

extension UINavigationController {

public func setColors(using theme: MINavigationBarThemeProtocol) {
view.backgroundColor = MITheme.current.primaryBackGroundColor
navigationBar.barTintColor = theme.backgroundColor
navigationBar.tintColor = theme.buttonColor
navigationBar.isTranslucent = theme.isTranslucent
UIApplication.shared.setStatusBarColor(theme.backgroundColor)
}
}

The setStatusBarColor is a custom method, which requires a separate discussion. You can find the method in UIKitExtensions.swift in this repo.

We’ve set our primary background color to navigation controller’s view because when it is clear, the UIWindow below is seen during some transitions and the window color might be different to the current theme based on the OS version.

Now we’ve to call this navigationController’s setColors method on baseController’s viewWillAppear and traitCollectionDidChange. Make note not to call this method from viewDidLoad because the navigationController property can be nil for a view controller when it loads.

Among the elements which are in view controller, we face some challenges in making tableView (and similarly collection view) respond to theme changes. This is because at any point of time, we don’t know what cells and sections are visible on the screen.

We can set colors to the visible cells, which confirm to our Themable protocol like below.

extension UITableView: Themable {

func setColors() {
view.backgroundColor = .clear guard let indices = indexPathsForVisibleRows else { return }

for indexPath in indices {
if let cell = cellForRow(at: indexPath) as? Themable {
cell.setColors()
} else {
assertionFailure("Confirm \(self) to Themable")
}
}
}
}

From iOS 13, most default view objects like the UITableView and UITableViewCell change background color automatically based on the system theme. So make sure to set the view.backgroundColor property to either clear or a color of your choice in the setColors method if the default white/black doesn’t cut it for you.

Similarly, if you are not satisfied with how the system’s color for header matches with your theme, you can override the viewForHeaderInSection method in the table view delegate and return a custom view. We can reach out to this custom view at runtime via the NotificationCenter.


class MISectionHeaderView: UIView, Themable {

init(/* other properties */ frame: CGRect = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 44)) {
//... initialize other properties ...
super.init(frame: frame)
NotificationCenter.default.addObserver(self, selector: #selector(setColors), name: NSNotification.Name(rawValue: "themeChanged"), object: nil)
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

@objc func setColors() {
// apply colors of your choice based on current theme
}
// ... other properties and methods ... deinit {
NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: "themeChanged"), object: nil)
}
}

Now whenever the theme changes, we can post a themeChanged notification. If the section header is visible on the screen, it will observe the notification and the setColors function will be called.

Here’s how the base controller looks like after making all the suggested changes.

class MIViewController: UIViewController {

override func viewDidLoad() {
super.viewDidLoad()

if #available(iOS 13, *) {
MITheme.update(basedOn: traitCollection.userInterfaceStyle)
}
applyTheme()
}

override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setColors(using: MITheme.current.navBarTheme)
}

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
if #available(iOS 13, *),
traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
MITheme.update(basedOn: traitCollection.userInterfaceStyle) themeChanged()
}
}
func themeChanged() { NotificationCenter.default.post(name: Notification.Name("themeChanged"), object: nil) navigationController?.setColors(using:
MITheme.current.navBarTheme)
applyTheme()
}
private func applyTheme() {
if let themedVC = self as? Themable {
themedVC.setColors()
} else {
assertionFailure("Confirm \(self) to Themable")
}
}
}

We’ve now solved some of the edge cases that arise while implementing dark theme in iOS 13. These concepts can be extrapolated and applied to other views which we have not discussed. Here’s the source code for reference.

Hope your app looks awesome in both dark and light themes.

--

--

Mohammed Imthathullah

Aspiring Author. Mostly writes code and sometimes articles. Tweets @imthath_m