-
AppKit 및 UIKit과 함께 SwiftUI 사용하기
기존 AppKit 또는 UIKit 앱에 SwiftUI를 점진적으로 도입하는 방법을 알아보세요. Observation 프레임워크를 사용하여 뷰를 자동으로 업데이트하고, SwiftUI 구성요소를 기존 뷰 계층 구조에 통합하며, 제스처 인식기를 SwiftUI에 적용하는 방법을 안내합니다. 또한 전반적인 아키텍처를 변경하지 않고 앱에 완전한 SwiftUI 장면을 추가하는 방법도 살펴봅니다.
챕터
- 0:00 - Introduction
- 2:33 - Observation in AppKit
- 5:41 - Hosting SwiftUI in AppKit
- 7:48 - AppKit gestures in SwiftUI
- 9:16 - SwiftUI in the main menu
- 11:30 - SwiftUI scenes in AppKit
- 13:04 - Next steps
리소스
- Updating views automatically with observation tracking
- Updating views automatically with observation tracking
관련 비디오
WWDC26
WWDC25
WWDC22
WWDC21
-
비디오 검색…
안녕하세요, 저는 David Nadoba로 UI Frameworks 팀의 엔지니어입니다 오늘은 SwiftUI를 사용하는 방법에 관해 이야기하게 되어 기쁩니다 기존 AppKit 또는 UIKit 앱과 함께요 SwiftUI는 처음부터 AppKit 및 UIKit과 함께 잘 작동하도록 설계되었습니다 Swift가 Objective-C와 함께 작동하도록 설계된 것과 마찬가지입니다 모든 것을 다시 작성할 필요 없이 점진적으로 도입하기에 이상적입니다 또는 처음부터 시작할 필요도 없습니다
Apple은 수년간 이 전략을 사용해 왔습니다 Logic Pro는 Quantec Room Simulator 같은 플러그인에 SwiftUI를 사용하고 있습니다
macOS와 iPadOS 모두를 위한 Beat Breaker 플러그인에도 사용합니다 Xcode의 Coding Assistant는 처음부터 SwiftUI를 사용해 왔습니다 Xcode 27에서는 사이드바에서 에디터로 확장되고 있습니다
명시적으로 도입하지 않아도 요즘은 대부분의 앱이 SwiftUI를 암묵적으로 사용합니다 UI Frameworks 팀은 새 디자인을 기회로 삼아 SwiftUI로 Controls를 구현했습니다 이제 AppKit 타입을 사용하더라도 NSSlider 같은 타입을
NSSwitch, NSSegmentedControl을 사용하더라도 SwiftUI가 내부적으로 이러한 뷰와 그 이상을 렌더링하는 데 사용됩니다 해당 컨트롤과 OS의 다른 부분에 사용되는 Liquid Glass도 SwiftUI를 사용하여 구현의 대부분을 공유합니다 프레임워크와 플랫폼 전반에 걸쳐 이 영상에서는 더 많은 곳에서 SwiftUI를 도입하는 방법을 알려드리겠습니다 macOS에 집중하겠지만 다른 모든 Apple 플랫폼에도 개념이 동일하게 적용됩니다
먼저 @Observable을 사용하는 방법을 보여드리겠습니다 SwiftUI를 사용하기 전에도 NSView를 자동으로 업데이트하는 방법입니다 다음으로, SwiftUI 사용을 고려하기 좋은 때가 언제인지 이야기하겠습니다 NSView 계층 구조에 통합하는 방법도요
NSGestureRecognizer를 추가하는 방법도 보여드리겠습니다 SwiftUI View에 직접 추가하는 방법입니다 그런 다음 SwiftUI로 메뉴 항목을 만들고 기존 메인 메뉴에 추가할 것입니다
마지막으로 SwiftUI Scenes을 사용하는 방법을 다루겠습니다 기존 NSApplicationDelegate에서 이 발표 전반에 걸쳐 제가 만든 기존 AppKit 앱의 축소 버전을 사용하겠습니다
이 앱은 제 책상의 어드레서블 링 램프처럼 조명을 제어할 수 있습니다
색상을 변경하는 컨트롤이 있습니다
애니메이션을 실행하는 컨트롤도 있습니다
슬라이더가 어떻게 작동하는지 설명하겠습니다 @Observable 매크로가 어떻게 도움이 되는지도 보여드리겠습니다 앱은 시스템 색상 패널과 유사한 색상 피커를 사용합니다 컨트롤을 항상 가까이 두기 위해 인라인으로 표시됩니다 색상은 사용자 정의 트랙 그라디언트와 노브가 있는 슬라이더 3개로 제어됩니다 슬라이더 하나의 노브를 움직이면 새로 선택된 색상으로 자체적으로 다시 그려집니다 동시에 다른 모든 슬라이더도 그에 따라 업데이트됩니다
슬라이더는 자신의 값이 변경될 때 자동으로 다시 그려집니다 제 경우에는 변경된 값이 다른 슬라이더의 외관에도 영향을 미치지만 AppKit은 자동으로 다시 그리지 않습니다
현재는 AppKit에 수동으로 알려야 합니다 채도와 밝기 슬라이더를 다시 그리도록 색조 값이 변경될 때마다 needsDisplay를 true로 설정하여 수행됩니다
값 변경에 대해서도 유사하게 구현해야 합니다 나머지 모든 슬라이더와 다른 외부 변경에 대해서도 AppKit은 @Observable 타입의 속성에 대한 자동 Observation도 지원합니다 Swift 클래스에 @Observable 매크로를 추가하여 이를 활용하세요 모든 가변 변수가 관찰 시스템에 참여합니다
슬라이더는 NSSliderCell의 서브클래스로 구현됩니다 외관을 사용자 정의합니다 drawKnob 같은 특정 그리기 메서드를 재정의하여
새 ColorModel의 속성에만 접근하면 됩니다 drawKnob 메서드 내에서
AppKit은 각 접근을 추적하고 접근된 속성이 변경될 때마다 다시 그립니다 더 이상 needsDisplay를 true로 수동으로 설정할 필요가 없습니다
NSView 그리기의 일부로 호출되는 모든 그리기 메서드에 적용됩니다 NSSliderCell의 drawKnob 또는 drawBar 메서드처럼
NSView.draw(_:)는 observation을 지원하는 메서드 중 하나일 뿐입니다 updateConstraints(), layout(), updateLayer(), NSViewController에 해당하는 메서드도 Observation을 지원합니다
UIKit에는 UIView를 넘어 확장되는 더 많은 메서드가 있습니다 UIViewController, UIButton, UICollectionViewCell 등도 포함됩니다
이 통합을 macOS 15까지 하위 호환 배포할 수 있습니다 Info.plist에 NSObservationTrackingEnabled를 추가하면 됩니다 iOS 18에는 UIObservationTrackingEnabled를 추가하면 됩니다 2026년 이후 릴리스에서는 기본적으로 활성화됩니다 UIKit의 Observation Tracking에 대한 자세한 내용은 WWDC25의 "What's new in UIKit"을 시청하세요
네, 실제로 작동하는 모습을 보겠습니다 밝기를 높이겠습니다
색조를 빨간색으로 변경하겠습니다
좋아요, 모든 슬라이더가 업데이트되고 새 색상이 네트워크를 통해 조명으로 전송됩니다
@Observable을 도입하는 것은 좋은 시작입니다 NSView와 NSViewController에서 자동 업데이트를 받기 위한 새로운 것을 구현하고 싶을 때 SwiftUI로 이전하기도 더 쉬워집니다 새로운 것에 대해 말씀드리자면 다른 Color Picker 디자인에 대한 아이디어가 있습니다 색조는 빨간색으로 시작하여 모든 색상을 거쳐 다시 빨간색으로 돌아옵니다 이것을 원형 슬라이더로 표현하고 싶습니다
채도와 밝기를 나타낼 수 있습니다 외부 색조 링 안의 두 반원으로
바로 중앙에 결과 색상의 미리보기를 원으로 그리고 싶습니다 전체 그리기 코드와 상호작용이 완전히 바뀌므로 SwiftUI로 이전하기 좋은 때입니다
동일한 @Observable ColorModel을 재사용할 수 있습니다 이전 NSSlider 기반 Color Picker에서
뷰의 body에서 Canvas 뷰를 사용합니다 즉각적인 모드 그리기 API에 접근할 수 있습니다
Canvas는 AppKit 또는 UIKit의 drawRect와 매우 유사합니다 다시 그릴 때마다 클로저가 새로운 GraphicsContext와 함께 호출됩니다 획, 채우기, 변환, 필터 등 그리기 명령을 실행합니다 직접 적용할 수 있습니다 기존 CoreGraphics 그리기 코드를 SwiftUI에서 재사용할 수도 있습니다 withCGContext API를 호출하여 Canvas 소개는 WWDC21의 "Add rich graphics to your SwiftUI app"을 시청하세요
SwiftUI를 자체 Metal Shaders와 결합하는 방법을 알고 싶다면 WWDC26의 "Compose advanced graphics effects with SwiftUI"를 시청하세요 아직 많은 곳이 있습니다 색상 피커가 NSView 계층 구조에 포함된 곳이 많습니다
SwiftUI 뷰를 NSHostingView로 래핑할 수 있습니다 NSView의 서브클래스인
이미 모델을 @Observable로 옮겼으므로 이것이 정말 해야 할 전부입니다 NSHostingView 및 관련 타입에 대한 자세한 내용은 WWDC 2022의 "Use SwiftUI with AppKit"과 "Use SwiftUI with UIKit"을 시청하세요 새로운 Color Picker를 보여드리기 전에 기능을 하나 더 추가하고 싶습니다
밝기와 채도를 100%로 빠르게 재설정하고 싶습니다 트랙패드를 세게 누르는 Force Click 한 번으로 이를 위한 NSGestureRecognizer가 이미 있습니다 앱의 다른 부분에서 사용하는 NSGestureRecognizerRepresentable을 사용하여 새 SwiftUI View에 가져올 수 있습니다
다음을 준수하는 새 struct를 만드는 것부터 시작합니다 NSGestureRecognizerRepresentable 프로토콜을
makeNSGestureRecognizer에서 NSGestureRecognizer 서브클래스를 초기화하고 반환합니다
ForceClickGestureRecognizer는 앱의 다른 부분에서 사용하는 타입입니다 pressure stage 2에 도달했을 때를 인식합니다 Force Click을 트리거할 만큼 충분한 압력이 가해졌음을 나타냅니다
제스처가 인식되면 handleNSGestureRecognizerAction이 호출됩니다
채도와 밝기를 100%로 재설정하기에 적합한 곳입니다 HSBColorPicker SwiftUI 뷰로 돌아가서 .gesture 수정자로 이 제스처를 추가할 수 있습니다 SwiftUI Gesture처럼
ForceClickReset 제스처는 기존 드래그 제스처와 함께 작동합니다 다른 변경 없이도 SwiftUI에는 더 많은 representable 프로토콜도 있습니다 NSViewRepresentable처럼 NSView를 SwiftUI 뷰에 포함할 수 있습니다 Force Click은 모든 입력 장치에서 가능하지 않습니다 Magic Mouse나 MacBook Neo의 트랙패드처럼 모든 사람이 이 단축키를 활용할 수 있도록 이 기능에 접근하는 다른 방법을 추가해야 합니다 이 경우 키보드 단축키가 있는 메뉴 항목을 추가하겠습니다
앱은 메인 메뉴에 AppKit의 NSMenu를 사용합니다 SwiftUI를 사용하여 새 메뉴 항목을 추가하는 방법을 설명하겠습니다 View 프로토콜을 준수하는 새 struct를 만드는 것부터 시작합니다 공유된 ColorModel에 접근할 수 있습니다
뷰의 body에서 레이블이 있는 Button을 만들고 밝기와 채도를 100%로 재설정하는 액션 클로저를 만듭니다
withAnimation으로 수정을 감싸면 SwiftUI가 변경 사항을 애니메이션으로 처리합니다
빠른 접근을 위해 keyboardShortcut을 추가합니다 paletteStyle이 있는 Picker도 추가했습니다 일반적인 색상을 정확하게 선택하기 위해
이 SwiftUI View를 메인 메뉴에 추가해야 합니다
그를 위해 ColorMenu 뷰로 NSHostingMenu를 초기화합니다
NSHostingMenu는 NSMenu의 서브클래스로 다음과 같은 속성이 있습니다 메뉴를 구성하는 title 같은 속성들
남은 것은 NSMenuItem을 만드는 것입니다 colorMenu를 하위 메뉴로 설정하여 mainMenu에 추가합니다
이제 시험해 볼 시간입니다 켜겠습니다
모든 색조를 순환하여 초록색으로
키보드 단축키를 눌러 밝기를 몇 번 낮추겠습니다
그런 다음 메뉴 항목을 사용하여 완전히 끄겠습니다
Force Click을 하면 NSGestureRecognizer가 밝기를 재설정합니다
이 사용자 정의 SwiftUI 컨트롤을 앱에 점진적으로 추가했습니다
나머지 AppKit 앱은 이전과 동일하게 계속 작동합니다
마지막 단계로 완전한 SwiftUI Scenes을 앱에 가져오는 방법입니다 기존 앱 델리게이트를 사용하여
항상 사람들이 빠르게 접근할 수 있도록 조명의 색상이나 밝기를 변경할 수 있도록 이를 위해 메뉴 막대 추가 항목을 추가할 수 있습니다 SwiftUI의 MenuBarExtra scene으로 몇 줄만으로 가능합니다 NSHostingSceneRepresentation은 SwiftUI scene을 래핑하고 기존 AppKit 앱에서 동적으로 추가할 수 있습니다 scene을 추가하기 좋은 곳은 applicationWillFinishLaunching입니다 NSApplicationDelegate에서
scene과 함께 addSceneRepresentation을 호출하면 SwiftUI가 나머지를 처리합니다
MenuBarExtra scene이 있다면 사람들이 제거하고 다시 삽입할 수 있도록 하는 것도 좋습니다 Settings scene은 Toggle을 추가하기에 완벽한 곳입니다 MenuBarExtra scene 삽입 여부를 제어하는
NSHostingSceneRepresentation에는 환경 속성이 있습니다 openSettings() 액션을 노출하는
@IBAction에서 설정 창을 프로그래밍 방식으로 열 수 있습니다
앱의 메인 메뉴에서 설정을 열겠습니다
메뉴 막대 추가 항목을 활성화하겠습니다
색상 피커를 빠르게 열겠습니다
마지막으로 조명을 켜겠습니다
SwiftUI scenes에 대해 더 알고 싶다면 WWDC22의 "Bring multiple windows to your SwiftUI app"을 시청하세요 SwiftUI와 AppKit을 다양한 방식으로 혼합하는 방법을 보여드렸습니다 이를 결합하는 올바른 방법은 앱에 따라 다릅니다 그리고 해결하려는 문제에 따라
오늘 소개한 모든 API는 2026년 릴리스 이상에서 이미 사용할 수 있습니다
좋은 첫 번째 단계는 @Observable을 시도하는 것입니다 모델과 NSView를 자동으로 동기화하여 SwiftUI로의 전환을 원활하게 합니다 새 컴포넌트를 구현할 때 SwiftUI를 고려하세요 또는 기존 컴포넌트를 다시 작성할 때
기존 제스처 인식기 서브클래스를 SwiftUI 뷰에 추가하세요 기존 앱에서도 새 scene에는 SwiftUI로 시작하세요 그리고 기억하세요 앱이 완전히 SwiftUI일 필요 없이도 SwiftUI의 장점을 활용할 수 있습니다
시청해 주셔서 감사하고 훌륭한 앱을 만들어 주셔서 감사합니다
-
-
3:39 - Observation in AppKit
// Observation in AppKit import Observation @Observable @MainActor final class ColorModel { var hue: Double = 0.6 var saturation: Double = 1.0 var brightness: Double = 1.0 } -
6:28 - Circular color picker
// Circular color picker import SwiftUI import Observation @Observable @MainActor final class ColorModel { var hue: Double = 0.6 var saturation: Double = 1.0 var brightness: Double = 1.0 } // MARK: - Picker View @Animatable struct HSBColorPicker: View { var hue: Double var saturation: Double var brightness: Double @AnimatableIgnored var model: ColorModel init(model: ColorModel) { self.model = model self.hue = model.hue self.saturation = model.saturation self.brightness = model.brightness } var body: some View { Canvas { context, size in let metrics = PickerMetrics(size: size) drawPicker(in: &context, metrics: metrics, hue: hue, saturation: saturation, brightness: brightness) } .contentShape(Circle()) .modifier(ColorPickerDragGesture(model: model)) .aspectRatio(1, contentMode: .fit) } } // MARK: - Drag Gesture private struct ColorPickerDragGesture: ViewModifier { var model: ColorModel private enum Ring { case hue, saturation, brightness } @State private var draggedRing: Ring? func body(content: Content) -> some View { GeometryReader { proxy in content.gesture( DragGesture(minimumDistance: 0, coordinateSpace: .local) .onChanged { onDrag(to: $0.location, size: proxy.size) } .onEnded { _ in draggedRing = nil } ) } } private func onDrag(to location: CGPoint, size: CGSize) { let metrics = PickerMetrics(size: size) let point = CGPoint(x: location.x - metrics.mid.x, y: location.y - metrics.mid.y) if draggedRing == nil { let distance = hypot(point.x, point.y) if distance >= metrics.radius - metrics.ringWidth - metrics.gap / 2 { draggedRing = .hue } else if distance >= metrics.radius - metrics.ringWidth * 2 - metrics.gap { draggedRing = point.x > 0 ? .brightness : .saturation } } switch draggedRing { case .hue: model.hue = (angle0To2Pi(point) / (2 * .pi) + 0.25).truncatingRemainder(dividingBy: 1) case .saturation: model.saturation = leftSemicircleValue(point) case .brightness: model.brightness = 1 - rightSemicircleValue(point) case nil: break } } } // MARK: - Metrics struct PickerMetrics { let mid: CGPoint let radius: CGFloat let ringWidth: CGFloat let gap: CGFloat = 8 init(size: CGSize) { let border: CGFloat = 1 // reserve room so the outer ring's stroke isn't clipped mid = CGPoint(x: size.width / 2, y: size.height / 2) radius = (min(size.width, size.height) - 2 * border) / 2 ringWidth = radius / 3 } var diameter: CGFloat { radius * 2 } var innerRadius: CGFloat { (diameter - 2 * ringWidth - gap) / 2 } var centerRadius: CGFloat { radius - 2 * ringWidth - gap } } // MARK: - Geometry Helpers func angle0To2Pi(_ point: CGPoint) -> CGFloat { let a = atan2(point.y, point.x) return a >= 0 ? a : a + 2 * .pi } func rightSemicircleValue(_ point: CGPoint) -> CGFloat { let angle = atan2(point.y, point.x) return point.x >= 0 ? (angle + .pi / 2) / .pi : (point.y >= 0 ? 1 : 0) } func leftSemicircleValue(_ point: CGPoint) -> CGFloat { guard point.x <= 0 else { return point.y >= 0 ? 1 : 0 } return (atan2(point.y, -point.x) + .pi / 2) / .pi } private extension Path { /// A circle whose stroke of `lineWidth` lands inside `radius`. init(ring radius: CGFloat, center: CGPoint, lineWidth: CGFloat) { let inset = radius - lineWidth / 2 self.init(ellipseIn: CGRect(x: center.x - inset, y: center.y - inset, width: inset * 2, height: inset * 2)) } } // MARK: - Drawing private func drawPicker(in context: inout GraphicsContext, metrics: PickerMetrics, hue: Double, saturation: Double, brightness: Double) { drawHueRing(in: &context, metrics: metrics, hue: hue, saturation: saturation, brightness: brightness) drawValueRings(in: &context, metrics: metrics, hue: hue, saturation: saturation, brightness: brightness) drawCenter(in: &context, metrics: metrics, hue: hue, saturation: saturation, brightness: brightness) } private func drawHueRing(in context: inout GraphicsContext, metrics: PickerMetrics, hue: Double, saturation: Double, brightness: Double) { let ring = Path(ring: metrics.radius, center: metrics.mid, lineWidth: metrics.ringWidth) // A custom metal shader would be work great here as well let colors = stride(from: 0.0, through: 1, by: 1.0 / 64).map { Color(hue: $0, saturation: saturation, brightness: brightness) } context.stroke(ring, with: .conicGradient(Gradient(colors: colors), center: metrics.mid, angle: .degrees(-90)), lineWidth: metrics.ringWidth) context.stroke(ring.strokedPath(StrokeStyle(lineWidth: metrics.ringWidth)), with: .color(.black), lineWidth: 1) // Tick marks are left as a fun exercise for the reader. drawKnob(in: &context, metrics: metrics, radius: metrics.radius, rotation: 2 * .pi * hue + .pi) } private func drawValueRings(in context: inout GraphicsContext, metrics: PickerMetrics, hue: Double, saturation: Double, brightness: Double) { drawSemicircle(in: &context, metrics: metrics, start: .degrees(90), conicAngle: .degrees(0), stops: (0...1).map { Gradient.Stop(color: Color(hue: hue, saturation: 1 - Double($0), brightness: brightness), location: 0.25 + Double($0) * 0.5) }) drawSemicircle(in: &context, metrics: metrics, start: .degrees(270), conicAngle: .degrees(180), stops: (0...1).map { Gradient.Stop(color: Color(hue: hue, saturation: saturation, brightness: 1 - Double($0)), location: 0.25 + Double($0) * 0.5) }) drawKnob(in: &context, metrics: metrics, radius: metrics.innerRadius, rotation: .pi * (1 - saturation)) drawKnob(in: &context, metrics: metrics, radius: metrics.innerRadius, rotation: .pi * (1 - brightness) + .pi) } private func drawSemicircle(in context: inout GraphicsContext, metrics: PickerMetrics, start: Angle, conicAngle: Angle, stops: [Gradient.Stop]) { var path = Path() path.addArc(center: metrics.mid, radius: metrics.innerRadius - metrics.ringWidth / 2, startAngle: start, endAngle: start + .degrees(180), clockwise: false) let band = path.strokedPath(StrokeStyle(lineWidth: metrics.ringWidth)) context.fill(band, with: .conicGradient(Gradient(stops: stops), center: metrics.mid, angle: conicAngle)) context.stroke(band, with: .color(.black), lineWidth: 1) // Tick marks are left as a fun exercise for the reader. } private func drawCenter(in context: inout GraphicsContext, metrics: PickerMetrics, hue: Double, saturation: Double, brightness: Double) { let r = metrics.centerRadius let disc = Path(ellipseIn: CGRect(x: metrics.mid.x - r, y: metrics.mid.y - r, width: r * 2, height: r * 2)) context.fill(disc, with: .color(Color(hue: hue, saturation: saturation, brightness: brightness))) context.stroke(disc, with: .color(.black)) } private func drawKnob(in context: inout GraphicsContext, metrics: PickerMetrics, radius: CGFloat, rotation: CGFloat) { let lineWidth: CGFloat = 5 let inset: CGFloat = 3 + lineWidth / 2 var path = Path() path.move(to: CGPoint(x: 0, y: radius - metrics.ringWidth + inset)) path.addLine(to: CGPoint(x: 0, y: radius - inset)) path = path.applying(CGAffineTransform(rotationAngle: rotation)) path = path.applying(CGAffineTransform(translationX: metrics.mid.x, y: metrics.mid.y)) context.stroke(path, with: .color(.black.opacity(0.8)), style: StrokeStyle(lineWidth: lineWidth + 1, lineCap: .round)) context.stroke(path, with: .color(.white), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) } #Preview { @Previewable @State var model = ColorModel() HSBColorPicker(model: model) .frame(width: 320, height: 320) .padding() } -
7:21 - Hosting SwiftUI in AppKit
// Hosting SwiftUI in AppKit NSHostingView( rootView: HSBColorPicker(model: model) ) -
8:14 - Mix NSGestureRecognizer with SwiftUI
// Mix NSGestureRecognizer with SwiftUI import SwiftUI import AppKit @Observable @MainActor final class ColorModel { var hue: Double = 0.6 var saturation: Double = 1.0 var brightness: Double = 1.0 } struct ForceClickReset: NSGestureRecognizerRepresentable { var model: ColorModel func makeNSGestureRecognizer(context: Context) -> ForceClickGestureRecognizer { ForceClickGestureRecognizer() } func handleNSGestureRecognizerAction(_ recognizer: ForceClickGestureRecognizer, context: Context) { withAnimation { model.saturation = 1 model.brightness = 1 } } } final class ForceClickGestureRecognizer: NSGestureRecognizer { private var didActivate = false override func pressureChange(with event: NSEvent) { if event.stage >= 2 && !didActivate { didActivate = true state = .ended } } override func mouseDown(with event: NSEvent) { didActivate = false state = .possible } override func mouseUp(with event: NSEvent) { didActivate = false state = .possible } } -
9:42 - Adding ColorMenu to the Main Menu
// Adding ColorMenu to the Main Menu import AppKit import SwiftUI import Observation @Observable @MainActor final class ColorModel { var hue: Double = 0.6 var saturation: Double = 1.0 var brightness: Double = 1.0 } // Menu definition in SwiftUI. struct ColorMenu: View { var model: ColorModel private static let hues: [(name: String, hue: Double)] = [ ("Red", 0), ("Yellow", 0.17), ("Green", 0.33), ("Cyan", 0.5), ("Blue", 0.67), ("Purple", 0.83), ] var body: some View { Button("Full Intensity") { withAnimation { model.saturation = 1 model.brightness = 1 } } .keyboardShortcut(.upArrow, modifiers: [.command, .shift]) Button("Blackout") { withAnimation { model.brightness = 0 } } .keyboardShortcut(.downArrow, modifiers: [.command, .shift]) Divider() Button("Brighten") { withAnimation { model.brightness = min(1, model.brightness + 0.1) } } .keyboardShortcut(.upArrow, modifiers: .command) Button("Dim") { withAnimation { model.brightness = max(0, model.brightness - 0.1) } } .keyboardShortcut(.downArrow, modifiers: .command) Divider() Picker("Color", selection: Bindable(model).hue) { ForEach(Self.hues, id: \.hue) { entry in Label(entry.name, systemImage: "circle.fill") .tint(Color(hue: entry.hue, saturation: 1, brightness: 1)) .tag(entry.hue) } } .pickerStyle(.palette) } } @MainActor class AppDelegate: NSObject, NSApplicationDelegate { let colorModel = ColorModel() func setupMainMenu() { let mainMenu = NSMenu() let colorMenu = NSHostingMenu(rootView: ColorMenu(model: colorModel)) colorMenu.title = "Color" let colorMenuItem = NSMenuItem() colorMenuItem.submenu = colorMenu mainMenu.addItem(colorMenuItem) } } #Preview { Menu("Color") { ColorMenu(model: ColorModel()) }.padding() } -
11:36 - Adding SwiftUI scenes dynamically
// Adding SwiftUI scenes dynamically import AppKit import SwiftUI import Observation @MainActor class AppDelegate: NSObject, NSApplicationDelegate { let model = AppModel() var openSettingsAction: (() -> Void)? func applicationWillFinishLaunching(_ notification: Notification) { let scenes = NSHostingSceneRepresentation { LightMenuBarExtra(appModel: model) LightSettings(appModel: model) } NSApplication.shared.addSceneRepresentation(scenes) openSettingsAction = { scenes.environment.openSettings() } } @IBAction func openSettings(_ sender: Any?) { openSettingsAction?() } } @Observable @MainActor final class ColorModel { var hue: Double = 0.6 var saturation: Double = 1.0 var brightness: Double = 1.0 var color: Color { Color(hue: hue, saturation: saturation, brightness: brightness) } } @Observable @MainActor final class AppModel { var showMenuBarExtra: Bool = true var colorModel = ColorModel() var startUniverse: Int = 1 var numberOfPixels: Int = 50 var maxBrightness: Double = 1.0 var isConnected: Bool = false } struct LightMenuBarExtra: Scene { var appModel: AppModel var body: some Scene { MenuBarExtra("Light Mix", systemImage: "lightbulb.fill", isInserted: Bindable(appModel).showMenuBarExtra) { MenuBarContent(appModel: appModel) } .menuBarExtraStyle(.window) } } struct MenuBarContent: View { @Bindable var appModel: AppModel var body: some View { // TODO: Use HSBColorPicker VStack { RoundedRectangle(cornerRadius: 10) .fill(appModel.colorModel.color) .frame(height: 80) .overlay(RoundedRectangle(cornerRadius: 10).stroke(.black.opacity(0.1))) LabeledContent("Brightness") { Slider(value: $appModel.colorModel.brightness) .frame(width: 140) } } .padding() .frame(width: 280) } } struct LightSettings: Scene { var appModel: AppModel var body: some Scene { Settings { SettingsView(appModel: appModel) } } } struct SettingsView: View { var appModel: AppModel var body: some View { TabView { Tab("General", systemImage: "gearshape") { GeneralTab(appModel: appModel) } Tab("Output", systemImage: "antenna.radiowaves.left.and.right") { OutputTab(appModel: appModel) } Tab("About", systemImage: "info.circle") { AboutTab() } } .formStyle(.grouped) .scrollDisabled(true) .frame(width: 460) .fixedSize(horizontal: false, vertical: true) } } struct GeneralTab: View { @Bindable var appModel: AppModel var body: some View { Form { Section("Appearance") { Toggle("Show in Menu Bar", isOn: $appModel.showMenuBarExtra) } Section("DMX Configuration") { LabeledContent("Start Universe") { TextField("", value: $appModel.startUniverse, format: .number) .textFieldStyle(.roundedBorder) .frame(width: 80) } LabeledContent("Number of Pixels") { TextField("", value: $appModel.numberOfPixels, format: .number) .textFieldStyle(.roundedBorder) .frame(width: 80) } } } } } struct OutputTab: View { @Bindable var appModel: AppModel var body: some View { Form { Section("Output") { LabeledContent("Max Brightness") { HStack { Slider(value: $appModel.maxBrightness, in: 0...1) Text("\(Int((appModel.maxBrightness * 100).rounded()))%") .monospacedDigit() .foregroundStyle(.secondary) .frame(width: 40, alignment: .trailing) } } } } } } struct AboutTab: View { var body: some View { VStack(spacing: 16) { Image(systemName: "lightbulb.fill") .font(.system(size: 48)) .foregroundStyle(.yellow.gradient) Text("Light Mix") .font(.title2.bold()) Text("WWDC26 — Bring SwiftUI to your AppKit and UIKit App") .multilineTextAlignment(.center) .foregroundStyle(.secondary) } } } #Preview("Menu Bar") { MenuBarContent(appModel: AppModel()) } #Preview("Settings") { SettingsView(appModel: AppModel()) }
-
-
- 0:00 - Introduction
How SwiftUI is designed to work alongside existing AppKit and UIKit apps — already used in Logic Pro plugins, Xcode's Coding Assistant, and even AppKit controls like NSSlider, NSSwitch, and NSSegmentedControl. Previews the agenda using a sample lighting-control app: Observation in AppKit, hosting SwiftUI in AppKit, AppKit gestures in SwiftUI, SwiftUI in the main menu, and SwiftUI scenes in AppKit.
- 2:33 - Observation in AppKit
Replace manual needsDisplay invalidation with automatic updates by adopting @Observable on your model. AppKit (and UIKit) automatically track property reads in draw, updateConstraints, layout, updateLayer, and their NSViewController equivalents — so dependent views redraw when the model changes. Back-deployable to macOS 15 / iOS 18 via NSObservationTrackingEnabled / UIObservationTrackingEnabled, and on by default with the 2026 releases.
- 5:41 - Hosting SwiftUI in AppKit
When a new feature would require very different drawing or interaction code, it's a good moment to move to SwiftUI. Reimplement the color picker as a SwiftUI Canvas — an immediate-mode drawing API similar to drawRect, with withCGContext for reusing existing CoreGraphics code — then embed the SwiftUI view in the existing AppKit hierarchy with NSHostingView.
- 7:48 - AppKit gestures in SwiftUI
Reuse an existing NSGestureRecognizer subclass directly in a SwiftUI view via the new NSGestureRecognizerRepresentable protocol. Implement makeNSGestureRecognizer and handleNSGestureRecognizerAction, then attach it with the standard .gesture modifier — shown adding a Force Click to reset brightness and saturation alongside an existing drag gesture.
- 9:16 - SwiftUI in the main menu
Build a menu in SwiftUI as a regular View — Buttons with actions, keyboard shortcuts, and a palette-style Picker — then add it to the AppKit main menu using NSHostingMenu (an NSMenu subclass) wrapped in an NSMenuItem. Ensures features like the Force Click reset are also available to people on input devices that don't support force gestures.
- 11:30 - SwiftUI scenes in AppKit
Use NSHostingSceneRepresentation to add complete SwiftUI scenes to an app with the AppKit lifecycle. Add a MenuBarExtra for quick light controls, and a Settings scene with a Toggle that inserts or removes the MenuBarExtra dynamically — all from your existing NSApplicationDelegate.
- 13:04 - Next steps
Start using @Observable to keep models and NSViews in sync, consider SwiftUI for new views, reuse existing gestures via the representable protocol, and use SwiftUI for new scenes. There's no expectation that an app needs to be entirely SwiftUI to take advantage of it.