Chapters

Hide chapters

macOS Apprentice

Second Edition · macOS 15 · Swift 5.9 · Xcode 16.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

17. Using AppKit in SwiftUI
Written by Sarah Reichelt

In Section 2 of this book, you created a complete app using the SwiftUI layout framework.

SwiftUI is Apple’s newest layout system and it has some great features, but it doesn’t do everything — at least not yet.

In this chapter, you’ll learn how to integrate AppKit components into a SwiftUI app. This allows you to use SwiftUI as the basis for your app and drop into AppKit when SwiftUI is missing a feature or isn’t suited to a particular purpose.

Showing a Word Definition

Open your Snowman project from the end of Chapter 10, “Adding Toolbars & Menus”, or use the starter project from the downloads for this chapter.

Run the app and play a few games to remind yourself what you built:

Starting app with some games played.
Starting app with some games played.

The game can throw up some unusual words, so it’d be nice to be able to find a definition for any that are new to you.

Dictionary.com is an online dictionary where you can look up word definitions.

You can provide the word in the web address so the user doesn’t have to type it. Test by opening this URL in your browser:

https://www.dictionary.com/browse/postlude

You add browse to the basic address and follow it with the word to look up.

You’ll add a new window to the app to display a web view with the Dictionary.com page for the game word.

The only problem is that SwiftUI doesn’t have a web view, so you’ll have to use the one in AppKit: WKWebView.

Adding a Lookup Button

The first step is to add a button to trigger this. Like the New Game button, it’ll only be visible and active for completed games, so it makes sense to put it beside that button.

Open GameView.swift and scroll to where you defined Button("New Game").

Right-click anywhere in the word Button and choose Embed in HStack from the popup menu:

Embed in HStack
Embed in HStack

Note: Unless the preview canvas is open, you won’t see this option in the menu. Press Command-Option-Return to open it and try again. The preview doesn’t have to be active, but it must be visible.

Select the two lines of code with the opacity and disabled modifiers. Press Command-Option-] to move them down a line, so they apply to the entire HStack, not only the “New Game” button.

Insert this inside the HStack, before the existing Button:

Button("Look Up Word") {
  // open lookup window
}

This adds a new button with a placeholder for its action.

And, to space the buttons further apart, replace HStack with:

HStack(spacing: 60)

Run the app and finish a game to see your new button:

Lookup button
Lookup button

It doesn’t do anything yet, but it’s in place. There are several more steps to go before your app shows a web view in a new window.

Creating a Web View

To show any AppKit view in a SwiftUI app, you first convert it into a SwiftUI view. The NSViewRepresentable protocol provides the means for doing this.

Start by making a new file. Select Views in the Project navigator to position the new file. Press Command-N and add a macOS ▸ Swift File called WebView.swift.

Replace the file contents with:

// 1
import SwiftUI
import WebKit

// 2
struct WebView: NSViewRepresentable {
  // 3
  let word: String
}

Stepping through this:

  1. Import the two libraries you need: one for the SwiftUI protocol and one for the AppKit web view.
  2. Create a structure called WebView and mark it as conforming to NSViewRepresentable. WebView is your name for the SwiftUI view you’ll create from AppKit’s WKWebView.
  3. Provide the word for this web view to look up. You’ll pass this in when you open the window.

You’ve got an error now because WebView doesn’t conform to the protocol. Click the red dot on the error indicator and then click Fix:

Fixing the protocol error.
Fixing the protocol error.

This isn’t as helpful as you probably hoped, as it adds a second error. But it has inserted this line:

typealias NSViewType = type

The NSViewRepresentable protocol can work with any AppKit view, so this line asks you to state what type of view you want to use. Replace the type placeholder with:

WKWebView

Now you’re back to a single error, but this time, the Fix button actually fixes it by providing stubs for the two required methods. It was able to do this because your typealias specified the AppKit view type:

Really fixing the protocol error.
Really fixing the protocol error.

Filling in the Methods

The first of these methods makes the AppKit view, so in makeNSView(context:), replace the placeholder with:

WKWebView()

This creates an instance of WKWebView and returns it.

The second required method is the one that SwiftUI calls to refresh the display whenever the data changes: in this case word. This method navigates to the correct web address for the word.

Fill in updateNSView(_:context:) with:

