Skip to content
macOSDeep Dive Published Updated 7 min readViews unavailable

SwiftUI and AppKit on macOS: Representable Lifecycles and Coordinators

Bridge AppKit into SwiftUI with idempotent updates, coordinator ownership, target-action synchronization, sizing contracts, and teardown.

NSViewRepresentable lets SwiftUI create and manage an AppKit view inside a SwiftUI hierarchy. It is a lifecycle adapter, not a one-time constructor. SwiftUI may create a native view, call updateNSView as state changes, create a coordinator for delegate or target-action communication, and dismantle the view when it leaves the hierarchy. Correct bridges make updates idempotent and avoid using AppKit view identity as the application’s source of truth.

Choose the integration direction intentionally. Use NSViewRepresentable when a SwiftUI screen needs a specific AppKit control or view. Use NSHostingView or NSHostingController when an AppKit window wants to host SwiftUI content. Mixing both directions can be useful during migration, but every boundary should have one owner for model state, focus, sizing, and teardown.

Create the native view once, update its configuration

makeNSView(context:) creates the view object and configures its initial state. updateNSView(_:context:) applies current SwiftUI state to the existing instance. Treat update as a reconciliation function: compare current native state with incoming values and make only necessary changes. Do not recreate the view on every state change, start network requests from every update, or assume a particular call count.

import AppKit
import SwiftUI

struct SearchFieldBridge: NSViewRepresentable {
    @Binding var text: String

    func makeCoordinator() -> Coordinator { Coordinator(self) }

    func makeNSView(context: Context) -> NSSearchField {
        let field = NSSearchField()
        field.delegate = context.coordinator
        field.stringValue = text
        return field
    }

    func updateNSView(_ field: NSSearchField, context: Context) {
        context.coordinator.parent = self
        if field.stringValue != text { field.stringValue = text }
    }

    static func dismantleNSView(_ field: NSSearchField, coordinator: Coordinator) {
        field.delegate = nil
    }

    final class Coordinator: NSObject, NSSearchFieldDelegate {
        var parent: SearchFieldBridge
        init(_ parent: SearchFieldBridge) { self.parent = parent }

        func controlTextDidChange(_ notification: Notification) {
            guard let field = notification.object as? NSSearchField,
                  parent.text != field.stringValue else { return }
            parent.text = field.stringValue
        }
    }
}

The equality guard prevents unnecessary write-back when SwiftUI sends state already reflected by the native control. The coordinator forwards delegate changes into the binding and is the right place for state that must survive representable value reconstruction. Clear delegates or observers during dismantling if the native view can outlive its representable coordinator through another owner.

Use a coordinator as a narrow adapter

SwiftUI view values are transient descriptions. A coordinator is a reference object designed to communicate delegate and target-action events from the native view back to the current SwiftUI value. Refresh its parent value during updateNSView; otherwise, callbacks may capture an obsolete binding or configuration. Keep the coordinator small and avoid turning it into an unbounded second model store.

For AppKit controls with target-action, set the coordinator as target and a stable selector as action. For delegate-based views, assign the coordinator or another retained owner. Avoid strong cycles between coordinator, represented view, and model. If asynchronous work starts from a callback, capture a model identifier and generation, then ignore results if the SwiftUI state has moved to another item before completion.

Do not make the coordinator the only owner of durable application state. SwiftUI can reconstruct the representable value while preserving the coordinator, and the coordinator’s parent should be refreshed to the latest value on updates. Store a stable model object or binding in the appropriate SwiftUI ownership mechanism, then let the coordinator forward native events. This keeps the bridge replaceable and makes it possible to test state logic without creating an AppKit window.

For controls that emit many intermediate changes, decide when to write each value back. A text field can update on each edit, while an expensive search may debounce or commit on Return. Keep the native displayed value and model value synchronized through a clear policy; do not throttle one side without defining which value wins when an external model update arrives mid-edit. If edits can conflict, expose that state rather than silently overwriting the user’s in-progress input.

Avoid update loops and competing layout ownership

