Making a Swift app themeable

Recently I started working on a new side project which includes an iOS application written in Swift. Because it's a new project that should support multiple themes eventually, it seemed wise to add theming from the start.

While there are multiple libraries that let you theme your app, including css-like libraries, I wanted something more lightweight, without the need to add another dependency. Initially, themes would only have to support different colorschemes, fonts and icons. Please note that this is work in progress.

The themes

First, I decided on a theme protocol, which looks something like this:

public protocol Theme {

    var backgroundColor: UIColor { get }
    var tintColor: UIColor { get }
    var navigationBarTintColor: UIColor? { get }
    var navigationBarTranslucent: Bool { get }

    var navigationTitleFont: UIFont { get }
    var navigationTitleColor: UIColor { get }

    var headlineFont: UIFont { get }
    var headlineColor: UIColor { get }

    var bodyTextFont: UIFont { get }
    var bodyTextColor: UIColor { get }

    // ...

}

I added a protocol extension with a default implementation and a few convenience methods to apply styles to a group of views. This default implementation matches iOS' default theme.

extension Theme {

    public var backgroundColor: UIColor { return UIColor(hex: "#ffffff") }
    public var tintColor: UIColor { return UIColor(hex: "#007aff") }
    public var navigationBarTintColor: UIColor? { return nil }
    public var navigationBarTranslucent: Bool { return true }

    public var navigationTitleFont: UIFont { return UIFont.boldSystemFontOfSize(17.0) }
    public var navigationTitleColor: UIColor { return UIColor.blackColor() }

    public var headlineFont: UIFont { return UIFont.boldSystemFontOfSize(17.0) }
    public var headlineColor: UIColor { return UIColor.blackColor() }

    public var bodyTextFont: UIFont { return UIFont.systemFontOfSize(17.0) }
    public var bodyTextColor: UIColor { return UIColor.blackColor() }

    public func applyBackgroundColor(views: [UIView]) {
        views.forEach {
            $0.backgroundColor = backgroundColor
        }
    }

    public func applyHeadlineStyle(labels: [UILabel]) {
        labels.forEach {
            $0.font = headlineFont
            $0.textColor = headlineColor
        }
    }

    public func applyBodyTextStyle(labels: [UILabel]) {
        labels.forEach {
            $0.font = bodyTextFont
            $0.textColor = bodyTextColor
        }
    }

}

I also added a dark theme, which is to be main theme of the app. Of course, you can add as many themes as you like.

public struct DarkTheme: Theme {

    public var backgroundColor: UIColor = UIColor(hex: "#303030")
    public var tintColor: UIColor = UIColor(hex: "#FFCF00")
    public var navigationBarTintColor: UIColor? = UIColor(hex: "#404040")
    public var navigationBarTranslucent: Bool = false

    public var navigationTitleColor: UIColor = UIColor.whiteColor()
    public var headlineColor: UIColor { return UIColor.whiteColor() }
    public var bodyTextColor: UIColor { return UIColor.whiteColor() }

    public init() {}

}

Note that we're only selectively overriding properties that have different styling.

The theme service

In order to keep things centralised, we need a central object that informs 'themable' objects that the theme has been set or changed. For that, I added a theme service.

The theme service has a public-settable property theme that is being observed. When the theme is changed, it calls the private function applyTheme which applies global styles via UIAppearance and informs each listener that the theme has been updated, so those listeners have a chance to update themselves.

One thing to note is that I opted to use an NSHashTable with weakly linked objects instead of an Array or Set. The reason for this is simple. The latter two retain objects added to them, which means in order for objects to successfully deallocate, you'd need to explicitly remove them from this collection, or deallocate the collection itself. Since our service will be (retained by) a singleton, the latter will never happen.

A NSHashTable with weakly linked objects doesn't share this behaviour. If you add an object, it won't be retained, which means we also won't have to explicitly remove it. Instead, the entry will just be nullified (and eventually purged by the system).

For more info on NSHashTable and NSHashMap, check out this article on NSHipster.

public class ThemeService {

    public let shared = ThemeService()
    public var theme: Theme = DefaultTheme() {
        didSet {
            applyTheme()
        }
    }

    private var listeners = NSHashTable.weakObjectsHashTable()

    public init() {}

    public func addThemeable(themable: Themeable, applyImmediately: Bool = true) {
        guard !listeners.containsObject(themable) else { return }
        listeners.addObject(themable)

        if applyImmediately {
            themable.applyTheme(theme)
        }
    }

    private func applyTheme() {
        // Update styles via UIAppearance
        UINavigationBar.appearance().translucent = theme.navigationBarTranslucent
        UINavigationBar.appearance().barTintColor = theme.navigationBarTintColor
        UINavigationBar.appearance().titleTextAttributes = [
            NSForegroundColorAttributeName: theme.navigationTitleColor,
            NSFontAttributeName: theme.navigationTitleFont
        ]

        // The tintColor will trickle down to each view
        if let window = UIApplication.sharedApplication().windows.first {
            window.tintColor = theme.tintColor
        }

        // Update each listener. The type cast is needed because allObjects returns [AnyObject]
        listeners.allObjects
            .flatMap { $0 as? Themeable }
            .forEach { $0.applyTheme(theme) }
    }

}

You'll notice that the addThemeable method also has an optional applyImmediately argument (which defaults to true). This way the current theme is immediately 'pushed' to the new listener, the same way it would when the theme changes later on.

The only thing missing now is the themable protocol, which is the simplest of them all.

public protocol Themeable: class {  
    func applyTheme(theme: Theme)
}

One downside to NSHashTable is that it doesn't support Swift value types, so our protocol needs to be constrained to classes (which are bridgeable).

As long as your listener is a class and it implements the Themeable protocol, it can be added as a listener and receive updates about theme changes.

Using it!

You can use it like so:

class MyViewController: UIViewController {

    @IBOutlet private var headerLabel: UILabel!
    @IBOutlet private var bodyTextLabel1: UILabel!
    @IBOutlet private var bodyTextLabel2: UILabel!

    func viewDidLoad() {
        super.viewDidLoad()

        ThemeService.shared.addThemable(self)       
    }

    // MARK: - Themable

    func applyTheme(theme: Theme) {
        theme.applyBackgroundColor([view, collectionView])
        theme.applyHeadlineStyle([headerLabel])
        theme.applyBodyTextStyle([bodyTextLabel1, bodyTextLabel2])
    }

}

To change the theme, just pass a new theme to the Theme service:

ThemeService.shared.theme = DarkTheme()  

That wraps up this little write-up on a work-in-progress theming service. I hope it was of some use to you.

😔

Erik van der Wal

Erik van der Wal

I love building things with Swift, Objective-C and Ruby and tinkering with technologies like Golang and Elixir.

comments powered by Disqus