// 1
let address = "https://www.dictionary.com/browse/\(word)"
// 2
guard let url = URL(string: address) else {
  return
}

// 3
let request = URLRequest(url: url)
// 4
nsView.load(request)

Taking this line by line:

  1. Construct the web address by interpolating word.
  2. Make sure this results in a valid URL.
  3. Create a URLRequest with this URL. A URLRequest contains everything needed to open a web page: address, headers, cache policy and so on. In this case, the defaults are fine, so you only need to supply the URL.
  4. The nsView method argument points to the WKWebView, so you can use a WKWebView method to load the URLRequest.

When you initialize the view, you set a value for word, which triggers this method. That means that you don’t need to load the web page in makeNSView(context:).

That’s everything you need to load a web page into your view, but so far, you don’t have a way of showing it in your app.

Setting Up a Window Group

Open SnowmanApp.swift and scroll to the end of the structure. You’ve already added a new Window to display the Statistics views. Now, you’ll add a WindowGroup to show the web view.

Add this inside body after the last keyboardShortcut line:

// 1
WindowGroup(for: String.self) { $word in
  // 2
  Text(word ?? "SNOWMAN")
}

What does this do?

  1. A WindowGroup allows you to open multiple windows using the same view. This initializer lets you specify a content type for the group, with a value for each window in the group. Here, the content type is String, and you use word to access the value, supplied as a binding.
  2. You’ll show a WebView here soon but, for testing the data flow, use a Text view to display the supplied word. As the WindowGroup value is always an optional, set up a fallback word, just in case.

Now, you have all the pieces you need to display the window.

Opening the Window

Open GameView.swift again and scroll to the top. Add this:

@Environment(\.openWindow) var openWindow

This creates a property with the @Environment property wrapper, so it can access values from the view’s environment. There are certain predefined keys for these EnvironmentValues.

One of these keys is openWindow, which you can use to present a window defined in a WindowGroup.

To call this, in the action for the Look Up Word button, replace // open lookup window with:

openWindow(value: game.word)

This uses the openWindow property to call the environment’s openWindow action, passing in the current word.

Because you set up the new WindowGroup to accept a String, any use of openWindow that provides a String value, opens a window from this group.

Now, you’re ready for a test. Run the app, play a game and click Look Up Word:

Lookup window test
Lookup window test

There’s a new window, showing a Text view with your word. But now for a cool feature of WindowGroup. Click back in the main window and then click Look Up Word again. Because you sent the same data to WindowGroup, it reactivated the existing window and didn’t create a new one.

Leave the word window open and play a new game. When you’ve finished, lookup the word. Now you have two word windows because you supplied a different value. Select different games in the sidebar and click Look Up Word to bring each one to the front. And if you close one, Look Up Word reopens it.

You’ve worked out how to open windows and you’ve confirmed the data flow. On to the next step.

Showing the Web Page

The last part of this process is to display your WebView instead of the Text view.

In SnowmanApp.swift, replace Text(word ?? "SNOWMAN") with:

WebView(word: word ?? "SNOWMAN")

Don’t run the app yet. You need to tell the App Sandbox to permit web loads.

Select the Snowman project at the top of the Project navigator and click the Snowman target. Choose Signing & Capabilities across the top and in the App Sandbox section, check Outgoing Connections (Client):

Configuring the sandbox.
Configuring the sandbox.

Build and run the app, play a game and lookup the word:

Looking up a word at Dictionary.com
Looking up a word at Dictionary.com

There are a couple of things you can do to make this window better. The first is to show the word in the window title.

Open SnowmanApp.swift and add this after the WebView line:

.navigationTitle(word ?? "Snowman")

This tries to use word as the window title and falls back to “Snowman” if necessary.

The other improvement would be to increase the default window size to make more of the web page visible by default. A size of 1000 x 800 pixels seems about right.

Add this modifier to the new WindowGroup:

.defaultSize(width: 1000, height: 800)

Run the app, play a game and lookup a word. The first time, the window may still be the size you used last, but close that window and try again. This time, you’ll see a large window with the word as its title:

WebView window
WebView window

Now you know how to convert an AppKit view into a SwiftUI view and how to open a secondary window to display it.

Using a Coordinator

Sometimes, you want to get data back into a SwiftUI view from an AppKit view. WKWebView can have a navigationDelegate to track navigation successes and failures. But how can you create a delegate so that both SwiftUI and AppKit can use its data?

