All skills
rshankras avatar

/appkit-swiftui-bridge

@1361023

Expert guidance for hybrid AppKit-SwiftUI development. Covers NSViewRepresentable, hosting controllers, and state management between frameworks. Use when bridging AppKit and SwiftUI.

Use this Skill: https://skilld.dev/gh/rshankras/claude-code-apple-skills/appkit-swiftui-bridge

This session only. Nothing lands on disk.

state-management.md

≈1.9k tokens on demand. Your agent reads this file only when SKILL.md points to it.

State Management Across Frameworks

Bridging state between AppKit and SwiftUI. The key challenge is keeping both sides synchronized without retain cycles or stale data.

Approach 1: @Observable (Recommended, macOS 14+)

The simplest and most modern approach. Both AppKit and SwiftUI can observe the same @Observable class.

@Observable
class AppState {
    var currentDocument: Document?
    var isEditing = false
    var statusMessage = ""
}

SwiftUI Side

struct ContentView: View {
    var appState: AppState

    var body: some View {
        VStack {
            if let doc = appState.currentDocument {
                DocumentView(document: doc)
            }
            Text(appState.statusMessage)
                .foregroundStyle(.secondary)
        }
    }
}

AppKit Side

Use withObservationTracking to react to changes:

class AppKitController: NSViewController {
    let appState: AppState
    private var isObserving = true

    init(appState: AppState) {
        self.appState = appState
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) { fatalError() }

    override func viewDidLoad() {
        super.viewDidLoad()
        observeState()
    }

    private func observeState() {
        guard isObserving else { return }
        withObservationTracking {
            // Access properties you want to observe
            let message = appState.statusMessage
            updateStatusBar(message)
        } onChange: {
            // Re-observe on next change (must re-register)
            DispatchQueue.main.async { [weak self] in
                self?.observeState()
            }
        }
    }

    deinit {
        isObserving = false
    }
}

Hosting with @Observable

let appState = AppState()

// SwiftUI side - pass as environment
let hostingView = NSHostingView(
    rootView: ContentView()
        .environment(appState)
)

// AppKit side - use the same instance
let appKitController = AppKitController(appState: appState)

// Changes from either side propagate automatically
appState.statusMessage = "Updated from AppKit"  // SwiftUI view updates

Approach 2: Combine (macOS 10.15+)

Use Combine publishers for cross-framework communication when targeting older macOS versions.

Shared ViewModel with Combine

class SharedViewModel: ObservableObject {
    @Published var items: [Item] = []
    @Published var selectedItemID: UUID?
    @Published var isLoading = false
}

SwiftUI Side

struct ItemListView: View {
    @ObservedObject var viewModel: SharedViewModel

    var body: some View {
        List(viewModel.items, selection: $viewModel.selectedItemID) { item in
            Text(item.name)
        }
    }
}

AppKit Side

class AppKitSidebarController: NSViewController {
    let viewModel: SharedViewModel
    private var cancellables = Set<AnyCancellable>()

    init(viewModel: SharedViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) { fatalError() }

    override func viewDidLoad() {
        super.viewDidLoad()

        viewModel.$selectedItemID
            .receive(on: DispatchQueue.main)
            .sink { [weak self] selectedID in
                self?.highlightItem(selectedID)
            }
            .store(in: &cancellables)

        viewModel.$items
            .receive(on: DispatchQueue.main)
            .sink { [weak self] items in
                self?.reloadTable(with: items)
            }
            .store(in: &cancellables)
    }

    func userSelectedItem(_ id: UUID) {
        viewModel.selectedItemID = id  // SwiftUI view updates automatically
    }
}

Approach 3: NotificationCenter

Best for loosely coupled, fire-and-forget communication between distant parts of the app.

// Define notification names
extension Notification.Name {
    static let documentDidSave = Notification.Name("documentDidSave")
    static let themeDidChange = Notification.Name("themeDidChange")
}

