18.
Using SwiftUI in AppKit
Written by Sarah Reichelt
In the previous chapter, you went back to your SwiftUI app and used AppKit to add some features that aren’t available in SwiftUI.
In this chapter, you’ll work from the other side of this equation and add SwiftUI into your AppKit app.
When coding a SwiftUI app, there are things you can’t do without AppKit. In an app that starts with AppKit, there are almost no features you can’t add — the new SwiftUI Charts library is the only thing I can think of — but there are cases where SwiftUI can make your life easier or make developing a certain feature quicker.
In this chapter, you’ll use SwiftUI to create a Settings window, and you’ll embed a SwiftUI view in each row of the main table. Then, you’ll examine when this is the best way to build an app.
Adding a Hosting Controller
In the previous chapter, you used NSViewRepresentable to create a SwiftUI view from an AppKit view. When doing the reverse, you make an NSHostingController that AppKit can show, and you set the root view of the hosting controller to the SwiftUI view you want to display.
Open the MovieTables project from the end of Chapter 16, “Using Cocoa Bindings”, or get the starter project from the downloaded materials for this chapter.
Run the app and take a look at the MovieTables menu:
There’s a Settings… menu item, but you haven’t linked it to a window, so your app disables the item.
Open Main.storyboard and, in Application Scene, open the MovieTables menu. You’re probably surprised to see a Preferences… menu item there. Until macOS Ventura, Settings were called Preferences. Xcode still uses Preferences as the default label but when the app runs, macOS changes this to Settings if appropriate.
Open the Library using the + button in the toolbar or by pressing Shift-Command-L. Search for host and drag a Hosting View Controller into the storyboard, near the menu bar:
With that in place, Control-drag from the Preferences… menu item to the new Hosting Controller and select Show from the popup options:
This creates a segue so that choosing Settings… opens the hosting controller. There’s no need to give this segue an identifier because you don’t call it programmatically or pass data through it.
Next, you’ll make a SwiftUI view for the hosting controller to host.
Creating the Settings View
The Settings view will be pure SwiftUI. Select EditViewController.swift in the Project navigator and press Command-N to add a file. Select SwiftUI View and set the name to SettingsView.swift.
What settings will this control?
Open ViewController.swift and jump to searchMovies(). There are two options that could usefully appear in Settings. The first is the default viewMode. Right now, the app starts up showing all the movies, but some users may prefer a different starting mode.
The second option is the limit for showing highest rated movies. It’s set to 9.0, but your users may be more or less picky about movies.
Back in SettingsView.swift, add these two @AppStorage properties to SettingsView:
@AppStorage("defaultViewMode") var defaultViewMode = ViewMode.allMovies
@AppStorage("highRatingLimit") var highRatingLimit = 9.0
These lines create properties that the app stores and reloads, using SwiftUI’s @AppStorage wrapper. The first one gives an error because this storage mechanism can only store basic data types, and it doesn’t know how to store ViewMode cases.
You’ll fix this by changing ViewMode to conform to a type that @AppStorage can handle.
Open ViewMode.swift and change the enum declaration line to:
enum ViewMode: Int {
This gives every case in the enumeration its own integer rawValue. @AppStorage has no trouble handling an Int and can now store properties of this type.
Back in SettingsView.swift, it’s time to add the interface to go with the properties.
Coding the Interface
Replace the default contents of body with:
// 1
VStack(alignment: .leading, spacing: 30) {
// 2
Picker("Default View Mode:", selection: $defaultViewMode) {
// 3
Text("All Movies").tag(ViewMode.allMovies)
Text("Favorites Only").tag(ViewMode.favsOnly)
Text("Highest Rated").tag(ViewMode.highRating)
}
// 4
.pickerStyle(.radioGroup)
// 5
Slider(value: $highRatingLimit, in: 7.5 ... 10.0) {
// 6
HStack {
Text("High Rating Limit:")
// 7
Text(highRatingLimit, format: .number.precision(.fractionLength(1)))
}
}
}
// 8
.frame(width: 300, height: 120)
.padding()
You’ve had quite a bit of experience with SwiftUI views by now but there are some new features here:
-
By default, a
VStackaligns all its views centrally and close together. These arguments pin the views to the leading side and add more spacing between them. -
A
Pickeris a good interface choice when deciding between a fixed set of options. This initializer gives it a title and binds its selection todefaultViewMode. -
Inside the
Picker, each option has its ownTextdisplay with ataglinking it back to its associatedViewModecase. -
SwiftUI offers several different
pickerStyleoptions. A segmented control is a possibility that often works well for enumerations, but in this case the labels are long, so aradioGrouplooks best. -
Set up a
Sliderto control the numerichighRatingproperty. Bind its value tohighRatingLimitand set its limits using a closed range. The data file only includes movies with a rating of at least 7.5, so there’s no need to let the slider go any lower. -
The contents of the
Slidershows its associated label: a title and the currently selected value, wrapped in anHStack. -
When showing a number in a
Textview, you can supply aformat. This is aFormatStylethat specifies how the number looks. Thenumberstyle, when applied to a floating point number, has aprecisionmethod for setting various other options. This configuration shows the number with one digit after the decimal point. -
Finally, set the view
frameand add somepaddingto inset the subviews from the edges.
Press Command-Option-P to resume the preview and you’ll see your Settings view:
Now that you have a view to show, you can tell the hosting controller to show it.
Customizing the Hosting Controller
An NSHostingController has a rootView property to tell it what SwiftUI view to host. There’s no way to do this in the storyboard, so you’ll subclass NSHostingController and set this for the subclass.
Select EditViewController.swift in the Project navigator and use your preferred method to add a new file. Create a new Swift file called SettingsHostingController.swift.
Replace the contents of the new file with:
// 1
import SwiftUI
// 2
class SettingsHostingController: NSHostingController {
// 3
required init?(coder: NSCoder) {
// 4
super.init(coder: coder, rootView: SettingsView())
}
}
Working through these lines:
-
NSHostingControllerlooks like an AppKit class with thatNSprefix, but it’s defined in the SwiftUI library, so import that. The SwiftUI import also brings in AppKit and Foundation, so there’s no need to import them separately. - Define a class called
SettingsHostingControllerthat’s a subclass ofNSHostingController. This line shows an error, but it’s a useful error, so leave it in place for now. - Whenever you initialize a controller from a storyboard, it calls
init(coder:), so this isrequired. The coder decodes the storyboard data to create the user interface. - Since this is a subclass, call the inherited class’s
init. For anNSHostingController, this requires thecoderand therootView. Create aSettingsViewand pass it to the hosting controller.
That’s quite a chunk of code, but now you can go back to the storyboard and finish the job.
Open Main.storyboard and select the Hosting Controller. Press Command-Option-4 to show the Identity inspector. Change Class to SettingsHostingController and press Return:
This is the reason for the error in SettingsHostingController.swift. If you’d written that correctly, Xcode wouldn’t have shown anything in the Class popup menu. You could have typed it in manually, or copied and pasted, but this is a more reliable way of getting it exactly right and avoiding a crash.
While you’re in the storyboard, open the Attributes inspector and set the hosting controller’s Title to Settings and set Presentation to Single so only one copy of this window is ever open at a time:
Now that you’ve set the class, you can go back to SettingsHostingController.swift to fix the error. Change the class declaration line to:
class SettingsHostingController: NSHostingController<SettingsView> {
The name inside the angle brackets tells SettingsHostingController the type of the SwiftUI view it’s to host.
You’re ready for a test run now. Press Command-R to run the app and use MovieTables ▸ Settings… to open the Settings window.
Change the options, quit the app and reopen the Settings window. Your changes are all there. The next step is to make the AppKit ViewController use them.
Using a Setting in AppKit
The Settings window stores two different settings using the @AppStorage property wrapper. This is a SwiftUI wrapper built on top of AppKit’s UserDefaults system, so you can access them this way.
The first setting only matters when the app starts. It allows the user to set the default view mode — the one that appears initially.
Open ViewController.swift, find viewDidLoad() and replace all its code with:
// 1
super.viewDidLoad()
movies = dataStore.readStoredData()
// 2
let defaultViewModeSetting = UserDefaults.standard
.integer(forKey: "defaultViewMode")
// 3
if let defaultViewMode = ViewMode(rawValue: defaultViewModeSetting) {
// 4
viewMode = defaultViewMode
}
// 5
addSortDescriptors()
searchMovies()
showMovieCount()
Stepping through this:
-
Call
viewDidLoad()for the super class and read themoviesarray, as before. -
Query
UserDefaults.standardfor an integer with the keydefaultViewMode.UserDefaultsis the settings storage mechanism and itsstandardproperty gives you access to the settings for this app. When setting up@AppStorage, you madeViewModeconform toInt, so that’s what it stored for this setting, and the key is the label you set for the@AppStorageproperty.integer(forKey:)always returns anInt, using0if the key doesn’t exist yet. -
Try to convert this integer into a
ViewMode. You didn’t assign arawValuedirectly to eachViewModecase, but by conforming it toInt, you set the first case to 0 by default, and this increments for each subsequent case. This means that a stored value of 0 or a missing value both giveallMovies, which is perfect. -
If the integer is a valid
rawValueforViewMode, use the result to setviewMode. ThisViewControllerproperty dictates the list of movies to show or search. -
Instead of setting
visibleMoviesto the fullmoviesarray, callsearchMovies()to apply the default view mode setting. This reloads the table, so make sure to add the sort descriptors first. Then, update the counter.
Time to test this. Run the app and make sure you have some favorites marked. Use Settings to change the default view mode to Favorites Only. Quit the app and run it again to confirm that it defaults to the correct mode:
The default view mode setting only matters when the app starts. After that, the user can change the default setting without needing to trigger a display change. This isn’t the case for the other setting.
Watching for a Change
Whenever the user changes the high rating limit, they expect the new setting to take effect immediately. You can’t ask them to quit the app and restart to implement their change.
In SwiftUI, this isn’t a problem. The @AppStorage property wrapper handles this sort of thing for you and changes to any @AppStorage property triggers updates to the display. With UserDefaults, you have to do more of the work.
To detect when the user edits this setting, you’ll watch for a notification.
Still in ViewController.swift, add this to the end of viewDidLoad(), after all the other code:
// 1
NotificationCenter.default.addObserver(
// 2
forName: UserDefaults.didChangeNotification,
// 3
object: nil,
// 4
queue: .main) { _ in
// 5
// process notification
}
What does this do?
-
NotificationCenteris a mechanism for sending and receiving broadcast notifications.NotificationCenter.defaultis the notification center for the app and you add an observer to detect a specific type of notification. - The
forNameargument takes anNSNotification.Nameto tell it what to observe. You can create your own notifications and names, but this observer watches forUserDefaultsto post a notification calledUserDefaults.didChangeNotification. It does this whenever anyUserDefaultsetting changes. This includes when@AppStoragehas handled the change. - Some notifications come with an attached
object, usually the notification sender. You’re only interested in knowing that there was a change, so you can ignore this by setting it tonil. - The third argument sets the
queuethat’ll run the attached code block. Since this change triggers an interface refresh, it must run on the main queue. - The code to run when any notification arrives goes in this closure.
That’s a complex sequence of events, but to summarize:
UserDefaults broadcasts a predefined notification whenever anything changes. Any object registered as an observer to that event receives the notification.
Now that you’re detecting changes, you can take appropriate action.
Processing the New Setting
Add a new ViewController method:
func userDefaultsChanged() {
// 1
let newLimit = UserDefaults.standard.double(forKey: "highRatingLimit")
// 2
let roundedLimit = round(newLimit * 10) / 10
// 3
highRatingLimit = roundedLimit
// 4
if viewMode == .highRating {
searchMovies()
}
}
You’ll call this from the observer, but what does it do?
- This time, you query
UserDefaults.standardfor aDouble. The key is the one you set for the@AppStorageproperty. If this isn’t set — for example, when the user has just installed your app — you’ll get0as the value fornewLimit. You’ll fix this momentarily. - A
Doublecan have a lot of digits after the decimal point and this can cause problems. If you thought you set a limit of 8.0 but it was actually 8.00000001, then you wouldn’t see movies with a rating of 8.0. To solve this, round the number to one decimal place. You can do this for any number by multiplying it by 10, rounding it to get rid of any digits after the decimal point, and then dividing by 10. - Use the rounded value to change
highRatingLimit. - Since this setting only affects the display when the user is in
highRatingmode, check for this before callingsearchMovies(). The changedhighRatingLimitapplies next time the user selects this view mode, even if it isn’t used now.
This gives you the method for processing changes, so now make the observer call it.
Inside the addObserver block, replace // process notification with:
self.userDefaultsChanged()
This calls userDefaultsChanged() but why does it have a .self prefix? The method is in ViewController, but any object can receive notifications. Using self lets the block capture the current value of self, which is ViewController. That way, the block always knows where to find the method.
You don’t need to remember when to add self — if you leave it out when it’s needed, Xcode shows an error and suggests fixes.
To handle the case where the user hasn’t yet set a value for the high limit, go back to viewDidLoad() and add the following right before the call to NotificationCenter:
UserDefaults.standard.register(defaults: ["highRatingLimit": 9.0])
UserDefaults provides a mechanism where you provide a dictionary of default values. Any time you read a value that isn’t already set, it will look in this dictionary and return whatever it finds there.
Time to test this feature. Run the app and press Command-R to get to the Highest Rated Movies view. Sort by Rating and sort again if needed to show the lowest rated movies at the top.
Press Command-, to open Settings and start sliding the slider. The list updates as you drag:
That’s the end of the first SwiftUI addition to your AppKit app. Creating a window in SwiftUI is quicker and easier than doing it in AppKit, mostly because you don’t have to struggle with Auto Layout. And handling user settings takes a lot less code in SwiftUI.
Now on to something a bit different.
Jazzing Up the Table
Run the app if it isn’t still running and take a look at the main movies table:
The information is all there, but the display is a bit boring. The first thing you can do is apply alternate row colors. This makes a table more visually stimulating and easier to read.
Open Main.storyboard and select the table in View Controller Scene. Remember, you can Shift-right-click in the table area and select Movies Table View from the popup menu.
Press Command-Option-5 to show the Attributes inspector and check Alternating Rows:
With the default size settings, this makes the rows look too squashed together, so Command-Option-6 over to the Size inspector and set Row Size Style to Medium:
Leave Row Height empty and showing its placeholder of 24. The table works out the row height from the style.
Run the app and check out the improved look:
You can’t see as many movies in the same height window, but readability is more important.
That improves the table as a whole, now to add some sparkle to the rating column.
Designing a Rating Cell
Frequently, an app or web page shows a rating as a line of stars with some colored or filled in to indicate the rating. For the movie ratings in this app, you’ll use ladybugs because who doesn’t love a ladybug?
Actually, the real reason to use a ladybug is that SF Symbols has a multicolored ladybug icon with a filled version that shows colors and a plain version that’s only gray. A strip of these will look great as the rating display.
If you don’t already have the SF Symbols app, download it from Apple Developer. The Xcode Library lists all the symbols, but the app shows a lot more information.
Once you have it installed, launch the app and search for ladybug. You’ll see the plain and filled variants. Select ladybug.fill and click the paint brush tab in the right sidebar. The fourth version of the image is the Multicolor option, and that’s the one you’ll use:
The movies in the list have ratings between 7.5 and 10, so there’s no point in having the display account for ratings from 1 to 7.5. The display will show five multicolored ladybugs for a rating of 10 and five gray images for a rating of 7.5 with more colors appearing as the rating increases. Still in SF Symbols, select the plain ladybug and choose Secondary in the Color popup to see the gray version.
Now that you’ve worked out what to display, you can set up the SwiftUI view for it.
Creating the Rating View
Select SettingsView.swift in the Project navigator and then create a new SwiftUI View file called RatingView.swift. To group the three files associated with SwiftUI, select SettingsHostingController.swift, SettingsView.swift and RatingView.swift in the Project navigator. Right-click and choose New Group from Selection setting the name of the new group to SwiftUI. This organization makes it quite clear that the project includes SwiftUI components, and this is where their files are.
Open RatingView.swift and add these two properties:
// 1
let rating: Double
// 2
let ladybugImage = Image(systemName: "ladybug")
.symbolRenderingMode(.multicolor)
These provide:
- The rating for the movie, as supplied to the view.
- An
Imageview for the ladybug icons, using the SF Symbol name and pre-configured to use multicolor rendering.
This gives an error in the preview, but ignore it for now as you’ll set up multiple previews soon.
Replace the Text view in body with:
// 1
HStack(spacing: 2) {
// 2
ladybugImage.symbolVariant(rating >= 8 ? .fill : .none)
ladybugImage.symbolVariant(rating >= 8.5 ? .fill : .none)
ladybugImage.symbolVariant(rating >= 9 ? .fill : .none)
ladybugImage.symbolVariant(rating >= 9.5 ? .fill : .none)
ladybugImage.symbolVariant(rating >= 10 ? .fill : .none)
}
// 3
.font(.title3)
// 4
.foregroundColor(.secondary)
How does this show the rating?
- Use an
HStackto draw the five images side-by-side and close together. - For each image, check if
ratingmeets a certain threshold. Set the symbol variant tofillif the the rating is high enough ornoneif not. Thenonevariant gives the original image, which isn’t multicolored. - Set the size of the symbol using a
fontmodifier. You can treat SF Symbols like text when it comes to sizing. - Set the foreground color to the secondary system color. This only affects the plain variant.
To confirm this works for different ratings, you’ll add a range of previews.
In RatingView_Previews, replace the contents of previews with:
VStack {
RatingView(rating: 7.5)
RatingView(rating: 8)
RatingView(rating: 8.5)
RatingView(rating: 9)
RatingView(rating: 9.5)
RatingView(rating: 10)
}
Resume the preview to admire your work:
This does exactly as planned with multicolored ladybugs appearing as the rating increases. But how can you make this appear in the table?
Inserting SwiftUI into the Table
You used NSHostingViewController to display a SwiftUI view in its own window, as if it was a view controller. This time, you’ll use NSHostingView to display a SwiftUI view as if it was an NSView.
Open TableData.swift and, since you’ll need SwiftUI features, replace the import line at the top with:
import SwiftUI
In tableView(_:viewFor:row:), find the case "RatingColumn": section. Delete the two lines setting cellID and cellText for the rating column and replace them with:
// 1
let view = RatingView(rating: movie.rating)
// 2
let host = NSHostingView(rootView: view)
// 3
return host
What’s happening here?
- Create a SwiftUI
RatingViewusing the rating for the current movie. - Initialize an
NSHostingView, setting itsrootViewto the newly createdRatingView. - Return the
NSHostingView. The method requires you to return anNSView. Until now, you usedcellIDto get anNSTableCellViewfrom the storyboard, and populated itstextFieldwith a String. Now you’re returning anNSHostingViewfor the table to show in the rating column for this row.
Run the app and see how this looks:
When you look back at the screenshot of the original table, I think you’ll agree that this is a big improvement. There’s nothing here that you couldn’t have done in pure AppKit, but it would have taken a lot more code and effort.
Note: When integrating SwiftUI into an iOS app that uses UIKit, there’s a class called
UIHostingConfigurationspecifically designed for use in table cells or collection view cells. Unusually for SwiftUI, there’s no AppKit equivalent. If Apple ever createsNSHostingConfiguration, it’ll replace the use ofNSHostingViewhere, but for now, this is a perfectly functional system.
When Should You Start With AppKit?
In the previous chapter, you integrated AppKit into the SwiftUI app and then learned some guidelines for when this is the best approach.
This chapter covers the reverse process, so when is AppKit the best way to build an app?
There are a lot of AppKit apps out there, and you may come across one that you have to maintain. It’s always tempting to throw the whole thing out and start again using the newest technology, but that’s probably not a great idea. An established app has been well tested and debugged, with all the edge cases handled. You don’t want to lose all that.
In that circumstance, the best thing is to add SwiftUI incrementally. If you’re making a new window, consider using SwiftUI for it. Or maybe use SwiftUI for a single view. As you saw with the table, you can use NSHostingView anywhere you can use NSView, but you have to add it programmatically and not through the storyboard.
This lets you keep the proven parts of the AppKit app in place while you gradually move towards the convenience of SwiftUI.
If you’re creating a new app, then the guidance from the previous chapter applies: Apps that include long-form text editing or that display more than a thousand records in a list are best suited to AppKit.
Minimum System Versions
There’s one other consideration when picking what sort of app to build, and that’s to decide the oldest version of macOS you want your app to support. Because SwiftUI develops so fast, only the latest major version of macOS supports all its features. You can build SwiftUI apps for macOS 10.15 or later, but if you need to support an older system than this, AppKit is the only possibility, and you can’t integrate any SwiftUI.
Even if you only support macOS 10.15 and later, a lot of SwiftUI features won’t work. AppKit is more mature and stable — it still gets new features, but at a much slower rate, meaning that if you want to support old systems, you can do so without losing a lot of functionality.
To demonstrate this, select the project at the top of the Project navigator, choose the target and then the General tab. Set the Minimum Deployments popup to 11.0.
Go to Xcode’s Settings ▸ General and turn on Continue building after errors. This stops Xcode from giving up in despair midway through a flood of issues. :]
Press Shift-Command-K to clean the build folder and then Command-B to build. Use Command-5 to open the Issue navigator to view the list of problems:
Ignoring the two SwiftUI files, there are nine issues, most of which are easily worked around without changing the end result. The SwiftUI files use features that are only available in macOS 12.0 or later, so with minor tweaks, you could make this app available for macOS 12. After that, it would need more changes, particularly in RatingView, to make it work on macOS 11, but it is feasible.
Note: You may see more errors related to files that you didn’t write like MovieTables.swiftmodule. You can ignore them as they refer to previous builds.
In contrast to this, open the Snowman project from the last chapter. Building for macOS 11.0 or for macOS 12.0 gives at least 49 issues, and fixing them would require you to remove significant portions of the app.
Switch both apps back to macOS 13.0 and build again to get them to a working state.
In Summary
- To support old versions of macOS, use AppKit.
- For long-form text editing or for thousands of records, use AppKit.
- For existing AppKit apps, add SwiftUI gradually.
- For everything else, start with SwiftUI and include AppKit as needed.
Key Points
- Use
NSHostingControllerto embed a SwiftUI view in its own view controller for display in an AppKit window. - Both
@AppStorageandUserDefaultshandle user settings. -
NSHostingViewlets you insert a SwiftUI view in place of anNSView. - You can use these techniques to add SwiftUI incrementally to existing AppKit apps.
Where to Go From Here
At WWDC 2022, there was a video that’s relevant to this chapter and the previous one: Use SwiftUI with AppKit. Despite the title, it also covers using AppKit with SwiftUI.