The answer is to set up a custom coordinator. The NSViewRepresentable methods have a context argument that contains information about your view. One of its properties is a coordinator for communication between the two frameworks. You can provide a custom coordinator to do what your app needs.

First, open WebView.swift and add a new property after word:

@Binding var isLoading: Bool

This is an @Binding property so when WebView changes it, the new value flows back to the SwiftUI view that supplied the property.

Next, insert a Coordinator class inside WebView:

class Coordinator: NSObject, WKNavigationDelegate {
}

This does nothing at the moment, but it conforms to WKNavigationDelegate, which requires it to inherit from NSObject.

It causes an error because WebView knows there’s a Coordinator class, but isn’t using it. The Fix button comes to the rescue again, adding:

func makeCoordinator() -> Coordinator {
  code
}

To pass data from Coordinator back to WebView, Coordinator needs access to its view. Add this property and initializer to Coordinator:

// 1
var parent: WebView

// 2
init(_ parent: WebView) {
  // 3
  self.parent = parent
}

You configure Coordinator by:

  1. Giving it a WebView property called parent.
  2. Creating an initializer. The underscore for the external argument label means the caller can omit it.
  3. Setting Coordinator’s parent to the supplied WebView.

Now you can fill in makeCoordinator(), replacing the code placeholder with:

Coordinator(self)

This returns a Coordinator object, initialized with the containing WebView as its parent. This gives you a place to put your delegate methods.

Setting Up the Delegate

The purpose of this coordinator is to track page loads and errors using WKNavigationDelegate.

First, set the WKWebView to have the Coordinator as its delegate. Change the code in makeNSView(context:) to:

// 1
let webView = WKWebView()
// 2
webView.navigationDelegate = context.coordinator
// 3
return webView

Before you had a single line, returning a WKWebView, but now:

  1. Create a WKWebView.
  2. Set its navigationDelegate, using context to get coordinator.
  3. Return the configured WKWebView.

The next step is to add some delegate methods.

Add some blank lines inside Coordinator and type webview:

Adding web delegate methods.
Adding web delegate methods.

You need two methods from the autocomplete menu. First, add webView(_ webView:didFinish:). Then, make some more space below the new method and type webview again to find and add webView(_ webView:didFail:withError). The first one detects a successful load and the second one traps for failure. Both tell you that loading has finished, which is what you’re interested in.

To handle a completed load, replace the code placeholder in webView(_:didFinish:) with:

parent.isLoading = false

And to report an error, fill in webView(_:didFail:withError:) with:

print(error.localizedDescription)
parent.isLoading = false

Both these set the parent’s isLoading to false and didFail prints an error message to help you debug. So, how can you use this information?

Displaying a Lookup View

At the moment, your WindowGroup displays nothing but a WebView. Now, you’ll give it a new SwiftUI view that contains the WebView as well as indicators to show if the page is still loading.

Select the Views folder in the Project navigator and create a new SwiftUI View file called LookupView.swift.

Add these properties to the new file:

let word: String
@State var webViewIsLoading = true

These hold the current word and a Boolean to track whether the web view is still loading. It’s true by default because this view starts the page load as soon as it appears.

Get rid of the error in #Preview by replacing the error line with:

LookupView(word: "SNOWMAN")

Next, add the view code. Replace the contents of body with:

// 1
ZStack {
  // 2
  WebView(word: word, isLoading: $webViewIsLoading)

  // 3
  if webViewIsLoading {
    ProgressView()
  }
}
// 4
.navigationTitle(webViewIsLoading ? "Loading…" : word)

What does this show?

  1. You’ve used an HStack to group views horizontally and a VStack to group vertically. A ZStack piles views one on top of another.
  2. Show WebView, passing it the value for word and a binding to webViewIsLoading. You already set up isLoading in WebView to accept this binding.
  3. If the web page is still loading, show a ProgressView superimposed on the WebView. A ProgressView with no arguments displays a spinner.
  4. Set the window title to show Loading… until the page has loaded, then use word as the title.

To use this new view, open SnowmanApp.swift. Replace both lines in the last WindowGroup with:

LookupView(word: word ?? "snowman")

Run the app, play a game and click Look Up Word to see the spinner and window title:

Loading…
Loading…

