Skip to content

macOS AppKit Interoperability

The two-directional bridge — NSViewRepresentable and NSViewControllerRepresentable for embedding AppKit in SwiftUI; NSHostingController and NSHostingView for hosting SwiftUI in AppKit. Plus the responder chain, NSToolbar, NSOpenPanel, and drag-and-drop bridging.

When to Use This Skill

Use this skill when you're:

  • Embedding an AppKit view or view controller inside SwiftUI
  • Hosting SwiftUI views inside an AppKit app
  • Working around SwiftUI gaps — NSToolbar customization, NSOpenPanel options, NSTextView rich text
  • Debugging menu commands, copy/paste, or keyboard shortcuts that don't cross the SwiftUI/AppKit boundary
  • Bridging drag-and-drop between SwiftUI's Transferable model and AppKit's NSDraggingDestination
  • Diagnosing responder chain or focus behavior that breaks when mixing frameworks
  • Optimizing SwiftUI cells inside NSCollectionView or NSTableView for scroll performance

Example Prompts

Questions you can ask Claude that will draw from this skill:

  • "How do I host an NSTextView inside SwiftUI and keep bindings synced?"
  • "My SwiftUI cells inside NSCollectionView cause scroll jank. What's wrong?"
  • "When should I drop from .fileImporter to NSOpenPanel?"
  • "My .onCommand modifier is silently ignored. What did I miss?"
  • "Why are my writes from the AppKit delegate not reaching the SwiftUI binding?"

What This Skill Provides

Direction Decision

  • SwiftUI host + AppKit guest → NSViewRepresentable (raw view) or NSViewControllerRepresentable (controller with lifecycle)
  • AppKit host + SwiftUI guest → NSHostingController (controller contexts) or NSHostingView (raw view contexts)
  • When to bridge at all — start with SwiftUI; cross only when SwiftUI lacks the capability

NSViewRepresentable Lifecycle

  • makeCoordinator()makeNSView(context:)updateNSView(_:context:)dismantleNSView(_:coordinator:)
  • The coordinator pattern for delegate callbacks writing back to bindings
  • Refreshing context.coordinator.parent = self in updateNSView so bindings stay current
  • Guarding redundant property sets to avoid unnecessary AppKit work
  • Reading context.environment (e.g., isEnabled) and applying it to the AppKit view
  • The never-set-frame rule — SwiftUI owns layout; use .frame() on the SwiftUI side

NSHostingController vs NSHostingView

  • NSHostingController for view-controller contexts (NSSplitViewItem, sheets, popovers, modal windows, tabs)
  • NSHostingView for raw view contexts (collection cells, sidebars, table cells)
  • sizingOptions on the controller for Auto Layout constraint generation
  • Critical reuse rule: create the hosting view once, then update rootView on reuse — never new-hosting-view-per-cell

Responder Chain and Focus

  • The "they don't live in separate worlds" mental model — SwiftUI views participate in the AppKit responder chain
  • SwiftUI command modifiers — .copyable, .cuttable, .pasteDestination, .onMoveCommand, .onExitCommand, .onCommand(#selector(...))
  • The .focusable() requirement for command receivers
  • Full Keyboard Navigation testing (System Settings toggle on and off)

Bridging Other AppKit APIs

  • NSToolbar for capabilities .toolbar doesn't cover (item validation, user customization, centered groups)
  • NSOpenPanel for capabilities .fileImporter doesn't cover (directories, accessory views, ubiquitous content)
  • Drag and drop — Transferable + .draggable/.dropDestination for SwiftUI-native; NSDraggingDestination on the AppKit view inside a representable
  • Shared state via @Observable (or ObservableObject) accessible to both sides

Key Pattern

The most common performance bug — creating a new NSHostingView on every cell reuse instead of updating rootView:

swift
class ShortcutItemView: NSCollectionViewItem {
    private var hostingView: NSHostingView<ShortcutView>?

    func displayShortcut(_ shortcut: Shortcut) {
        let view = ShortcutView(shortcut: shortcut)
        if let hostingView {
            hostingView.rootView = view  // reuse — SwiftUI diffs internally
        } else {
            let newHosting = NSHostingView(rootView: view)
            self.view.addSubview(newHosting)
            setupConstraints(for: newHosting)
            hostingView = newHosting
        }
    }
}

In updateNSView, always refresh context.coordinator.parent = self so coordinator-held bindings stay current — stale references silently swallow writes back to SwiftUI state.

Documentation Scope

This page documents the appkit-interop skill in the axiom-macos suite. The skill file contains comprehensive guidance Claude uses when answering your questions about bridging SwiftUI and AppKit.

For UIKit-SwiftUI bridging — Use axiom-uikit for the same Representable pattern with UIView/UIViewController types.

  • swiftui-differences — Drop to AppKit only when these macOS SwiftUI primitives don't cover the need
  • windowsNSHostingController is the right way to host SwiftUI inside an AppKit-managed window or sheet
  • sandbox-and-file-access — Reasons to drop from .fileImporter to NSOpenPanel
  • menus-and-commands — Where SwiftUI's command modifiers meet AppKit's responder chain
  • axiom-uikit — Same representable pattern, UIKit edition

Resources

WWDC: 2022-10075

Docs: /swiftui/nsviewrepresentable, /swiftui/nsviewcontrollerrepresentable, /swiftui/nshostingcontroller, /swiftui/nshostingview, /appkit/nstoolbar, /appkit/nsopenpanel

Skills: axiom-macos, swiftui-differences, windows, sandbox-and-file-access, menus-and-commands

Released under the MIT License