Discover how the Shortcuts app uses both SwiftUI and AppKit to create a top-tier experience on macOS. Follow along with the Shortcuts team as we explore how you can host SwiftUI views in AppKit code, handle layout and sizing, participate in the responder chain, enable navigational focus, and more. We'll also show you how to host AppKit views, helping you migrate existing code into a SwiftUI layout within your app.
♪ instrumental hip hop music ♪ ♪ Welcome to "Use SwiftUI with AppKit." I'm Ian, an engineer working on Shortcuts.
In macOS Monterey, Shortcuts came to macOS.
Shortcuts uses a lot of SwiftUI on the Mac.
SwiftUI helps customize the experience for the platform, while sharing common views with the apps on iOS and watchOS.
In this video, I'm going to show how you can start adopting SwiftUI in your Mac app, by looking at some examples from Shortcuts.
First, I'll show you an example of how to host SwiftUI views in your app, and then talk about how to pass data between AppKit and SwiftUI.
I'll also cover hosting SwiftUI views in the cells of a collection or table view, how to handle layout and sizing of SwiftUI views when they are embedded in AppKit, how to make your SwiftUI views participate in the responder chain and be focusable, and, finally, how to host an AppKit view in SwiftUI.
Alright, I'll start with how to host SwiftUI in AppKit.
In Shortcuts, the main window contains an AppKit split view controller and the sidebar on the left is written using SwiftUI.
The sidebar view is implemented as a SwiftUI List, and the list shows sections with rows for all of the places you can navigate to in the app.
The view keeps track of which item is selected, through the selected item binding.
The possible items that can be selected are represented as cases in the SidebarItem type.
In this case, since there's a split view controller already.
To host this sidebar view, we use a class from SwiftUI called NSHostingController.
The SwiftUI sidebar view is passed in as the root view of that hosting controller.
Since a hosting controller can be used like any other view controller, here, we configure it as a splitViewItem and add that to the splitViewController.
Now the sidebar is hosted in the split view, but for it to work when the selection changes, the right side of the split view needs to show a different page.
Currently, the selected item state only exists within SwiftUI.
What we need to do is move that to a place that can be shared between the split view and the sidebar.
A good way to do this is to create a model object that can be stored outside of SwiftUI and contain the state that needs to be shared.
I'll call this object the SelectionModel.
Now, the sidebar can still read and write the state in the SelectionModel.
In code, the SelectionModel is a class that conforms to ObservableObject.
Being an observable object lets SwiftUI reload the view when the state stored in the model changes.
It stores which sidebar item is currently selected.
This property is published so that the SwiftUI sidebar view can update when the selected item changes.
Whenever someone changes the selection in the sidebar, the model can show a new page in the detail view.
Now that I've covered how to host SwiftUI in AppKit, let's move on to collection and table cells.
When bringing Shortcuts from other platforms to macOS, there was already an iconic SwiftUI view built to display a shortcut in a collection view cell or a Home Screen widget.
On macOS, these same views are displayed in the cells of an NSCollectionView.
In a collection or table view with lots of items, each cell view is recycled as you scroll, showing different content over time.
To make sure the cell reuse is performant, you need to avoid adding and removing subviews from the cells as the user scrolls.
When displaying a SwiftUI view in each cell, use a single hosting view and update it with a different root view when the cell's content needs to change.
Here's all you need to build a collection view cell to host SwiftUI.
In the example here, I'm building the cell that displays a shortcut view.
Each cell contains an NSHostingView to host SwiftUI.
Since cells are created before they are configured with any content, this will start off as nil, and will be set the first time a shortcut is ready to be displayed.
The displayShortcut method is called by the data source when configuring the cell to display a shortcut.
This method creates a SwiftUI ShortcutView.
Then, if there's already a hostingView, the rootView of that hostingView is set to the new view.
Otherwise, if it's the first time, a newHostingView is created and added as a subview of the cell.
Here's the lifecycle of the cell that's hosting SwiftUI.
First, the cell is initialized and it starts with no subviews, since there is not a shortcut to display yet.
The first time displayShortcut is called, the hostingView is created with the shortcutView to display.
This creates a SwiftUI view hierarchy, containing a VStack, an image, a spacer, and two text views.
If this cell is then scrolled off screen, it will be potentially dequeued by the system and need to show a different shortcut.
When this happens, a new ShortcutView is created and given to the HostingView.
Since the HostingView was already displaying a different shortcut view, it will reuse the overall structure of the view, including the VStack and the spacer, and only update the image, text, and background that changed.
Alright, next, let's talk about layout and sizing.
Hosting controllers and hosting views have intrinsic sizes based on the SwiftUI view's ideal width and height.
SwiftUI automatically creates and updates Auto Layout constraints, which the AppKit layout system uses to size the view appropriately.
Views are also flexible, meaning they support a variety of sizes, between a minimum and a maximum.
SwiftUI creates constraints for these as well.
When embedding SwiftUI hosting views in your hierarchy, you should apply your own Auto Layout constraints to the superview or to other adjacent views.
Using the frame modifier or other SwiftUI layout will result in an update to the constraints that are created, such as overriding the width to be a fixed size.
Since windows can be resized by the user, they have a minimum and a maximum size.
When HostingViews are set as the top-level contentView of a window, SwiftUI will automatically update that window's minimum and maximum size based on the content being displayed.
And this lets windows be resizable either vertically, horizontally, or both, depending on the content.
SwiftUI views, placed in hosting controllers, also are sized based on the content when presented modally.
For example, you can easily place SwiftUI views into an AppKit popover, by presenting a hosting controller using the popover presentation API on NSViewController, as shown here.
You can also present SwiftUI views as sheets, using the presentAsSheet method.
And finally, for a modal window, you can use the presentAsModalWindow method to present a window that blocks interaction until closed.
The window is sized to fit the content.
In macOS Ventura, there are new APIs on NSHostingView and NSHostingController that allow you to customize the constraints that are automatically added.
By default, hosting controllers and views create constraints for the minimum size, intrinsic size, and maximum size.
You may want to disable some of these for performance reasons if you want the view to always be flexibly sized, or the constraints are already added to surrounding views in AppKit.
For hosting controllers, to let the ideal size of the view determine the preferred content size, you can enable the preferredContentSize option.
When you start adding SwiftUI views to your app, it's important that they take part in the responder chain and focus system just like other views in your app.
In Shortcuts, our editor is implemented as a SwiftUI View.
But the editor needs to handle menu bar commands defined in the main menu, which is implemented in AppKit.
These commands include cut, copy, paste, and others.
We implemented a few of our own custom menu items as well, for moving actions up and down.
In AppKit, your view hierarchy makes up a chain of views called "the responder chain." The focused responder is called the first responder.
When a menu item is selected, the selector for that item is sent to the first responder.
But if the first responder doesn't respond to that selector, then the selector is sent to each next responder, until something handles the selector, or it reaches the app.
The equivalent to the first responder in SwiftUI is the focused view.
Focusable SwiftUI views can respond to keyboard input and handle selectors sent to the responder chain.
Some views like text fields are already focusable, but you can use the focusable modifier to make other views focusable too.
SwiftUI has a few modifiers to handle common commands, such as copy, cut, and paste.
These pass values in and out of the pasteboard, and it's an easy way to let people transfer data in and out of your app.
The shortcuts editor uses the onMoveCommand and onExit command modifiers to handle the arrow keys and escape keys.
The onCommand modifier can be used to handle any of the common selectors from AppKit or your own custom selectors defined in your app.
Here, we handle the selectAll command from AppKit and the moveActionUp and moveActionDown commands defined in the Shortcuts app.
When testing focus and keyboard navigability in your app, make sure to open Keyboard System Settings and test with Full Keyboard Navigation turned both on and off, since many controls are only focusable when that's enabled.
There's a lot more you can do to make your app work great with the keyboard.
For example, there are APIs such as FocusState and the focused modifier that let you programmatically change which view is focused.
To learn more about focus and the keyboard, you should go watch the "Direct and reflect focus in SwiftUI" video.
Finally, let's talk about hosting AppKit views in SwiftUI.
There are some instances where Shortcuts is hosting AppKit views inside of a SwiftUI layout, and you may need to host AppKit views, too, as you adopt SwiftUI in your app.
One example is inside of the SwiftUI shortcuts editor, where there's an embedded AppleScript editor view, which is an AppKit control shared with a few other system apps on macOS.
SwiftUI provides two representable protocols that allow AppKit views and view controllers to be embedded within a SwiftUI view hierarchy.
Like SwiftUI views, representables are descriptions for how to create and update AppKit views.
Since many classes in AppKit have delegates, observers, or rely on KVO or notifications to be observed, the protocols also include an optional coordinator object that you can implement to accompany your view or view controller.
Here's the lifecycle of the hosted object and its coordinator.
We start with the hosted view being initialized.
This happens when the view is about to be displayed for the first time.
The first thing SwiftUI does during initialization is make the coordinator.
This is optional, but you can define your own type and return it from makeCoordinator if you need it for delegation or state management.
A single instance of the coordinator will stay around for the lifetime of the view.
Second, either the makeNSView or makeNSViewController method is called.
This is where you describe to SwiftUI how to create a new instance of your view.
The context contains the coordinator that was just made, if any, so here's a good place to assign the coordinator as the view's delegate or other type of observer.
Once the view has been created, the update view method will be called whenever the SwiftUI state or environment changes.
Here, it's your responsibility to update any properties or state stored in the AppKit view to keep it in sync with the surrounding SwiftUI state and environment.
The update method can be called often, so the changes you make to the view should be as minimal as possible.
You should check for what has changed and only reload the affected part of the view when changes are made.
When SwiftUI is done displaying the hosted view, it will be dismantled.
The hosted view and coordinator will both be deallocated.
Before these are deallocated, the representable protocols give you an optional method to implement, where you can clean up state if needed.
Alright, now that you know the lifecycle and are familiar with the representable protocols, I'm going to show you how Shortcuts hosts the custom script editor view in the app.
The script editor is an NSView called ScriptEditorView.
The code that's written in the editor can be accessed and modified through the sourceCode property, and the view can be disabled to prevent changes from being made.
The script editor also has a delegate, which is notified any time someone modifies the source code.
When hosting an AppKit view, first think about where the view will be placed in SwiftUI, and what data needs to be passed in and out.
In Shortcuts, this view is placed into a container view next to the compile button.
The compile button's handler needs to access the source code that's entered into the view.
The source code is stored in SwiftUI using the State property wrapper.
The representable will need to both read and write to this state.
To build the representable, start by creating a type that conforms to NSViewRepresentable, since it will host an NSView.
Add properties for each thing that needs to be configurable from SwiftUI.
For the source code a binding is used, which will read and write the state stored in SwiftUI.
The first method you need to implement is makeNSView.
Here is where you describe how to create a new instance of the view, and where you should do any one-time setup that's required.
Here, the delegate is set to the coordinator.
I'll talk about the coordinator more in a bit.
Next, implement updateNSView.
This will be called when either the sourceCode changes, or when the SwiftUI environment changes.
Since the script editor does a bunch of work when the sourceCode property is set, we compare the value already in the view, and only set the property if it changes to avoid unnecessary work.
The context passed to updateNSView contains the SwiftUI environment.
The isEnabled environment key is passed to the isEditable property on the script editor, so editing is disabled if the rest of the SwiftUI view hierarchy is.
Whenever someone modifies the source code in the view, the source code binding needs to capture the new value.
To do this, we'll build a coordinator that conforms to the ScriptEditorViewDelegate.
The coordinator will store the representable value, which contains the source code binding that it needs to update.
And in the sourceCodeDidChange method, the binding is set to the new string value from the view.
Finally, we need to tell the SwiftUI representable how to make and update the coordinator.
First, you need to implement the makeCoordinator method to create a new coordinator.
Coordinators have the same lifetime as the hosted view, and like hosted views, properties you add to the coordinator need to remain up to date as the representable changes.
Since updateNSView is called when the values stored in the representable change, here, the representable property on the coordinator is updated.
Now that you know how to add AppKit into SwiftUI, and also add SwiftUI into AppKit, you should start integrating SwiftUI into your app.
A great place to start is in your sidebar, or table and collection view cells.
Make sure your views are sizing themselves correctly and handling common commands and focus.
Thanks for your time, and I can't wait to see what you build ♪
let splitViewController =NSSplitViewController()
let sidebar =NSHostingController(rootView: SidebarView(...))
let splitViewItem =NSSplitViewItem(viewController: sidebar)