This is quite a complex chain of data passing, so here’s a review of the complete sequence:

  1. GameView uses openWindow to target the WindowGroup that can accept a String, passing in word.
  2. WindowGroup displays LookupView, sending it word as a non-optional String.
  3. LookupView has its webViewIsLoading property set to true. It passes this to WebView as a binding and also sends word.
  4. LookupView displays a ProgressView and sets the navigationTitle depending on the value of webViewIsLoading.
  5. WebView sets up its Coordinator as the navigationDelegate, providing a reference to itself. Then, it starts to load the web page using the value in word.
  6. Coordinator tracks the progress of the web page load and switches WebView’s isLoading to false when the load is complete.
  7. This flows back to the parent WebView and, because it’s a binding, back to LookupView, which changes its display accordingly. The property names for the Boolean are deliberately different in LookupView and WebView, so you can see which is which.

As soon as you need an AppKit view to be able to change data in a SwiftUI view, things get complicated, so take your time and follow this chain through the different files.

Next, you’ll learn another way of including AppKit features in a SwiftUI app.

Observing Events

You’ve seen how to convert an AppKit view into a SwiftUI view for presentation in your SwiftUI app, but AppKit has more than views. One thing it’s extremely good at is event handling.

SwiftUI has modifiers to trap certain events. You’ve used onAppear to take action when a view first appears and you’ve used onChange to detect data changes.

Press Shift-Command-L to open the Xcode Library. Select the Modifiers tab — the one with the sliders icon — and scroll down to find the Events section. There are lots of events, but nothing to detect key presses. That’s why you used a text entry field for the player’s guesses.

Using an AppKit method, you’ll add key press detection and make entering guesses look and feel a lot more natural.

Trapping Key Strokes

Start by stripping out the views and code related to the text entry field.

Open GuessesView.swift and double-click the closing curly brace at the end of the LabeledContent line. This selects the contents of this view and all its modifiers. Press Delete to get rid of it and then delete the LabeledContent line itself.

In the properties for this view, delete entryFieldHasFocus but leave the others in place. You’ll still use nextGuess, but you’ll populate it differently.

You’ve eliminated the current method for guessing letters so now it’s time to replace it.

Still in GuessesView.swift, add this method to the structure, outside body:

func startMonitoringKeystrokes() {
  // 1
  NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
    // 2
    print(event.characters)
    // 3
    return event
  }
}

This method uses an AppKit class:

  1. NSEvent is a class that provides information about user actions. In this case, use a class method to monitor this app only and watch for the user pressing a key.
  2. The method passes an NSEvent into the attached closure where you’ll process it. For now, print its characters to see what’s happening. Ignore the warning since you’ll delete this line in a minute.
  3. Return the event for any other part of the app to handle as usual.

To activate this method, attach a modifier to the VStack:

.onAppear(perform: startMonitoringKeystrokes)

This is a different way to use onAppear, passing the name of a method as its perform argument. Notice how there’s no need to add the parentheses after the method name. If a called method doesn’t have any arguments, this is a neat way of writing onAppear.

Run the app now and start pressing buttons on your keyboard:

Detecting key presses.
Detecting key presses.

The Xcode console shows the optional characters for keys you pressed, with a mix of uppercase, lowercase, numbers, symbols and strange control sequences.

Now, you can decide which keys you want the game to process.

Processing Key Strokes

The first step is to work out if the player pressed a valid key.

Replace print(event.characters) with:

// 1
guard let key = event.characters(byApplyingModifiers: .shift) else {
  return event
}

// 2
if key >= "A" && key <= "Z" {
  // 3
  nextGuess = key
  return nil
}

This does two checks:

  1. See if the event had any characters after you apply the Shift key. This means if the user presses either a or Shift-a, key is A. If the event has no characters, key is nil and this returns the event immediately.
  2. The second test confirms the typed character is between A and Z. The byApplyingModifiers: .shift has changed everything to upper case, so there’s no need to check for a to z.
  3. If both checks pass, set nextGuess to the typed character and return nil to indicate that you’ve handled the event and it doesn’t need to be passed on to the rest of the app.

Finally, everything is in place to handle the entered letters, so add this modifier after onAppear:

// 1
.onChange(of: nextGuess) {
  // 2
  if game.gameStatus == .inProgress {
    // 3
    game.processGuess(letter: nextGuess)
  }
  // 4
  nextGuess = ""
}

