Diagnosing a SwiftUI Color Scheme Bug on macOS

A SwiftUI macOS window stayed in the old color scheme until focus changed. Here is the evidence, the failed fixes, and what my first tests missed.

7 min read
Two hora Calendar windows showing different color schemes after the appearance setting changed

I was testing the Light, Dark, and Auto appearance picker in hora when the app split into two realities.

The Settings window changed immediately. The main calendar window stayed light. Closing Settings, or making the main window key again, finally made the calendar catch up.

My first conclusion was simple: I had found a SwiftUI .preferredColorScheme bug on macOS.

That conclusion was too confident.

I had a repeatable symptom, but several appearance systems met inside the same view hierarchy: @AppStorage, SwiftUI environment values, AppKit semantic colors, a visual-effect view, and two separate scenes. The first version of this post treated one suspect as a proven cause. The follow-up investigation showed that the useful question was not "is this a SwiftUI bug?" but "at which layer does the new appearance stop becoming visible?"

This is the corrected debugging story.

The smallest version of the setup

hora stored one enum in UserDefaults through @AppStorage:

enum AppearanceMode: String, CaseIterable {
    case auto = "Auto"
    case light = "Light"
    case dark = "Dark"

    var colorScheme: ColorScheme? {
        switch self {
        case .auto: return nil
        case .light: return .light
        case .dark: return .dark
        }
    }
}

Both the main scene and Settings read the same key and applied the preference to their own view hierarchy:

@AppStorage("appearanceMode") private var appearanceMode: AppearanceMode = .auto

var body: some View {
    CalendarRootView()
        .preferredColorScheme(appearanceMode.colorScheme)
}

On macOS, a SwiftUI Settings scene is presented in its own window. That matters because .preferredColorScheme(_:) is a preference for a presentation, not a global command that paints every window in the process.

Apple's ColorScheme documentation says SwiftUI updates the environment value and redraws views that depend on it when the appearance changes. That describes the intended contract. It does not tell you which layer is stale when one composed window still shows old pixels.

What I could actually prove

The UI test performed the same sequence each time:

  1. Open Settings while macOS is using Dark appearance.
  2. Select Light and wait for the app to settle.
  3. Select Auto, which should resolve back to Dark.
  4. Capture the whole application every 200 milliseconds for three seconds.
  5. Close Settings and capture another sequence.

The images showed a consistent result. Settings reflected the selection, while the main window remained in its previous appearance until the focus relationship changed.

That proved a visual update was delayed in hora's real two-window composition. It did not prove that .preferredColorScheme alone was broken.

I also wrote a timing test that stored a raw string in UserDefaults and read it back immediately. It completed in well under a millisecond on my machine. I originally described this as proof that @AppStorage propagation was instant.

It was not.

The test only proved that a synchronous write could be read from the same UserDefaults object. It did not observe a second SwiftUI scene, wait for an @AppStorage update, inspect the environment, or verify a rendered pixel. The number looked precise, but it measured the easiest part of the path.

Appearance has more than one layer

A useful mental model is to separate the update into four questions:

LayerQuestionHow to inspect it
Stored valueDid the picker write Auto, Light, or Dark?Read the raw UserDefaults value
SwiftUI environmentDid the destination hierarchy receive a new colorScheme?Render the environment value in a temporary diagnostic label
Adaptive resourcesDid backgrounds, separators, and materials resolve again?Compare one native SwiftUI style with each bridged AppKit color
Window compositionDid the non-key window redraw the affected surfaces?Capture both windows before and after focus changes
The stored preference, SwiftUI environment, adaptive resources, and window composition layers involved in a macOS appearance update.

My first test covered the top row. The bug lived somewhere below it.

That distinction matters whenever SwiftUI and AppKit share a window. AppKit describes appearance as an inherited value: apps, windows, and views can each receive it from an ancestor or override it. Its NSAppearance documentation also recommends adaptive colors and images instead of manually resolving everything against a momentary appearance.

The fixes I tried before understanding the boundary

Applying NSAppearance to every window

I tried setting NSApp.appearance, then assigning the same appearance to every NSWindow:

private func applyAppearance(_ appearance: NSAppearance?) {
    NSApp.appearance = appearance

    for window in NSApp.windows {
        window.appearance = appearance
        window.displayIfNeeded()
    }
}

This made some AppKit-managed surfaces respond, but it also gave two systems ownership of the same decision. SwiftUI still had .preferredColorScheme, while AppKit received an explicit appearance. The result was not a clean fix. I saw mixed surfaces and occasional flashes during transitions.

Forcing another display pass

display(), displayIfNeeded(), and invalidateShadow() sounded promising because the visible symptom looked like a stale window.

They did not make the full SwiftUI hierarchy recompute its environment-dependent content. Asking AppKit to draw again is not the same as changing the inputs SwiftUI used to build the view tree.