// AppKit posts
NotificationCenter.default.post(
    name: .documentDidSave,
    object: nil,
    userInfo: ["documentID": document.id]
)

// SwiftUI receives
struct ContentView: View {
    var body: some View {
        Text("Content")
            .onReceive(NotificationCenter.default.publisher(for: .documentDidSave)) { notification in
                if let docID = notification.userInfo?["documentID"] as? UUID {
                    handleSave(docID)
                }
            }
    }
}

Approach 4: Shared UserDefaults / App Storage

For simple preferences shared between both frameworks:

// SwiftUI side
@AppStorage("sidebarWidth") private var sidebarWidth: Double = 250

// AppKit side
UserDefaults.standard.addObserver(self, forKeyPath: "sidebarWidth", context: nil)

override func observeValue(forKeyPath keyPath: String?, of object: Any?,
                           change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
    if keyPath == "sidebarWidth" {
        let width = UserDefaults.standard.double(forKey: "sidebarWidth")
        updateSidebarWidth(width)
    }
}

Approach 5: NSResponder Chain

Pass actions up through the responder chain from SwiftUI to AppKit:

// SwiftUI sends an action up the responder chain
struct ToolbarView: View {
    var body: some View {
        Button("Save") {
            NSApp.sendAction(#selector(DocumentController.saveDocument(_:)), to: nil, from: nil)
        }
    }
}

// AppKit receives via responder chain
class DocumentController: NSDocumentController {
    @objc func saveDocument(_ sender: Any?) {
        currentDocument?.save(nil)
    }
}

Choosing the Right Approach

Approach macOS Version Coupling Best For
@Observable 14+ Tight Shared view models, closely related views
Combine 10.15+ Medium Reactive data streams, async updates
NotificationCenter Any Loose Cross-module events, fire-and-forget
UserDefaults Any Loose Simple preferences, settings
Responder Chain Any Loose Menu actions, commands

Common Mistakes

Retain Cycles

// Wrong - strong reference cycle
class Coordinator: NSObject {
    let viewModel: SharedViewModel
    var cancellable: AnyCancellable?

    init(viewModel: SharedViewModel) {
        self.viewModel = viewModel
        cancellable = viewModel.$items.sink { items in
            self.update(items)  // Strong capture of self!
        }
    }
}

// Right - weak capture
cancellable = viewModel.$items.sink { [weak self] items in
    self?.update(items)
}

Thread Safety

// Wrong - updating UI from background thread
viewModel.$data
    .sink { data in
        self.tableView.reloadData()  // May be on background thread!
    }

// Right - ensure main thread
viewModel.$data
    .receive(on: DispatchQueue.main)
    .sink { [weak self] data in
        self?.tableView.reloadData()
    }

Best Practices

  1. Use @Observable for macOS 14+ - Simplest approach, works automatically across both frameworks
  2. Avoid mixing approaches - Pick one primary mechanism per data flow
  3. Always use weak self in closures - Prevent retain cycles in Combine sinks and callbacks
  4. Dispatch to main thread - All UI updates must happen on the main thread
  5. Clean up subscriptions - Cancel Combine subscriptions and remove observers in deinit/dismantle
  6. Keep shared state minimal - Only share what both frameworks actually need
  7. Test bidirectional updates - Verify changes from either side propagate correctly

Source: SKILL.md on GitHub

No alerts2mo3 checks · Risk SAFE
  • Gen Agent Trust Hub2mo

    This skill provides expert guidance for Apple developers working with hybrid AppKit and SwiftUI applications. It contains educational content and code snippets for standard framework bridging protocols without any security risks or malicious behavior.

  • Socket2mo

    No alerts

  • Snyk2mo

    Risk: LOW · No issues

Signed by skilld at 1361023. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Steadyupdated 3 months ago
What it can do
Reads files
last_verified
2026-07-16
review_by
2027-06-22
os_version
iOS 27 / macOS 27
All 3 allowed tools
ReadGlobGrep

README badge

README badge for rshankras/claude-code-apple-skills/appkit-swiftui-bridge