iOS Quick Tip - Support User Customization of Appearance Theme

iOS Quick Tip - Support User Customization of Appearance Theme

iOS 13 introduced official system support for dark mode. I prefer light mode for everything because I'm a normal person, or maybe I'm just a boomer, but I digress. XCAssets color sets make it very simple to support dark and light mode in your apps. (This post will not teach you have to support dark mode in iOS) Your app's users may wish to have dark mode turned on in their iOS settings but want to use your app in light mode, or vice versa. Or they may just want your app to respect the setting they choose in iOS settings. All of these options are possible and this article will show you the code for how to accomplish this.

We'll start with a simple SwiftUI view that allows the user to select which theme they prefer.

struct ThemeView: View {
    var body: some View {
        VStack {
            Divider()
            
            List {
                ForEach(ThemeStyle.allCases, id: \.self) {
                    ThemeViewCell(themeStyle: $0)
                }
            }
            .listStyle(.plain)
        }
        .navigationBarTitle("Theme")
    }
}

fileprivate struct ThemeViewCell: View {
    var themeStyle: ThemeStyle
    
    private var isCurrent: Bool {
        themeStyle.rawValue == ThemeStyle.current
    }
    
    var body: some View {
        HStack {
            Text(themeStyle.rawValue)
            
            Spacer()
            
            if isCurrent {
                Image(systemName: "checkmark")
            }
        }
        .onTapGesture { viewModel.setTheme(themeStyle) }
    }
    
    func setTheme(_ theme: ThemeStyle) {
        ThemeStyle.current = theme.rawValue
    }
}

This code will not yet compile because some code is not yet implemented and we'll get to that now. We need to implement the ThemeStyle enum.

enum ThemeStyle: String, CaseIterable {
    case system = "System Default"
    case light = "Light"
    case dark = "Dark"
}

As you can see, this enum must conform to CaseIterable so that it can be used in our SwiftUI view's forEach, ForEach(ThemeStyle.allCases, id: \.self). User defaults cannot store enum cases, so we set our enum case raw values to type String so that we can store the value in a user default. We use the following code to store in user defaults,

extension ThemeStyle {
    @UserDefault(key: "currentThemeStyle", defaultValue: ThemeStyle.system.rawValue)
    public static var current: String {
        didSet { Appearance.overrideApplicationThemeStyle() }
    }
}

This will not yet compile because we have not yet coded our Appearance struct or that custom property wrapper, @UserDefault. I'll show you the code I am using if you want to copy it verbatim, but instead of just copying my code, I highly recommend you read this blog post by Jesses Squires, A better approach to writing a UserDefaults Property Wrapper.

@propertyWrapper
public struct UserDefault<T> {
    private let key: String
    private let defaultValue: T

    public init(key: String, defaultValue: T) {
        self.key = key
        self.defaultValue = defaultValue
    }

    public var wrappedValue: T {
        get { return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue }
        set { UserDefaults.standard.set(newValue, forKey: key) }
    }
}

My Appearance struct has 1 static method to set the theme,

import UIKit

struct Appearance {
    static func overrideApplicationThemeStyle() {
        let userInterfaceStyle: UIUserInterfaceStyle
        
        switch ThemeStyle.current {
        case ThemeStyle.light.rawValue:
            userInterfaceStyle = .light
        case ThemeStyle.dark.rawValue:
            userInterfaceStyle = .dark
        default:
            userInterfaceStyle = .unspecified
        }
        
        UIApplication.shared.connectedScenes.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }.first { $0.isKeyWindow }?.overrideUserInterfaceStyle = userInterfaceStyle
    }
}

Now anytime ThemeStyle's static property current is set, it is saved as a user default to persist between sessions and it will update your apps theme. Just like in the setTheme method in ThemeViewCell,

func setTheme(_ theme: ThemeStyle) {
    ThemeStyle.current = theme.rawValue
}

The final step we must take is to set your app's theme every time your app loads and to do that add the following viewModifier to the launch screen of your app, .onAppear { Appearance.overrideApplicationThemeStyle() },

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onAppear { 
                   Appearance.overrideApplicationThemeStyle() 
                }
        }
    }
}

And that's all there is to it. Let me know what you think on Twitter, I'm @TomRads.