Observing UserDefaults.didChangeNotification

The notification fired. That was useful evidence that a write occurred, but it still did not identify whether the stale layer was the environment, a semantic color, a material, or the final window composition.

Removing AppKit appearance overrides

This reduced the number of moving parts, which was useful. It did not eliminate the symptom by itself. That was another sign that there was no single line to blame.

A better diagnostic harness

If I had to investigate this again, I would build the harness before trying more fixes.

I would put these elements in both scenes:

struct AppearanceProbe: View {
    @Environment(\.colorScheme) private var colorScheme

    var body: some View {
        VStack(alignment: .leading) {
            Text("Environment: \(String(describing: colorScheme))")
            Rectangle().fill(.windowBackground).frame(height: 24)
            Rectangle()
                .fill(Color(nsColor: .windowBackgroundColor))
                .frame(height: 24)
        }
    }
}

Then I would test four transitions with Settings open:

  • Auto to Light
  • Light to Dark
  • Dark to Auto
  • A system appearance change while Auto is selected

The temporary environment label answers whether SwiftUI received the new value. The two rectangles reveal whether different resource paths resolve at the same time. Repeating the matrix with and without the visual-effect bridge isolates the material layer. Only then is it worth changing window-level AppKit state.

This approach is less exciting than finding a framework bug. It is also much more likely to produce evidence somebody else can use.

What finally changed

The fix landed two days later as a group of changes:

  • Auto stopped passing nil through the same transition path as explicit Light and Dark modes. hora now resolves the current AppKit appearance to an explicit SwiftUI ColorScheme.
  • A forced active state was removed from the sidebar's NSVisualEffectView bridge.
  • Several Color(NSColor.*) constructions in the calendar surfaces were replaced with native SwiftUI shape styles.
  • Focus changes caused by Settings stopped triggering unrelated synchronization work while I was inspecting appearance updates.

Together, those changes made both windows update correctly in hora. They do not establish a universal rule that every optional color scheme, AppKit color bridge, or visual-effect view is broken.

The full postmortem is in How I Fixed SwiftUI Appearance Switching on macOS.

The lesson I kept

The hardest debugging mistake here was not the code. It was promoting a plausible explanation to a conclusion because the symptom matched it.

A precise timing number did not validate the rendering path. A stale non-key window did not prove which framework owned the stale state. A workaround that fixed hora did not become a general law of SwiftUI.

What helped was separating state, environment, adaptive resources, and window composition, then removing one boundary at a time.

Appearance switching is now part of the native Mac experience in hora Calendar 1.0. If you use Google Calendar and want to see the result, try hora Calendar on the Mac App Store.

SwiftUI appearance debugging FAQ

Does preferredColorScheme work on macOS?

Yes. preferredColorScheme is the supported SwiftUI API for requesting Light or Dark appearance for a presentation. A stale surface can still come from the surrounding composition, including separate scenes, bridged AppKit views, visual effects, or resources that did not resolve again.

What does nil mean in preferredColorScheme?

Passing nil removes an explicit Light or Dark preference and lets the presentation follow its inherited appearance. In hora, transitions back to nil were part of the failing path, so the app now resolves Auto to an explicit scheme as a local workaround.

How do you test AppStorage across SwiftUI scenes?

Do not treat a direct UserDefaults write and read as an AppStorage propagation test. Observe the value in both live scenes, expose the destination colorScheme environment during diagnostics, and verify the rendered result separately.

How should I debug mixed SwiftUI and AppKit appearance code?

Start with one owner for the appearance preference. Then inspect the stored value, SwiftUI environment, adaptive colors or materials, and final window redraw as separate layers. Add AppKit overrides only when you can identify the boundary that requires them.

Related stories

How I Fixed SwiftUI Appearance Switching on macOS
Engineering

How I Fixed SwiftUI Appearance Switching on macOS

The real fix for a stale SwiftUI macOS window combined an explicit color scheme, adaptive styles, and a safer NSVisualEffectView bridge in hora.

Maciej Szamowski7 min read
Google Calendar API vs CalDAV: Which Should You Use?
Engineering

Google CalDAV vs Calendar API: Which Should You Use?

Does Google Calendar support CalDAV? Yes. Compare Google CalDAV with the Calendar API, including sync, OAuth, event data, and multi-provider tradeoffs.

Maciej Szamowski10 min read
Google Calendar API in Swift: The Package I Missed
Engineering

Google Calendar API in Swift: The Package I Missed

I hand-built a Google Calendar API client in Swift, then migrated hora to Google's GTLR library. See the code, tradeoffs, and rate-limit lessons.

Maciej Szamowski10 min read
Mobile Beta Sign-up

Want to stay updated?

Subscribe for information about iOS/iPadOS beta availability.