-
Use SwiftUI with AppKit and UIKit
Discover how to incrementally adopt SwiftUI in your existing AppKit or UIKit app. We'll show you how to use the Observation framework to automatically update your views, integrate SwiftUI components into an existing view hierarchy, and bring gesture recognizers into SwiftUI. We'll also explore how to add complete SwiftUI scenes to your app without changing your overall architecture.
Chapters
- 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
Resources
- Updating views automatically with observation tracking
- Updating views automatically with observation tracking
Related Videos
WWDC26
WWDC25
WWDC22
WWDC21
-
Search this video…
Hello, I'm David Nadoba, an engineer on the UI Frameworks team. Today, I'm excited to talk to you about using SwiftUI with your existing AppKit or UIKit app. SwiftUI was designed from the beginning to work great alongside AppKit and UIKit. Just like Swift was designed to work together with Objective-C. This is ideal for incremental adoption without the need to rewrite everything or start from scratch. Apple has used this strategy throughout the years. Logic Pro is using SwiftUI for plugins like the Quantec Room Simulator, or the Beat Breaker plugin for both macOS and iPadOS. The Coding Assistant in Xcode was from start using SwiftUI and in Xcode 27 is expanding from the sidebar to the editor. Even without explicit adoption, most apps use SwiftUI implicitly nowadays. The UI Frameworks team used the new design as an opportunity to implement Controls in SwiftUI. Now, even if you use AppKit types like NSSlider, NSSwitch, and NSSegmentedControl, SwiftUI is used under the hood to render these views and more. Liquid Glass used in those controls and in other parts of the OS is also using SwiftUI to share large parts of the implementation across frameworks and platforms. In this video I will share how you can start adopting SwiftUI in more places too. I will focus on macOS, but the concepts apply to all other Apple platforms as well. First, I will show how you can use @Observable to automatically update your NSView, even before using any SwiftUI. Next, I will talk about when it is a good time to consider using SwiftUI and integrate it in an NSView hierarchy. I will also show you how you can add an NSGestureRecognizer directly to your SwiftUI View. Then, I will create menu items in SwiftUI and add them to the existing main menu. Finally, I will cover how you can use SwiftUI Scenes from your existing NSApplicationDelegate. Over the course of this presentation I will use a reduced version of an existing AppKit app that I made.
This app can control lights, like this addressable ring lamp on my desk.
It has controls to change the color, and run animations.
I'll walk you through how the sliders work and then demonstrate how the @Observable macro can help. The app uses a color picker that is similar to the system color panel or color well, but is displayed inline to keep the controls always within reach. The color is controlled through 3 sliders with a custom track gradient and knob. As I move the knob of one slider, it redraws itself with the newly selected color. At the same time, all other sliders also update accordingly.
A slider automatically redraws itself when its own value changes. In my case, the changed value also influences the appearance of the other sliders, but AppKit doesn't automatically redraw them.
I currently need to manually tell AppKit to redraw the saturation and brightness slider whenever the hue value changes. This is done by setting needsDisplay to true. This also needs to be implemented similarly for value changes of all the remaining sliders and other external changes. AppKit also supports automatic Observation of properties from @Observable types. Leverage this by adding the @Observable macro to a Swift class. All mutable variables then participate in the observation system.
The sliders are implemented as subclasses of NSSliderCell and customize the appearance by overriding certain draw methods like drawKnob. I only need to access the properties of my new ColorModel inside the drawKnob method. AppKit tracks each access and redraws whenever any accessed properties change. No need for manually setting needsDisplay to true anymore.
This works for any draw method that is called as part of NSView draw like the drawKnob or this drawBar method from NSSliderCell. NSView.draw(_:) is only one method that supports observation. updateConstraints(), layout(), updateLayer(), and NSViewController equivalents support Observation too.
UIKit has even more methods that extend beyond UIView and UIViewController to UIButton, UICollectionViewCell and more. You can back-deploy the integration to macOS 15 by adding NSObservationTrackingEnabled to your Info.plist. And to iOS 18 by adding UIObservationTrackingEnabled. It is enabled by default with the 2026 releases and later. For a closer look at Observation Tracking in UIKit watch "What's new in UIKit" from WWDC25. Okay, here it is in action. I will increase the brightness.
And change the hue to red.
Great, all sliders update and the new color is sent to the light over the network.
Adopting @Observable is a great start to get automatic updates in your NSView and NSViewController. It also makes it easier to move to SwiftUI when you want to implement something new. Speaking of something new, I have an idea for a different Color Picker design.
Hue starts with red, progresses through all the colors, and returns back to red. I would like to represent this as a circular slider.
I can represent Saturation and Brightness as two Semicircles inside the outer hue ring. Right in the middle I want to draw a preview of the resulting color as a circle. The whole drawing code and interaction will completely change, so this is a great time to move to SwiftUI. I can reuse the same @Observable ColorModel from the previous NSSlider based Color Picker. In the view's body I use the Canvas view, which gives me access to an immediate mode drawing API. Canvas is very similar to drawRect in AppKit or UIKit. Each redraw calls your closure with a fresh GraphicsContext, and you issue draw commands like strokes, fills, transforms and filters, directly against it. You can also reuse your existing CoreGraphics drawing code in SwiftUI by calling the withCGContext API. For an introduction to Canvas, watch "Add rich graphics to your SwiftUI app" from WWDC21.
If you want to know how you can combine SwiftUI with your own Metal Shaders watch "Compose advanced graphics effects with SwiftUI" from WWDC26. I still have a lot of places where the color picker is embedded in an NSView hierarchy. I can wrap my SwiftUI view in an NSHostingView, which is a subclass of NSView.
Because I have already moved my model to @Observable, this is really all I need to do. For an in-depth tour of NSHostingView and related types, watch "Use SwiftUI with AppKit" and "Use SwiftUI with UIKit" from WWDC 2022. Before I show you this new Color Picker in action, I want to add one more feature. I want to quickly reset the Brightness and Saturation to 100% with a single force click, which is a firm press on the trackpad. I already have an NSGestureRecognizer for this that I use in other parts of the app. I can bring this to a new SwiftUI View using NSGestureRecognizerRepresentable.
I start by creating a new struct that conforms to the NSGestureRecognizerRepresentable protocol. In makeNSGestureRecognizer I initialize and return my NSGestureRecognizer subclass. ForceClickGestureRecognizer is the type that I use in other parts of my app. It recognizes when the pressure stage 2 is reached, which indicates that enough pressure has been applied to trigger a force click.
handleNSGestureRecognizerAction is called when the gesture is recognized. This is the right place to reset the saturation and brightness to 100%. Back in the HSBColorPicker SwiftUI view, I can now add this gesture with the .gesture modifier, just like a SwiftUI Gesture.
The ForceClickReset gesture works together with the existing drag gesture without any other changes. SwiftUI also comes with more representable protocols like NSViewRepresentable that allows you to embed NSViews into your SwiftUI views. A Force Click is not possible with all input devices, like the Magic Mouse or the trackpad of the MacBook Neo. To make sure everyone can take advantage of this shortcut, I need to add a different way to access this feature. In this case I will add a menu item with a keyboard shortcut. My app is using AppKit's NSMenu for the main menu. I will explain how to add the new menu item using SwiftUI. I start by creating a new struct that conforms to the View protocol. It has access to the shared ColorModel. In the view's body I create a Button with a label and an action closure which resets the brightness and saturation to 100%.
Wrapping the modification in withAnimation makes SwiftUI animate the change.
To give quick access, I am adding a keyboardShortcut. I have also added a Picker with the paletteStyle, to precisely select common colors.
I now need to add this SwiftUI View to the main menu.
I initialize an NSHostingMenu with the ColorMenu view for that. NSHostingMenu is a subclass of NSMenu and therefore has properties like the title to configure the menu. All that is left to do is to create an NSMenuItem, set that colorMenu as its submenu, and add that item to the mainMenu. Now it is time to try it out. I will turn it on.
And circle through all the hues to green.
I will press the Keyboard shortcut to decrease the brightness a couple times.
And then use the menu item to turn it off completely.
When I force click, my NSGestureRecognizer resets the brightness.
I incrementally added this custom SwiftUI control to my app.
The rest of my AppKit app continues to work, just like it did before. As a final step, here is how you can bring complete SwiftUI Scenes to your app using your existing app delegate. I always wanted to give people quick access to change the color or brightness of their lights. For this, I can add a menu bar extra item. SwiftUI's MenuBarExtra scene makes this possible with just a few lines. NSHostingSceneRepresentation wraps a SwiftUI scene and allows it to be added dynamically from an existing AppKit app.
A good place to add a scene is applicationWillFinishLaunching in your NSApplicationDelegate. Call addSceneRepresentation with your scenes, and SwiftUI will do the rest.
If you have a MenuBarExtra scene, it is also a good idea to make it possible for people to remove and insert it again. A Settings scene is the perfect place to add a Toggle that controls whether the MenuBarExtra scene is inserted. NSHostingSceneRepresentation has an environment property that exposes the openSettings() action.
It can be used from an @IBAction to open the settings window programmatically.
I'm opening the settings from the apps main menu.
And enable the menu bar extra item.
Let me quickly open the color picker.
And turn the light on one last time.
To learn more about SwiftUI scenes, watch "Bring multiple windows to your SwiftUI app" from WWDC22. I have shown how you can mix SwiftUI and AppKit in different ways. The right way to combine them depends on your app and the problem you are solving.
All APIs I have talked about today are available already on the 2026 releases or earlier. A great first step is to try out @Observable to keep your model and NSViews automatically in sync and make the transition to SwiftUI seamless. Consider SwiftUI when you implement a new component or rewrite an existing one.
Add your existing gesture recognizer subclasses to SwiftUI views. Start with SwiftUI for new scenes even in your existing apps. And remember, there are no expectations that an app needs to be entirely SwiftUI in order to take advantage of it. Thank you for watching and thank you for building great apps!
-
-
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.