SwiftUI owns the represented view’s frame and bounds. Do not directly assign those layout properties from bridge code. Supply intrinsic sizing or implement the representable sizing contract where appropriate; let SwiftUI’s layout system propose and manage geometry. If Auto Layout constraints are required inside the native view, constrain its subviews and avoid constraints that fight the outer SwiftUI frame.

A classic feedback loop occurs when updateNSView sets an AppKit property, that property emits a delegate callback, and the callback writes a semantically identical value back to SwiftUI, triggering another update. Compare normalized values, suppress programmatic echoes when necessary, and make state transitions idempotent. Avoid relying on a short-lived Boolean flag if callbacks can arrive asynchronously; use transaction or generation identity where the API allows it.

Focus, first responder, and accessibility are also lifecycle state. The represented control may be recreated after conditional view changes, and focus should not be restored blindly to a stale instance. Provide a deliberate focus bridge if the user interaction requires one. Ensure the control’s accessible label, role, enabled state, and value track the same model revision as the rest of the SwiftUI interface.

Sizing, appearance, and window transitions

An AppKit view can have an intrinsic content size, while SwiftUI proposes a size from its parent. Implement sizeThatFits only when the native content has a meaningful preferred size under the proposal. Do not measure recursively by forcing layout during every update. Test in resizable windows and at different text sizes, localizations, and dynamic appearances.

An NSHostingView used in the opposite direction has its own sizing options and reflects SwiftUI content into AppKit layout. Avoid applying the NSViewRepresentable frame rule backwards without checking the hosting API contract. Keep one layer responsible for the outer constraints. When changing windows, ensure observers and delegates owned by the embedded control are not accidentally left attached to the old window.

Prefer NSHostingView for a SwiftUI subview that must live in an existing AppKit hierarchy, and NSHostingController when the embedding boundary benefits from view-controller containment or presentation. A hosting view manages a SwiftUI hierarchy and participates in AppKit layout and event delivery; it is not a static bitmap. Keep its root view replacement explicit, and do not construct a fresh host during every parent layout pass. If the host is reused, update the root content from current state and verify how sizing options interact with the container constraints.

When a hybrid window uses both SwiftUI and AppKit menus, focus, or commands, assign each command one source of truth. Duplicate shortcut handlers in the hosting tree and responder chain can invoke an action twice. Test keyboard traversal across the boundary and ensure a focused AppKit text control still receives composition and editing events. If the embedded view participates in document editing, coordinate undo registration with the owning document rather than maintaining an invisible second undo stack.

Appearance changes, backing scale, and view attachment can require native refresh. Respond to AppKit’s relevant lifecycle callbacks when the control needs them, but publish durable meaning through the model rather than storing it only in the native instance. A representable can disappear and be recreated while the user’s logical selection remains the same.

Teardown and side effects

Use dismantleNSView(_:coordinator:) to undo registrations owned by the bridge: remove notification observers, cancel view-scoped tasks, clear delegates, and release external resources. Teardown may happen because the view is removed, not only because the app is quitting. Make cleanup idempotent and do not write persistent state merely because SwiftUI is recomputing the hierarchy.

For a native view that owns an expensive resource, separate resource lifetime from rendering lifetime. Start it when the feature is active, stop it when the view’s task ends, and make that policy explicit if the work should continue when the visual control disappears. Avoid hidden work continuing because a coordinator captured the parent model strongly.

Acceptance checks

Test first creation, repeated state updates, binding changes from both directions, conditional removal and reinsertion, window moves, focus changes, resizing, appearance changes, teardown during async work, and repeated observer installation. Assert that native view count stays stable while state updates, no values echo indefinitely, and no callback mutates a newer model generation.

Measure representable creation and update frequency, layout passes, retained coordinator count, and teardown completion. Use SwiftUI view debugging and AppKit view hierarchy inspection to confirm there is one intended owner at each boundary. A robust bridge adapts SwiftUI state to AppKit in repeatable updates, routes delegate changes through a coordinator, and cleans up external effects when removed.

Related:

Sources:

Comments