How to Use SwiftUI with AppKit for Complex macOS UIs: The Palmier Pro Pattern
Use the NSViewRepresentable protocol to wrap AppKit NSView subclasses in SwiftUI views, enabling complex macOS interfaces like custom scroll views, drag-and-drop zones, and high-performance video previews while maintaining SwiftUI's reactive state management.
When building sophisticated macOS applications, you often encounter interface requirements that exceed SwiftUI's native capabilities. The Palmier Pro repository demonstrates a production-ready architecture for integrating SwiftUI with AppKit, allowing developers to leverage mature macOS frameworks while preserving SwiftUI's declarative syntax and data flow.
Why Bridge SwiftUI and AppKit?
Complex macOS UIs frequently require fine-grained control unavailable in pure SwiftUI. AppKit provides mature view hierarchies including NSScrollView, NSHostingView, and AVPlayerLayer that can be tailored for performance or native macOS behavior. Once wrapped using NSViewRepresentable, these AppKit views participate fully in SwiftUI's layout system and can access @Environment values and @Binding properties.
The bridging pattern also enables the Coordinator object within NSViewRepresentable to handle delegate callbacks, notifications, and gesture handling while forwarding changes back to SwiftUI state. This separation of concerns keeps low-level drawing and event handling in AppKit while SwiftUI manages high-level layout and application state.
The NSViewRepresentable Protocol Structure
The core of this integration is the NSViewRepresentable protocol, which requires three main methods: makeNSView(context:) to create the native view, updateNSView(_:context:) to sync SwiftUI state changes, and optionally dismantleNSView(_:coordinator:) for cleanup.
Basic Implementation Skeleton
struct MyWrapper: NSViewRepresentable {
@Binding var isActive: Bool
let onDrop: ([URL]) -> Void
func makeNSView(context: Context) -> MyNSView {
let view = MyNSView()
view.onDrop = onDrop
view.onTargetChanged = { isActive = $0 }
return view
}
func updateNSView(_ nsView: MyNSView, context: Context) {
// Sync view with SwiftUI state
}
static func dismantleNSView(_ nsView: MyNSView, coordinator: Coordinator) {
// Cleanup resources
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
final class Coordinator {
// Holds delegates and notification observers
}
}
The makeNSView method instantiates the native view and attaches closures that modify SwiftUI bindings. The updateNSView method reacts to state changes, while the coordinator bridges AppKit delegate patterns back to SwiftUI.
Real-World Implementation Examples from Palmier Pro
Palmier Pro implements several sophisticated NSViewRepresentable wrappers that demonstrate different aspects of the SwiftUI-AppKit bridge.
Drag-and-Drop with MediaPanelDropArea
For custom drag-and-drop targets, MediaPanelDropArea wraps DropHostingView (an NSHostingView subclass) to forward AppKit drag events to SwiftUI bindings.
struct MediaPanelDropArea<Content: View>: NSViewRepresentable {
@Binding var isTargeted: Bool
let onDrop: (_ urls: [URL]) -> Void
@ViewBuilder let content: () -> Content
func makeNSView(context: Context) -> DropHostingView<Content> {
let view = DropHostingView(rootView: content())
view.onTargetChanged = { isTargeted = $0 }
view.onDrop = onDrop
return view
}
func updateNSView(_ nsView: DropHostingView<Content>, context: Context) {
nsView.rootView = content()
nsView.onTargetChanged = { isTargeted = $0 }
nsView.onDrop = onDrop
}
}
In Sources/PalmierPro/MediaPanel/MediaTab/MediaPanelDropArea.swift, the wrapper forwards draggingEntered, draggingExited, and performDragOperation from the AppKit view to the SwiftUI isTargeted binding and onDrop closure.
Scrollable Timelines with TimelineContainerView
For complex scrolling interfaces, TimelineContainerView creates an NSScrollView that hosts a custom TimelineView. The coordinator observes NSView.boundsDidChangeNotification and NSView.frameDidChangeNotification to synchronize SwiftUI graphics with scroll position.
@MainActor @objc func scrollViewBoundsChanged(_ notification: Notification) {
timelineView?.needsDisplay = true
timelineView?.updatePlayheadLayer()
if let scrollY = scrollView?.contentView.bounds.origin.y {
headerView?.setBoundsOrigin(NSPoint(x: 0, y: scrollY))
headerView?.needsDisplay = true
}
}
As implemented in Sources/PalmierPro/Timeline/TimelineContainerView.swift, the coordinator stores references to native sub-views and caches the latest RenderState to determine whether redraws are required. This pattern enables independent scrolling of header and content layers while maintaining smooth 60fps performance.
Video Previews with PreviewView
For high-performance video rendering, PreviewView wraps PreviewNSView, which owns an AVPlayerLayer. The implementation exposes command-scroll zoom functionality through closures that bridge AppKit events to SwiftUI state.
view.onCmdScroll = { [weak editor] deltaY, pointTopDown, viewSize in
guard let editor = editor else { return }
// Compute new zoom and offset values
editor.canvasOffset = newOffset
editor.canvasZoom = newZoom
}
According to Sources/PalmierPro/Preview/PreviewView.swift, the coordinator holds a reference to the VideoEngine, ensuring the preview stays alive while SwiftUI owns the wrapper. This approach keeps heavy CoreAnimation layers in AppKit while SwiftUI manages the surrounding layout and toolbars.
Performance and Architecture Benefits
Separating concerns between SwiftUI and AppKit yields significant architectural advantages. AppKit handles low-level drawing, scrolling inertia, and drag-and-drop operations, while SwiftUI declares the surrounding layout and manages application state. This separation allows heavy graphics components like video layers or custom CALayer trees to avoid SwiftUI's view recomputation overhead for every frame.
Wrapped views remain fully composable within SwiftUI hierarchies. You can apply standard modifiers like .padding(), .background(), or custom AppTheme values defined in Sources/PalmierPro/UI/AppTheme.swift to wrapped components. The top-level EditorView in Sources/PalmierPro/Editor/EditorView.swift demonstrates how these wrapped components integrate seamlessly with pure SwiftUI views.
Summary
- Use
NSViewRepresentableto bridge AppKit views into SwiftUI while maintaining access to bindings and environment objects. - Implement a Coordinator to handle AppKit delegate callbacks and notifications, forwarding events to SwiftUI state.
- Reference production patterns from
TimelineContainerView.swift,MediaPanelDropArea.swift, andPreviewView.swiftfor scroll views, drag-and-drop, and video rendering. - Keep heavy graphics in AppKit to avoid SwiftUI performance overhead while using SwiftUI for layout and state management.
- Apply SwiftUI modifiers to wrapped views just like native SwiftUI components, ensuring consistent theming through shared resources like
AppTheme.swift.
Frequently Asked Questions
How do I handle state synchronization between AppKit and SwiftUI?
Use @Binding properties in your NSViewRepresentable implementation and update them from within the coordinator or view closures. The updateNSView method receives the latest SwiftUI state whenever bindings change, allowing you to sync native view properties. For AppKit-to-SwiftUI communication, capture bindings in closures assigned to the native view during makeNSView, or use the coordinator to observe notifications and update published properties.
Can I use SwiftUI views inside my wrapped AppKit view?
Yes, by using NSHostingView as your container or as a subview within your custom NSView subclass. MediaPanelDropArea demonstrates this by wrapping DropHostingView (an NSHostingView subclass), which allows SwiftUI content to reside inside the AppKit drag-and-drop target while still receiving AppKit events.
What is the performance impact of using NSViewRepresentable?
When implemented correctly, the performance impact is minimal because AppKit handles the heavy rendering operations directly. The pattern actually improves performance for complex UIs by keeping CoreAnimation layers, video buffers, and custom drawing code in optimized AppKit views while avoiding SwiftUI's reconciliation overhead for frames that don't change. Ensure you implement updateNSView efficiently to avoid unnecessary view recreation.
How do I handle cleanup when the view disappears?
Implement the static dismantleNSView(_:coordinator:) method in your NSViewRepresentable conformance. This method receives the view and coordinator when SwiftUI removes the view from the hierarchy, allowing you to invalidate timers, remove notification observers, release VideoEngine references, or perform other resource cleanup. The coordinator persists for the lifetime of the view, making it ideal for managing resources that must survive view updates but require cleanup on removal.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →