Workarounds (Or, How To Get Reliable Color Scheme Switching In SwiftUI Apps)

I feel like I've been opening Apple's Feedback Assistant a whole lot this week while working on the SwiftUI client for WriteFreely.

That's normal! It's betas all the way down right now — building a new app from scratch, using a beta framework, in a beta IDE, running on a beta operating system. I'd be worried if everything worked normally, to be honest. Bug reports are part of life when you're this far along the bleeding edge.

But I'm getting ahead of myself here. Today, I want to talk a little bit about workarounds, so let me back up a little bit and talk about where we're at, today, in developing for Apple platforms.

In many ways, SwiftUI replaces the need for AppKit and UIKit. At least, that's the promise.

(It's not always the reality.)

The value of this, as I mentioned last week when discussing the promise vs. reality of SwiftUI multiplatform apps, is that SwiftUI's declarative nature lets us provide the semantics of a view, and the framework will essentially translate them into the actual presentation that works best for the particular platform it's running on.

I also mentioned how some things didn't quite work right, yet. That's normal! SwiftUI has been in developers' hands for barely over a year, so of course there's still work to be done. And while some would prefer to wait for the framework to mature, I'm going to go out on a limb here and say:

If you're starting work on a new app today, make it a SwiftUI multiplatform app.

I know, I know. I remember what it was like building an app in the early days of Swift and getting burned on the transition from Swift 2 to Swift 3, or trying to understand how to do something when even Apple's sample code was almost exclusively in Objective-C.

Five years later, I'm glad I'm all in on Swift, and five years from now, I'm willing to bet I'll be feeling the same way about SwiftUI.

In fact, the transition from AppKit and UIKit to SwiftUI will be even easier than from Objective-C to Swift, because this is more of a change in mindset than it is a change in language. Because most libraries were still written in Objective-C when Swift debuted, we needed (and still need) to use bridging headers to import them into an otherwise all-Swift codebase. The ecosystem is all-in on Swift now, and if SwiftUI doesn't do what you need —or do it reliably— yet, then Apple have made dropping into AppKit or UIKit from SwiftUI unbelievably easy.

This is the takeaway. As developers, we're used to finding workarounds to handle unexpected behaviour. We write our code defensively, fencing in a bug such that it's never exposed to the user. The reason I feel confident in recommending SwiftUI for your next project is because, while you'll invariably run into bugs, the workarounds are often trivial.

Let's get into an example.

"A segmented control with options for System. Light, and Dark appearance."

One common feature for modern apps is to be able to choose their colour scheme (i.e., light vs dark) independently of what the operating system is set to. At first blush, making this work in SwiftUI is really easy: just add the .preferredColorScheme() view modifier to your top-level ContentView and bind it to some state property:

import SwiftUI

@main
struct MyApp: App {
    // Set to .light for light scheme, or .none to follow the system setting.
    @State var selectedColorScheme: ColorScheme? = .dark

    var body: some Scene {
        WindowGroup {
            ContentView()
                .preferredColorScheme(selectedColorScheme)
            }
        }
    }
}

So, that's the promise. The reality is that I filed two bug reports yesterday and had to find a workaround to make this work.

The first (FB8383053) is that once you set .preferredColorScheme in your app to either .light or .dark, you can no longer unset that to get the app to follow whatever the system is set to.

The second (FB8382883) is that on macOS 11, setting .preferredColorScheme to .light isn't reliable. The app continues to follow whatever the system setting is; only a .dark scheme works as expected.

There are more details on both bugs in the README for this sample project on GitHub. And while frustrating, it's also unbelievably easy to drop into AppKit or UIKit from SwiftUI to work around this.

The WriteFreely SwiftUI client app uses a PreferencesModel ObservableObject for app-level preferences. Currently, there's only one: selectedColorScheme, which publishes the ColorScheme chosen by the user. That model becomes a @StateObject in the main App struct, to set the preferredColorScheme on its ContentView.

As I mentioned, however, that's not reliable, and instead we can drop into AppKit and UIKit when the user selects an appearance in the Picker:

import SwiftUI

class PreferencesModel: ObservableObject {
    // If we're in iOS, we need to get the UIWindow to apply the color scheme.
    #if os(iOS)
    var window: UIWindow? {
        guard let scene = UIApplication.shared.connectedScenes.first,
              let windowSceneDelegate = scene.delegate as? UIWindowSceneDelegate,
              let window = windowSceneDelegate.window else {
            return nil
        }
        return window
    }
    #endif

    @Published var appearance: Int = 0 {
        didSet {
            switch appearance {
            case 1:
                // To replicate selectedColorScheme = .light
                #if os(macOS)
                NSApp.appearance = NSAppearance(named: .aqua)
                #else
                window?.overrideUserInterfaceStyle = .light
                #endif
            case 2:
                // To replicate selectedColorScheme = .dark
                #if os(macOS)
                NSApp.appearance = NSAppearance(named: .darkAqua)
                #else
                window?.overrideUserInterfaceStyle = .dark
                #endif
            default:
                // To replicate selectedColorScheme = .none
                #if os(macOS)
                NSApp.appearance = nil
                #else
                window?.overrideUserInterfaceStyle = .unspecified
                #endif
            }
        }
    }
}

That's it. No having to import AppKit or Cocoa, no bridging headers, nothing. Just call the AppKit/UIKit methods you need right there, and now selecting your colour scheme works flawlessly across your app, in iOS and macOS.

Here's the change to PreferenceModel in the repository. My hope is that this gets fixed before iOS 14 and macOS 11 are released, and that I can uncomment the cleaner, multiplatform preferredColorScheme calls instead, but if not, the workaround isn't especially painful.

Now, about that toolbar…


Enter your email to subscribe to updates:

You can also subscribe via RSS or follow @angelo@write.as on Mastodon.