Initial commit
This commit is contained in:
485
skills/expertise/macos-apps/references/appkit-integration.md
Normal file
485
skills/expertise/macos-apps/references/appkit-integration.md
Normal file
@@ -0,0 +1,485 @@
|
||||
# AppKit Integration
|
||||
|
||||
When and how to use AppKit alongside SwiftUI for advanced functionality.
|
||||
|
||||
<when_to_use_appkit>
|
||||
Use AppKit (not SwiftUI) when you need:
|
||||
- Custom drawing with `NSView.draw(_:)`
|
||||
- Complex text editing (`NSTextView`)
|
||||
- Drag and drop with custom behaviors
|
||||
- Low-level event handling
|
||||
- Popovers with specific positioning
|
||||
- Custom window chrome
|
||||
- Backward compatibility (< macOS 13)
|
||||
|
||||
**Anti-pattern: Using AppKit to "fix" SwiftUI**
|
||||
|
||||
Before reaching for AppKit as a workaround:
|
||||
1. Search your SwiftUI code for what's declaratively controlling the behavior
|
||||
2. SwiftUI wrappers (NSHostingView, NSViewRepresentable) manage their wrapped AppKit objects
|
||||
3. Your AppKit code may run but be overridden by SwiftUI's declarative layer
|
||||
4. Example: Setting `NSWindow.minSize` is ignored if content view has `.frame(minWidth:)`
|
||||
|
||||
**Debugging mindset:**
|
||||
- SwiftUI's declarative layer = policy
|
||||
- AppKit's imperative APIs = implementation details
|
||||
- Policy wins. Check policy first.
|
||||
|
||||
Prefer SwiftUI for everything else.
|
||||
</when_to_use_appkit>
|
||||
|
||||
<nsviewrepresentable>
|
||||
<basic_pattern>
|
||||
```swift
|
||||
import SwiftUI
|
||||
|
||||
struct CustomCanvasView: NSViewRepresentable {
|
||||
@Binding var drawing: Drawing
|
||||
|
||||
func makeNSView(context: Context) -> CanvasNSView {
|
||||
let view = CanvasNSView()
|
||||
view.delegate = context.coordinator
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: CanvasNSView, context: Context) {
|
||||
nsView.drawing = drawing
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, CanvasDelegate {
|
||||
var parent: CustomCanvasView
|
||||
|
||||
init(_ parent: CustomCanvasView) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func canvasDidUpdate(_ drawing: Drawing) {
|
||||
parent.drawing = drawing
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</basic_pattern>
|
||||
|
||||
<with_sizeThatFits>
|
||||
```swift
|
||||
struct IntrinsicSizeView: NSViewRepresentable {
|
||||
let text: String
|
||||
|
||||
func makeNSView(context: Context) -> NSTextField {
|
||||
let field = NSTextField(labelWithString: text)
|
||||
field.setContentHuggingPriority(.required, for: .horizontal)
|
||||
return field
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSTextField, context: Context) {
|
||||
nsView.stringValue = text
|
||||
}
|
||||
|
||||
func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSTextField, context: Context) -> CGSize? {
|
||||
nsView.fittingSize
|
||||
}
|
||||
}
|
||||
```
|
||||
</with_sizeThatFits>
|
||||
</nsviewrepresentable>
|
||||
|
||||
<custom_nsview>
|
||||
<drawing_view>
|
||||
```swift
|
||||
import AppKit
|
||||
|
||||
class CanvasNSView: NSView {
|
||||
var drawing: Drawing = Drawing() {
|
||||
didSet { needsDisplay = true }
|
||||
}
|
||||
|
||||
weak var delegate: CanvasDelegate?
|
||||
|
||||
override var isFlipped: Bool { true } // Use top-left origin
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
guard let context = NSGraphicsContext.current?.cgContext else { return }
|
||||
|
||||
// Background
|
||||
NSColor.windowBackgroundColor.setFill()
|
||||
context.fill(bounds)
|
||||
|
||||
// Draw content
|
||||
for path in drawing.paths {
|
||||
context.setStrokeColor(path.color.cgColor)
|
||||
context.setLineWidth(path.lineWidth)
|
||||
context.addPath(path.cgPath)
|
||||
context.strokePath()
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse handling
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
drawing.startPath(at: point)
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
override func mouseDragged(with event: NSEvent) {
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
drawing.addPoint(point)
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
drawing.endPath()
|
||||
delegate?.canvasDidUpdate(drawing)
|
||||
}
|
||||
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
}
|
||||
|
||||
protocol CanvasDelegate: AnyObject {
|
||||
func canvasDidUpdate(_ drawing: Drawing)
|
||||
}
|
||||
```
|
||||
</drawing_view>
|
||||
|
||||
<keyboard_handling>
|
||||
```swift
|
||||
class KeyHandlingView: NSView {
|
||||
var onKeyPress: ((NSEvent) -> Bool)?
|
||||
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
if let handler = onKeyPress, handler(event) {
|
||||
return // Event handled
|
||||
}
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
|
||||
override func flagsChanged(with event: NSEvent) {
|
||||
// Handle modifier key changes
|
||||
if event.modifierFlags.contains(.shift) {
|
||||
// Shift pressed
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</keyboard_handling>
|
||||
</custom_nsview>
|
||||
|
||||
<nstextview_integration>
|
||||
<rich_text_editor>
|
||||
```swift
|
||||
struct RichTextEditor: NSViewRepresentable {
|
||||
@Binding var attributedText: NSAttributedString
|
||||
var isEditable: Bool = true
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
let scrollView = NSTextView.scrollableTextView()
|
||||
let textView = scrollView.documentView as! NSTextView
|
||||
|
||||
textView.delegate = context.coordinator
|
||||
textView.isEditable = isEditable
|
||||
textView.isRichText = true
|
||||
textView.allowsUndo = true
|
||||
textView.usesFontPanel = true
|
||||
textView.usesRuler = true
|
||||
textView.isRulerVisible = true
|
||||
|
||||
// Typography
|
||||
textView.textContainerInset = NSSize(width: 20, height: 20)
|
||||
textView.font = .systemFont(ofSize: 14)
|
||||
|
||||
return scrollView
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSScrollView, context: Context) {
|
||||
let textView = nsView.documentView as! NSTextView
|
||||
|
||||
if textView.attributedString() != attributedText {
|
||||
textView.textStorage?.setAttributedString(attributedText)
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, NSTextViewDelegate {
|
||||
var parent: RichTextEditor
|
||||
|
||||
init(_ parent: RichTextEditor) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard let textView = notification.object as? NSTextView else { return }
|
||||
parent.attributedText = textView.attributedString()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</rich_text_editor>
|
||||
</nstextview_integration>
|
||||
|
||||
<nshostingview>
|
||||
Use SwiftUI views in AppKit:
|
||||
|
||||
```swift
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
class MyWindowController: NSWindowController {
|
||||
convenience init() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
|
||||
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
// SwiftUI content in AppKit window
|
||||
let hostingView = NSHostingView(
|
||||
rootView: ContentView()
|
||||
.environment(appState)
|
||||
)
|
||||
window.contentView = hostingView
|
||||
|
||||
self.init(window: window)
|
||||
}
|
||||
}
|
||||
|
||||
// In toolbar item
|
||||
class ToolbarItemController: NSToolbarItem {
|
||||
override init(itemIdentifier: NSToolbarItem.Identifier) {
|
||||
super.init(itemIdentifier: itemIdentifier)
|
||||
|
||||
let hostingView = NSHostingView(rootView: ToolbarButton())
|
||||
view = hostingView
|
||||
}
|
||||
}
|
||||
```
|
||||
</nshostingview>
|
||||
|
||||
<drag_and_drop>
|
||||
<dragging_source>
|
||||
```swift
|
||||
class DraggableView: NSView, NSDraggingSource {
|
||||
var item: Item?
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
guard let item = item else { return }
|
||||
|
||||
let pasteboardItem = NSPasteboardItem()
|
||||
pasteboardItem.setString(item.id.uuidString, forType: .string)
|
||||
|
||||
let draggingItem = NSDraggingItem(pasteboardWriter: pasteboardItem)
|
||||
draggingItem.setDraggingFrame(bounds, contents: snapshot())
|
||||
|
||||
beginDraggingSession(with: [draggingItem], event: event, source: self)
|
||||
}
|
||||
|
||||
func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
|
||||
context == .withinApplication ? .move : .copy
|
||||
}
|
||||
|
||||
func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
|
||||
if operation == .move {
|
||||
// Remove from source
|
||||
}
|
||||
}
|
||||
|
||||
private func snapshot() -> NSImage {
|
||||
let image = NSImage(size: bounds.size)
|
||||
image.lockFocus()
|
||||
draw(bounds)
|
||||
image.unlockFocus()
|
||||
return image
|
||||
}
|
||||
}
|
||||
```
|
||||
</dragging_source>
|
||||
|
||||
<dragging_destination>
|
||||
```swift
|
||||
class DropTargetView: NSView {
|
||||
var onDrop: (([String]) -> Bool)?
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
registerForDraggedTypes([.string, .fileURL])
|
||||
}
|
||||
|
||||
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||
.copy
|
||||
}
|
||||
|
||||
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
||||
let pasteboard = sender.draggingPasteboard
|
||||
|
||||
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] {
|
||||
return onDrop?(urls.map { $0.path }) ?? false
|
||||
}
|
||||
|
||||
if let strings = pasteboard.readObjects(forClasses: [NSString.self]) as? [String] {
|
||||
return onDrop?(strings) ?? false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
```
|
||||
</dragging_destination>
|
||||
</drag_and_drop>
|
||||
|
||||
<window_customization>
|
||||
<custom_titlebar>
|
||||
```swift
|
||||
class CustomWindow: NSWindow {
|
||||
override init(
|
||||
contentRect: NSRect,
|
||||
styleMask style: NSWindow.StyleMask,
|
||||
backing backingStoreType: NSWindow.BackingStoreType,
|
||||
defer flag: Bool
|
||||
) {
|
||||
super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag)
|
||||
|
||||
// Transparent titlebar
|
||||
titlebarAppearsTransparent = true
|
||||
titleVisibility = .hidden
|
||||
|
||||
// Full-size content
|
||||
styleMask.insert(.fullSizeContentView)
|
||||
|
||||
// Custom background
|
||||
backgroundColor = .windowBackgroundColor
|
||||
isOpaque = false
|
||||
}
|
||||
}
|
||||
```
|
||||
</custom_titlebar>
|
||||
|
||||
<access_window_from_swiftui>
|
||||
```swift
|
||||
struct WindowAccessor: NSViewRepresentable {
|
||||
var callback: (NSWindow?) -> Void
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
DispatchQueue.main.async {
|
||||
callback(view.window)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||||
}
|
||||
|
||||
// Usage
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
MainContent()
|
||||
.background(WindowAccessor { window in
|
||||
window?.titlebarAppearsTransparent = true
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
</access_window_from_swiftui>
|
||||
</window_customization>
|
||||
|
||||
<popover>
|
||||
```swift
|
||||
class PopoverController {
|
||||
private var popover: NSPopover?
|
||||
|
||||
func show(from view: NSView, content: some View) {
|
||||
let popover = NSPopover()
|
||||
popover.contentViewController = NSHostingController(rootView: content)
|
||||
popover.behavior = .transient
|
||||
|
||||
popover.show(
|
||||
relativeTo: view.bounds,
|
||||
of: view,
|
||||
preferredEdge: .minY
|
||||
)
|
||||
|
||||
self.popover = popover
|
||||
}
|
||||
|
||||
func close() {
|
||||
popover?.close()
|
||||
popover = nil
|
||||
}
|
||||
}
|
||||
|
||||
// SwiftUI wrapper
|
||||
struct PopoverButton<Content: View>: NSViewRepresentable {
|
||||
@Binding var isPresented: Bool
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
func makeNSView(context: Context) -> NSButton {
|
||||
let button = NSButton(title: "Show", target: context.coordinator, action: #selector(Coordinator.showPopover))
|
||||
return button
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSButton, context: Context) {
|
||||
context.coordinator.isPresented = isPresented
|
||||
context.coordinator.content = AnyView(content())
|
||||
|
||||
if !isPresented {
|
||||
context.coordinator.popover?.close()
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, NSPopoverDelegate {
|
||||
var parent: PopoverButton
|
||||
var popover: NSPopover?
|
||||
var isPresented: Bool = false
|
||||
var content: AnyView = AnyView(EmptyView())
|
||||
|
||||
init(_ parent: PopoverButton) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
@objc func showPopover(_ sender: NSButton) {
|
||||
let popover = NSPopover()
|
||||
popover.contentViewController = NSHostingController(rootView: content)
|
||||
popover.behavior = .transient
|
||||
popover.delegate = self
|
||||
|
||||
popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY)
|
||||
self.popover = popover
|
||||
parent.isPresented = true
|
||||
}
|
||||
|
||||
func popoverDidClose(_ notification: Notification) {
|
||||
parent.isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</popover>
|
||||
|
||||
<best_practices>
|
||||
<do>
|
||||
- Use NSViewRepresentable for custom views
|
||||
- Use Coordinator for delegate callbacks
|
||||
- Clean up resources in NSViewRepresentable
|
||||
- Use NSHostingView to embed SwiftUI in AppKit
|
||||
</do>
|
||||
|
||||
<avoid>
|
||||
- Using AppKit when SwiftUI suffices
|
||||
- Forgetting to set acceptsFirstResponder for keyboard input
|
||||
- Not handling coordinate system (isFlipped)
|
||||
- Memory leaks from strong delegate references
|
||||
</avoid>
|
||||
</best_practices>
|
||||
Reference in New Issue
Block a user