Working through these lines:

  1. Watch for changes to nextGuess.
  2. When there’s a change, check if the game is still in progress. Previously, you used a modifier to disable the input field for completed games, but events can arrive at any time, so now you have to check manually.
  3. Process the guess as before.
  4. Clear the value in nextGuess. This is necessary when you have more than one active game. If you press E for game 1 and then switch to game 2 and press E again, there’s no change in the value of nextGuess to trigger onChange. Clearing nextGuess means that any key press triggers onChange.

Run the app now and play a game:

Playing with key press detection.
Playing with key press detection.

It doesn’t make the game any easier to win :] but the interface is cleaner and you’ve eliminated a lot of bother with the text entry field and its focus.

Note: You might think that it’d be easier to process the guess directly from the NSEvent handler. The problem is that if you access game inside the event handler’s closure, it captures the value for game as it’s created. This means that inside the closure, game never updates, but always refers to the first game. Setting nextGuess gets around this by doing the processing outside the closure using the current value for game.

There’s one final wrinkle before this new feature is complete.

Watching for the Command Key

Run the app and press Command-N to start a second game. Now press Command-D to get a different word. The game processed both these key presses, so it looks like you’ve already guessed N and D for the new game. And you didn’t get a different word since you already made a guess.

However, NSEvent can tell if the Command key was down at the time of the event, so you can allow for it.

Add this to startMonitoringKeystrokes() in GuessesView, before checking if key is a letter:

// 1
if event.modifierFlags.contains(.command) {
  // 2
  return event
}

How does this help?

  1. An NSEvent has a modifierFlags option set listing its modifier keys. You can query this to see if it contains any particular modifier. The possibilities are all properties of NSEvent.ModifierFlags. This includes shift, control, option and others, but the only one that matters for this app is command.
  2. If the event includes the Command key, return it immediately. This allows the menus to work as expected, but stops the game from processing the associated character.

You’ve implemented a new feature using AppKit, and you’ve added code to handle some edge cases. Great work!

When Should You Start With SwiftUI?

You’ve made a SwiftUI app and you’ve made an AppKit app. Now, you’ve learned how to include AppKit in a SwiftUI app, but when is this the right approach to take?

For a brand new project, there are only two cases where I would not start with SwiftUI: Apps that include long-form text editing and apps that display more than a thousand records in a list.

For every other project type, start with SwiftUI and see what happens.

As you progress, you may find a sticking point where SwiftUI doesn’t do what you want. At that stage, ask yourself these three questions:

  1. Can I restructure the app, the data or the data flow, so that SwiftUI does what I need?
  2. Is there an AppKit feature that I can include in the app to solve this problem?
  3. Would swapping to AppKit make everything work, realizing that I can include SwiftUI views in an AppKit app?

Your answers dictate what you do next: Take action based on the first question that gets a Yes answer.

For this app, the web view was the sticking point, and the answers were No, Yes and Yes, so you included AppKit.

Over time, you’ll develop your own sense of what works best for your individual programming style, but this is a good guide to start with.

SwiftUI has a limited number of view types and although Apple adds new views every year, it can’t compete yet with the huge scope of AppKit. A hybrid app, combining the speed and convenience of SwiftUI development with the power and depth of AppKit, is a terrific option. Apple continues to expand SwiftUI, reducing the need to use AppKit, but the mix-and-match approach will be necessary for many years.

Challenge

Add a menu item for looking up the game word. Don’t forget to give it a keyboard shortcut and make sure to disable it for games that are still in progress to stop your players cheating. :]

Have a go at this yourself, but check out SnowmanApp.swift in the challenge folder if you get stuck.

Key Points

  • NSViewRepresentable lets you create a SwiftUI version of an AppKit view.
  • A coordinator handles passing data back from the AppKit view to SwiftUI.
  • You can include non-view AppKit features, like NSEvent, in a SwiftUI app.
  • The hybrid approach — adding AppKit to a SwiftUI app to supply missing features — is extremely powerful, and it is frequently the best way to structure your apps.

Where to Go From Here

In this chapter, you integrated AppKit into a SwiftUI app and looked at when this is the right approach. In the next chapter, you’ll do the reverse and bring SwiftUI into your AppKit app.

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.