Chapters

Hide chapters

macOS Apprentice

First Edition · macOS 13 · Swift 5.7 · Xcode 14.2

Section II: Building With SwiftUI

Section 2: 6 chapters
Show chapters Hide chapters

Section III: Building With AppKit

Section 3: 6 chapters
Show chapters Hide chapters

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:

MovieTables menu
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:

Adding a Hosting View Controller.
Adding a Hosting View Controller.

With that in place, Control-drag from the Preferences… menu item to the new Hosting Controller and select Show from the popup options:

Creating a segue.
Creating a segue.

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:

  1. By default, a VStack aligns all its views centrally and close together. These arguments pin the views to the leading side and add more spacing between them.

  2. A Picker is a good interface choice when deciding between a fixed set of options. This initializer gives it a title and binds its selection to defaultViewMode.

  3. Inside the Picker, each option has its own Text display with a tag linking it back to its associated ViewMode case.

  4. SwiftUI offers several different pickerStyle options. A segmented control is a possibility that often works well for enumerations, but in this case the labels are long, so a radioGroup looks best.

  5. Set up a Slider to control the numeric highRating property. Bind its value to highRatingLimit and 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.

  6. The contents of the Slider shows its associated label: a title and the currently selected value, wrapped in an HStack.

  7. When showing a number in a Text view, you can supply a format. This is a FormatStyle that specifies how the number looks. The number style, when applied to a floating point number, has a precision method for setting various other options. This configuration shows the number with one digit after the decimal point.

  8. Finally, set the view frame and add some padding to inset the subviews from the edges.

Press Command-Option-P to resume the preview and you’ll see your Settings view:

Previewing the Settings view.
Previewing the 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:

  1. NSHostingController looks like an AppKit class with that NS prefix, 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.
  2. Define a class called SettingsHostingController that’s a subclass of NSHostingController. This line shows an error, but it’s a useful error, so leave it in place for now.
  3. Whenever you initialize a controller from a storyboard, it calls init(coder:), so this is required. The coder decodes the storyboard data to create the user interface.
  4. Since this is a subclass, call the inherited class’s init. For an NSHostingController, this requires the coder and the rootView. Create a SettingsView and 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:

Setting host controller class.
Setting host controller class.

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:

Setting hosting controller attributes.
Setting hosting controller attributes.

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.

Opening the Settings view.
Opening the Settings view.

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:

  1. Call viewDidLoad() for the super class and read the movies array, as before.

  2. Query UserDefaults.standard for an integer with the key defaultViewMode. UserDefaults is the settings storage mechanism and its standard property gives you access to the settings for this app. When setting up @AppStorage, you made ViewMode conform to Int, so that’s what it stored for this setting, and the key is the label you set for the @AppStorage property. integer(forKey:) always returns an Int, using 0 if the key doesn’t exist yet.

  3. Try to convert this integer into a ViewMode. You didn’t assign a rawValue directly to each ViewMode case, but by conforming it to Int, 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 give allMovies, which is perfect.

  4. If the integer is a valid rawValue for ViewMode, use the result to set viewMode. This ViewController property dictates the list of movies to show or search.

  5. Instead of setting visibleMovies to the full movies array, call searchMovies() 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:

Default view mode.
Default view 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?

  1. NotificationCenter is a mechanism for sending and receiving broadcast notifications. NotificationCenter.default is the notification center for the app and you add an observer to detect a specific type of notification.
  2. The forName argument takes an NSNotification.Name to tell it what to observe. You can create your own notifications and names, but this observer watches for UserDefaults to post a notification called UserDefaults.didChangeNotification. It does this whenever any UserDefault setting changes. This includes when @AppStorage has handled the change.
  3. 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 to nil.
  4. The third argument sets the queue that’ll run the attached code block. Since this change triggers an interface refresh, it must run on the main queue.
  5. 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?

  1. This time, you query UserDefaults.standard for a Double. The key is the one you set for the @AppStorage property. If this isn’t set — for example, when the user has just installed your app — you’ll get 0 as the value for newLimit. You’ll fix this momentarily.
  2. A Double can 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.
  3. Use the rounded value to change highRatingLimit.
  4. Since this setting only affects the display when the user is in highRating mode, check for this before calling searchMovies(). The changed highRatingLimit applies 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:

Changing the high rating limit setting.
Changing the high rating limit setting.

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 movies table
The 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:

Turning on alternating rows.
Turning on 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:

Setting the row size
Setting the row size

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:

Table with alternating and taller rows.
Table with alternating and taller rows.

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:

Searching SF Symbols.
Searching SF Symbols.

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:

  1. The rating for the movie, as supplied to the view.
  2. An Image view 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?

  1. Use an HStack to draw the five images side-by-side and close together.
  2. For each image, check if rating meets a certain threshold. Set the symbol variant to fill if the the rating is high enough or none if not. The none variant gives the original image, which isn’t multicolored.
  3. Set the size of the symbol using a font modifier. You can treat SF Symbols like text when it comes to sizing.
  4. 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:

Previewing the RatingView.
Previewing the RatingView.

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?

  1. Create a SwiftUI RatingView using the rating for the current movie.
  2. Initialize an NSHostingView, setting its rootView to the newly created RatingView.
  3. Return the NSHostingView. The method requires you to return an NSView. Until now, you used cellID to get an NSTableCellView from the storyboard, and populated its textField with a String. Now you’re returning an NSHostingView for the table to show in the rating column for this row.

Run the app and see how this looks:

SwiftUI rating view
SwiftUI rating view

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 UIHostingConfiguration specifically designed for use in table cells or collection view cells. Unusually for SwiftUI, there’s no AppKit equivalent. If Apple ever creates NSHostingConfiguration, it’ll replace the use of NSHostingView here, 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:

Build errors for macOS 11.0
Build errors for macOS 11.0

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 NSHostingController to embed a SwiftUI view in its own view controller for display in an AppKit window.
  • Both @AppStorage and UserDefaults handle user settings.
  • NSHostingView lets you insert a SwiftUI view in place of an NSView.
  • 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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.