SwiftUI Keyboard Shortcut Scope

A SwiftUI keyboard shortcut remains active as long as the control it’s attached to is still in the view hierarchy, even when not onscreen.

Adding Keyboard Shortcuts

SwiftUI added The .keyboardShortcut modifier in iOS 14. You apply the modifier to a control. The keyboard shortcut performs the primary action of the control. The shortcut defaults to using the command modifier but you can change that:

.keyboardShortcut("D", modifiers: [.control, .option])

Here’s an example of navigation from a root to detail view with keyboard shortcuts applied to the buttons:

struct ContentView: View {
  @State private var isPresented: Bool = false

  var body: some View {
    NavigationStack {
      VStack(spacing: 16) {
          Button("Detail") {
            isPresented = true
          }
          .keyboardShortcut("D")

          Button("New") { ... }
          .keyboardShortcut("N")
      }
      .navigationTitle("Title")
      .navigationBarTitleDisplayMode(.inline)
      .navigationDestination(isPresented: $isPresented) {
          Button("Edit") { ... }
          .keyboardShortcut("E")
          .navigationTitle("Detail")
      }
    }
  }
}

A command-N on the root view performs the new button action. A command-D performs the detail button action triggering a navigation to the detail view. On the detail view, command-E performs the edit button action.

Keyboard Shortcut Scope

What’s surprising to me is that the command-N and command-D keyboard shortcuts are still active on the detail view. You can see this when holding down the command key to view the available keyboard shortcuts. Here’s the available shortcuts from the root view:

Root view with Toolbar HUD showing command-D and command-N shortcuts

If I navigate to the detail view, the new and detail button are no longer onscreen but their shortcuts still show up and trigger their actions:

Detail view with Toolbar HUD showing command-D, command-N, and command-E shortcuts

What’s Happening?

From the documentation:

The target of a keyboard shortcut is resolved in a leading-to-trailing, depth-first traversal of one or more view hierarchies. On macOS, the system looks in the key window first, then the main window, and then the command groups; on other platforms, the system looks in the active scene, and then the command groups.

So the system searches for the first control associated with the shortcut in the window/scene view hierarchy. It doesn’t matter if the control is offscreen. I can see situations where you might want a shortcut to trigger when the control is offscreen but it’s not what I want here.

Workaround

The best workaround I’ve been able to come up with is to disable controls when I don’t want the shortcut to be active. In this example, I can use the isPresented state to disable the new and detail commands when I’m presenting the detail view:

Button("Detail") {
  isPresented = true
}
.keyboardShortcut("D")
.disabled(isPresented)

 Button("New") { ... }
.keyboardShortcut("N")
.disabled(isPresented)

See also this comment from Curt Clifton