DocumentGroup Breaks focusedSceneValue Propagation

DocumentGroup Breaks focusedSceneValue Propagation

I have already written about another gnarly bug around document-based apps in SwiftUI in a previous post. In that post, I found that on visionOS, any DocumentGroup scene would duplicate your view hierarchy, causing pretty funny looking visual overlaying glitches. It was most visible on visionOS due to the amount of UI translucency, but I later realized the bug was also reproducible on iOS.

It is fairly evident that document-based apps have not been a major focus area in the recent SwiftUI evolution and development lifecycle, because today we’re looking at another bug that makes DocumentGroup quite unusable. Turns out, if you have a DocumentGroup scene and you try using the focusedSceneValue API to define self-validating Commands for that scene, it just won’t work on iOS or visionOS, as your focused value will always be nil.

Implementing self-validating menu commands

The SwiftUI focus APIs can be quite helpful for implementing automatically validated key equivalents if your application uses SwiftUI App Lifecycle. I’ll aim to provide a quick introduction and demonstrate the behavior here, so please feel free to skip to the next section if you’re already familiar with these techniques.

Adding some key commands to your iOS app, or menu items to the macOS (and now iPadOS) menu bar, and wanting to automatically enable or disable them based on where the user is focused within your Scene, is a pretty canonical use case for focusedSceneValue. Perhaps you’d also like to know which instance of your Scene any invoked command should go to if your application supports multiple windows.

If you’re not yet familiar with using SwiftUI for building good menu bar experiences on the Mac (and iPad starting with iOS 26), Apple’s own Building and customizing the menu bar with SwiftUI is a good starting point.
If you’re coming from a macOS and particularly AppKit background, FocusedValue and focusedSceneValue will sound very conceptually similar to the Responder Chain.

The simplest example would be implementing key equivalents (or Commands, if you’re willing to forgive my Mac-ism) to operate on a text view that is currently being edited somewhere in your app. Suppose we have a very simple Observable object that stores the contents of our document and the high-level state of the app’s UI, like so:

@Observable
final class DocumentState {
    var text: String = ""
    var selection: TextSelection?
    
    func formatBold() { … }
}

struct ContentView: View {
    @State private var state = DocumentState()

    var body: some View {
        TextEditor(text: $state.text, selection: $state.selection)
            .focusedSceneValue(state)
    }
}

Now, since I have added my Observable object as a focusedSceneValue, I can implement my formatting commands and add them to the scene:

struct EditingCommands: Commands {
    @FocusedValue(DocumentState.self) private var state
    
    var body: some Commands {
        CommandGroup(after: .textEditing) {
            Button("Bold") { state?.formatBold() }
                .disabled(state == nil)
        }
    }
}

@main
struct FocusedSceneValueDocumentApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commands { EditingCommands() }
    }
}

To see the effects of what we’ve just accomplished, it is probably the easiest to run the app on macOS. You will see that when no instances of our WindowGroup are present (for example, if we close all of the application’s windows), our custom Bold menu item will be disabled in the Edit menu. If we open at least one window, and if at least one of these windows is key, it will become enabled. Moreover, we get the desired effect of SwiftUI knowing which instance of the Scene the command should go to when multiple windows are open. Invoking the Bold menu item in this sample app will always operate on the key window, which is what the user of your app expects.

focusedSceneValue is very powerful for implementing automatic action validation in your app, and its use is likely required if you want to build anything close to a Mac-assed Mac app with SwiftUI App Lifecycle. The above example can be useful for establishing the baseline of how this is supposed to work. And now…

Completely broken if your Scene is a DocumentGroup

The above simply does not work if the following 2 conditions are true:

  • Your scene is a DocumentGroup, and
  • You’re running on any platform other than macOS.

As far as I can tell, the bug reproduces on all latest versions of iOS (including iPadOS) and visionOS, as well as the Seed versions of iOS 26 and visionOS 26.

To reproduce this issue, it is enough to create a document-based SwiftUI app from the Xcode template and try using the focusedSceneValue API to implement a simple autovalidating command. You’ll notice that in your Commands implementation, the FocusedValue is always simply nil and your body is never invoked again with an updated value, even as you present more instances of the DocumentGroup or close the existing ones.

Workaround

After reporting the bug, I wanted to determine if a conceptually simple and lightweight workaround was possible. Since focusedSceneValue works fine on macOS, I only needed a targeted workaround for UIKit based platforms.

  • First, we need some kind of shared state that our Commands implementation can read, since it’s by far the most lightweight way to pass the state across the Scene boundary. It’s not the “Swiftiest,” but felt like an appropriate balance of conciseness and complexity.
  • We need a way to track what the active Scene is. We can use the scenePhase Environment key with some added handling for onAppear and onDisappear.
  • Finally, we can combine these into a ViewModifier that can be reused across various Scenes, and adopt it in our Commands implementation.

Putting this all together, here is where I landed:

#if canImport(UIKit)

import SwiftUI

extension View {
    func documentGroupSceneFocusBinding<T: AnyObject>(
        _ document: T,
        keyPath: ReferenceWritableKeyPath<ActiveScene, T?>
    ) -> some View {
        ModifiedContent(content: self, modifier: DocumentGroupFocusWorkaround(document: document, keyPath: keyPath))
    }
}

struct DocumentGroupFocusWorkaround<T: AnyObject>: ViewModifier {
    let document: T
    let keyPath: ReferenceWritableKeyPath<ActiveScene, T?>
    
    @Environment(\.scenePhase) private var scenePhase
    
    func body(content: Content) -> some View {
        content
            .onChange(of: scenePhase) { _, newValue in scenePhaseDidChange(to: newValue) }
            .onAppear { scenePhaseDidChange(to: .active) }
            .onDisappear { scenePhaseDidChange(to: .background) }
    }
    
    private func scenePhaseDidChange(to scenePhase: ScenePhase) {
        let isActive = scenePhase == .active
        if isActive {
            ActiveScene.shared[keyPath: keyPath] = document
        } else if ActiveScene.shared[keyPath: keyPath] === document {
            ActiveScene.shared[keyPath: keyPath] = nil
        }
    }
}

@Observable @MainActor
final class ActiveScene {
    static let shared = ActiveScene()
}

#endif

What’s left is to add the fields we care about, like the DocumentState from the example app we’ve defined, to the ActiveScene definition:

final class ActiveScene {
    // …
    var documentState: DocumentState?
}

Then, our ContentView can trivially apply the workaround using a single line of code:

var body: some View {
    platformView
#if canImport(UIKit)
        .documentGroupSceneFocusBinding(state, keyPath: \.documentState)
#endif
}

The adoption inside our Commands definition would be trivial, too, not requiring any changes to the body of the implementation:

struct EditingCommands: Commands {
#if canImport(UIKit)
    private var state: DocumentState? {
        ActiveScene.shared.documentState
    }
#else
    @FocusedValue(DocumentState.self) private var state
#endif

    var body: some Commands {
        // …
    }
}

Conclusion

I’ve reported the bug to Apple (tracked as FB20116555), and I’m including both the repro case and the above workaround as sample code available on GitHub. I truly hope both DocumentGroup and Document-Based App lifecycle can get the attention they deserve in the near future, as these issues are quite prohibitive when it comes to attempting to use these APIs in production applications.