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:
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:
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:
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:
- Import the two libraries you need: one for the SwiftUI protocol and one for the AppKit web view.
- Create a structure called
WebViewand mark it as conforming toNSViewRepresentable.WebViewis your name for the SwiftUI view you’ll create from AppKit’sWKWebView. - 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:
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:
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:
- Construct the web address by interpolating
word. - Make sure this results in a valid
URL. - Create a
URLRequestwith this URL. AURLRequestcontains 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. - The
nsViewmethod argument points to theWKWebView, so you can use aWKWebViewmethod to load theURLRequest.
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?
- A
WindowGroupallows 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 isString, and you usewordto access the value, supplied as a binding. - You’ll show a
WebViewhere soon but, for testing the data flow, use aTextview to display the supplied word. As theWindowGroupvalue 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:
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):
Build and run the app, play a game and lookup the word:
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:
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:
- Giving it a
WebViewproperty calledparent. - Creating an initializer. The underscore for the external argument label means the caller can omit it.
- Setting
Coordinator’s parent to the suppliedWebView.
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:
- Create a
WKWebView. - Set its
navigationDelegate, usingcontextto getcoordinator. - Return the configured
WKWebView.
The next step is to add some delegate methods.
Add some blank lines inside Coordinator and type webview:
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?
- You’ve used an
HStackto group views horizontally and aVStackto group vertically. AZStackpiles views one on top of another. - Show
WebView, passing it the value forwordand a binding towebViewIsLoading. You already set upisLoadinginWebViewto accept this binding. - If the web page is still loading, show a
ProgressViewsuperimposed on theWebView. AProgressViewwith no arguments displays a spinner. - Set the window title to show Loading… until the page has loaded, then use
wordas 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:
This is quite a complex chain of data passing, so here’s a review of the complete sequence:
-
GameViewusesopenWindowto target theWindowGroupthat can accept aString, passing inword. -
WindowGroupdisplaysLookupView, sending itwordas a non-optionalString. -
LookupViewhas itswebViewIsLoadingproperty set totrue. It passes this toWebViewas a binding and also sendsword. -
LookupViewdisplays aProgressViewand sets thenavigationTitledepending on the value ofwebViewIsLoading. -
WebViewsets up itsCoordinatoras thenavigationDelegate, providing a reference to itself. Then, it starts to load the web page using the value inword. -
Coordinatortracks the progress of the web page load and switchesWebView’sisLoadingtofalsewhen the load is complete. - This flows back to the parent
WebViewand, because it’s a binding, back toLookupView, which changes its display accordingly. The property names for the Boolean are deliberately different inLookupViewandWebView, 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:
-
NSEventis 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. - The method passes an
NSEventinto the attached closure where you’ll process it. For now, print itscharactersto see what’s happening. Ignore the warning since you’ll delete this line in a minute. - Return the
eventfor 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:
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:
- See if the event had any characters after you apply the Shift key. This means if the user presses either a or Shift-a,
keyis A. If the event has no characters,keyisniland this returns the event immediately. - The second test confirms the typed character is between A and Z. The
byApplyingModifiers: .shifthas changed everything to upper case, so there’s no need to check for a to z. - If both checks pass, set
nextGuessto 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:
- Watch for changes to
nextGuess. - 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.
- Process the guess as before.
- 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 ofnextGuessto triggeronChange. ClearingnextGuessmeans that any key press triggersonChange.
Run the app now and play a game:
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
NSEventhandler. The problem is that if you accessgameinside the event handler’s closure, it captures the value forgameas it’s created. This means that inside the closure,gamenever updates, but always refers to the first game. SettingnextGuessgets around this by doing the processing outside the closure using the current value forgame.
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?
- An
NSEventhas amodifierFlagsoption set listing its modifier keys. You can query this to see if it contains any particular modifier. The possibilities are all properties ofNSEvent.ModifierFlags. This includesshift,control,optionand others, but the only one that matters for this app iscommand. - 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:
- Can I restructure the app, the data or the data flow, so that SwiftUI does what I need?
- Is there an AppKit feature that I can include in the app to solve this problem?
- 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
-
NSViewRepresentablelets 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.