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 Warning Bar
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:
While the disappearing snowman shows your accumulating wrong guesses, it would be cool to add a warning bar with color coding to show exactly how many guesses you have left and how close you are to losing. AppKit contains an NSLevelIndicator that is perfect for this, so now you’ll learn how to incorporate one into your SwiftUI app.
Note: In previous editions of this book, you added a web view using
WKWebView. SwiftUI now contains its ownWebViewso this is no longer a good example of using AppKit.
Creating a Level Indicator
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. Right-click and choose New Empty File, then set its name to WarningBar.swift.
Set the file contents to:
// 1
import SwiftUI
import AppKit
// 2
struct WarningBar: NSViewRepresentable {
// 3
let guessesLeft: Int
}
Stepping through this:
- Import the two libraries you need: one for the SwiftUI protocol and one for the AppKit view.
- Create a structure called
WarningBarand mark it as conforming toNSViewRepresentable.WarningBaris your name for the SwiftUI view you’ll create from AppKit’sNSLevelIndicator. - Provide the number of guesses left. You’ll pass this in when you display this view.
You’ve got an error now because WarningBar doesn’t conform to the protocol. Click the red dot on the error indicator and then click Apply:
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 typealias line with:
typealias NSViewType = NSLevelIndicator
Now you’re back to a single error, but this time, the Apply 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:
NSLevelIndicator()
This creates an instance of NSLevelIndicator and returns it.
The second required method is the one that SwiftUI calls to refresh the display whenever the data changes: in this case guessesLeft. This method updates the level indicator to reflect that number.
Fill in updateNSView(_:context:) with:
nsView.intValue = Int32(guessesLeft)
Set the intValue of the NSLevelIndicator to the number of guesses left. The level indicator expects an Int32, so you need to convert the Int value of guessesLeft to the correct type.
When you initialize the view, you provide a value for guessesLeft, which triggers this method. That means that you don’t need to set intValue in makeNSView(context:).
That’s everything you need to display the level indicator, but so far, you don’t have a way of showing it in your app.
Setting Up the Data Flow
Your new WarningBar structure expects an Int containing the number of guesses left, but right now, there’s no property that supplies that information directly. It sounds like something Game should do, so open Game.swift and add this computed property:
// 1
var guessesLeft: Int {
// 2
7 - incorrectGuessCount
}
This adds a computed property:
- The property is called
guessesLeftand it returns anInt. - It calculates the number of guesses left by subtracting the number of incorrect guesses made so far from the maximum of 7.
Game uses the value 7 three times now, so it would be good practice to create a constant to hold this value. That way if you ever change the game to allow a different number of guesses, you only have to change it in one place.
Add this constant to the top of the Game structure:
let maxGuesses = 7
Next, search Game.swift for the number 7 and replace all three occurrences with maxGuesses. These replacements are in processGuess(letter:), checkForGameOver() and guessesLeft, which now looks like this:
var guessesLeft: Int {
maxGuesses - incorrectGuessCount
}
This is a much more maintainable way to write this code and avoids having a magic number appearing in several places.
Now, you have all the pieces you need to display the warning bar.
Adding the View
The logical place for this bar is underneath the snowman image inside GameView. Open GameView.swift and find the Image that displays the snowman. Select the Image line and its three modifier lines, then press {. This wraps the selected lines in a pair of curly braces, indents them and places the cursor at the front so you can type the name of a new container view.
Type VStack and a space to embed the Image in a vertical stack. Now you can add the WarningBar underneath the snowman.
Add this line before the closing curly brace for the new VStack:
WarningBar(guessesLeft: game.guessesLeft)
The bar now appears below the snowman. Run the app and play a game to see how it updates as you make wrong guesses:
The good news is that it appears and it does change as you make wrong guesses. The bad news is that its too wide, it doesn’t have the correct number of blocks and it doesn’t show any different colors. Time to go back to WarningBar and add some more settings.
Configuring the Level Indicator
NSLevelIndicator has several properties that you can change to make it look the way you want. To see the effect of your changes as you make them, open GameView.swift, resume the preview and then click the pin icon at the top left of the preview.
Switch to WarningBar.swift, where you can still see the GameView preview, and replace the contents of makeNSView(context:) with:
let levelIndicator = NSLevelIndicator()
// set properties here
return levelIndicator
Before, you returned the new NSLevelIndicator immediately, using an implicit return. Now, you’re creating the level indicator and assigning it to a constant, so you can set some properties before returning it.
One property that changes how to level indicator looks is levelIndicatorStyle. Replace the // set properties here comment with this line:
levelIndicator.levelIndicatorStyle = .
When you type the period, Xcode will suggest the different styles. Experiment with the four styles: continuousCapacity, discreteCapacity, rating and relevancy. For this app, discreteCapacity is the best choice, so select that once you’ve checked out the others.
The next task is to set the number of blocks to show in the level indicator. You could set this to 7, but you just created a maxGuesses constant in Game, so it would be better to pass that to the WarningBar and use it.
Start by adding this property to WarningBar, underneath guessesLeft:
let maxGuesses: Int
Jump back to GameView.swift to see the error that this has caused. Change the line showing the error to:
WarningBar(guessesLeft: game.guessesLeft, maxGuesses: game.maxGuesses)
Back in WarningBar.swift, add this line to makeNSView(context:), under where you set the style:
levelIndicator.maxValue = Double(maxGuesses)
This sets the maximum value for the level indicator to the number of guesses allowed in the game. You have to convert it to a Double because that’s the type that maxValue expects. Resume the ContentView preview to see the change:
The warning bar is now showing 7 discrete blocks, but they’ll all show the same color, even when the value changes. You want them to change color as the number of guesses left decreases, so add these two property settings:
levelIndicator.warningValue = 3
levelIndicator.criticalValue = 1
Run the app and enter lots of wrong guesses to see the colors change as you approach losing:
And when you finally lose the game, there are no blocks showing.
The remaining problem is that the warning bar is too wide, so open GameView.swift where the snowman image has a fixed with of 230. To apply this to the entire VStack, select the frame(width: 230) line and press Option-Command-] to move the line down. Keep pressing those keys until this modifier is outside the VStack. Finally, add this modifier to the WarningBar itself:
.padding(.horizontal)
This spaces it out from the sides but not from the top or bottom and now the preview shows the warning bar with a suitable width:
Run the app and play a few games to test it out. It’s a bit scary when you get near the end!
Now you know how to convert any AppKit view into a SwiftUI view and how to display it.
Using a Coordinator
You’re sending data from the SwiftUI view to the AppKit view, but what if you want to communicate back the other way?
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 WarningBar.swift and add a new property after maxGuesses:
@Binding var showHint: Bool
This is an @Binding property so when WarningBar changes it, the new value flows back to the SwiftUI view that supplied the property.
Next, insert a Coordinator class inside WarningBar:
class Coordinator {
}
This does nothing at the moment, but it sets up the new view to have a coordinator that it can use to handle interactions.
It causes an error because WarningBar knows there’s a Coordinator class, but isn’t using it. The Apply button comes to the rescue again, adding:
func makeCoordinator() -> Coordinator {
code
}
To pass data from Coordinator back to WarningBar, Coordinator needs access to it. Add this property and initializer to Coordinator:
// 1
var parent: WarningBar
// 2
init(_ parent: WarningBar) {
// 3
self.parent = parent
}
You configure Coordinator by:
- Giving it a
WarningBarproperty calledparent. - Creating an initializer. The underscore for the external argument label means the caller can omit it.
- Setting
Coordinator’s parent to the suppliedWarningBar.
Now you can fill in makeCoordinator(), replacing the code placeholder with:
Coordinator(self)
This returns a Coordinator object, initialized with the containing WarningBar as its parent. This gives you a place to handle interactions.
Setting Up the Action and Target
The purpose of this coordinator is to detect and respond to clicks inside the level indicator. AppKit views do this with a target and action system. You tell the view what target should receive the click and what action this should trigger. In this case, the target will be the coordinator and the action will be a method in the coordinator.
First, add the action method to the Coordinator class:
// 1
@objc func handleClick(sender: NSLevelIndicator) {
// 2
parent.showHint.toggle()
}
What’s happening here?
- Add a method to respond to clicks in the
NSLevelIndicator. This must have the@objcmarker soNSLevelIndicatorcan recognize and call it. - When the user clicks, toggle the value of
showHintin the parentWarningBar. This is a binding, so it will flow back to the SwiftUI view that supplied it.
Next, configure the target and action. In makeNSView(context:), add these lines after setting the properties, but before the return:
// 1
levelIndicator.target = context.coordinator
// 2
levelIndicator.action = #selector(Coordinator.handleClick)
This is how you set up the target and action for an AppKit view programmatically:
- Tell the level indicator that its target is the coordinator, which you can access through the
contextargument. - Set its action, using
#selectorto specify the method in the coordinator. This is the same technique that you used to specify sort descriptor methods in Chapter 13: “Powering Up Your Table”.
There’s one more wrinkle in this, which is due to the way SwiftUI views are redrawn as required. Every time the WarningBar view updates, you need to reset the coordinator’s parent. If you don’t do this, hints will only work for the first game.
Add this line to updateNSView(_:context:):
context.coordinator.parent = self
Now, the level indicator will detect clicks and call handleClick(sender:) in the coordinator. This method toggles a binding in the parent view, allowing SwiftUI to react to the change. So, how can you use this information?
Showing Hints
Right now, the app doesn’t build. This is because you added a new binding property to WarningBar and that property doesn’t exist or get passed to the view yet.
Start by opening Game.swift and adding this property to Game:
var showHint = false
Next, open GameView.swift and replace the line that creates the WarningBar, which is showing an error:
// 1
WarningBar(
// 2
guessesLeft: game.guessesLeft,
maxGuesses: game.maxGuesses,
// 3
showHint: $appState.games[appState.gameIndex].showHint
)
What does this do?
- Split the call over multiple lines, to make it easier to read now that it’s getting longer.
- Provide two game properties as before.
- Use the longer form of accessing the current game to be able to pass it as a binding. The
$prefix means that whenWarningBarchanges this, the new value flows back to the game. This is the same way you passed a binding toGuessesView.
Now showHint is being passed to WarningBar and clicks in WarningBar will toggle this value, so you can use it to show helpful hints.
Open Game.swift and add this computed property to Game:
// 1
var hint: String? {
// 2
if gameStatus != .inProgress || !showHint {
return nil
}
// 3
if guesses.count == 0 {
return "Starting with a vowel is always a good idea."
}
// 4
let numberOfVowelsGuessed = guesses.count {
"AEIOU".contains($0)
}
if numberOfVowelsGuessed == 0 {
return "Try guessing a vowel."
} else if numberOfVowelsGuessed < 3 {
return "Try guessing another vowel."
}
// 5
let commonConsonants = ["T","N","S","H","R"]
let unusedCommons = commonConsonants.filter {
!guesses.contains($0)
}
if !unusedCommons.isEmpty {
return "These are commonly used consonants that you haven't tried yet: "
+ unusedCommons
.joined(separator: ", ")
}
// 6
return "It's generally best to avoid uncommon letters like Z, Q and X."
}
This generates a hint based on the current game state:
-
hintis a computed property that returns an optionalString. - If the game is over or
showHintis false, returnnil. - If the player hasn’t made any guesses, advise them to start with a vowel.
- If the player has made some guesses but no or few vowels, recommend a vowel.
- If the player has guessed some vowels already, but hasn’t tried some of the most common consonants, suggest trying those.
- If none of these conditions are met, show them uncommon letters that are unlikely to be useful.
Feel free to change the hints to be more helpful or more fun, but this is a good starting point.
Finally, open GameView.swift and add this computed property:
// 1
var statusOrHint: String {
// 2
if let hint = game.hint {
return hint
}
return game.statusText
}
What’s this for?
-
statusOrHintis a computed property that returns aString. - Check to see if
game.hintis notnil. If so, return the hint. - Otherwise, return the normal status text.
To display this, replace Text(game.statusText) with:
Text(statusOrHint)
Now the text above the letters boxes will show a hint when the user clicks the level indicator to request a hint, and it will show the normal status text at all other times.
There’s one final tweak to add. The hint shouldn’t stay visible once it’s been used, so jump back to Game.swift and find processGuess(letter:). Before the checkForGameOver() call, add this line:
showHint = false
Now the player can ask for a hint and it disappears as soon as they make their next guess.
Run the app and test it out:
This is quite a complex chain of data passing, so here’s a review of the complete sequence:
-
Gamehas the propertiesmaxGuessesandshowHint, as well computed properties forguessesLeftandhint. -
GameViewdisplaysWarningBar, passing itguessesLeftandmaxGuessesas integers. -
GameViewalso sendsshowHintas a binding, so whenWarningBarchanges it, the new value flows back toGame. -
WarningBarusesmaxGuessesto configure theNSLevelIndicatorandguessesLeftto set its value. -
WarningBarsets up aCoordinatoras the target for theNSLevelIndicatorclick event, providing itself as the parent view. -
Coordinatordetects clicks in theNSLevelIndicatorand toggles theshowHintproperty in its parentWarningBar. - Because this is a binding, it flows back to the enclosing
GameViewwhich then changes its display accordingly using thehintproperty inGameto generate a relevant hint.
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.
The project now has four views that are involved in displaying the game, so to make the project more organized, move GameView.swift, GuessesView.swift, LettersView.swift and WarningBar.swift into a new folder called Game:
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 Views ▸ Game ▸ 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 returnnilto 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 try to start a second game. Try quitting the app using Command-Q. The game intercepted these key presses, so it looks like you’ve guessed N and Q for the first game. You didn’t get a new game, and the app didn’t quit.
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 between A and Z:
// 1
if event.modifierFlags.contains(.command) {
// 2
return event
}
How does this help?
- An
NSEventhas amodifierFlagsoption set, which lists 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 and 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 few 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, when looking to add a level indicator, 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.
Challenges
- Add a tooltip to
WarningBarthat shows how it can be used to display a hint. - Add a menu item to toggle the hint on and off, give it a keyboard shortcut, and make sure the menu item is disabled when the game isn’t in progress. Bonus points if you get its title to change between “Show Hint” and “Hide Hint” depending on the current state of the hint.
Have a go at these 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.