8.
Showing Other Windows
Written by Sarah Reichelt
In the previous two chapters, you set up the data flow for your app. This was a big task and one that can be confusing, so if you feel lost, don’t worry about it. Keep going and revisit these chapters at the end of this section if you need to.
Now, you’ll move on to dealing with multiple windows. So far, everything has happened in the main window of the app, although you can open it more than once. But Mac apps frequently have more than one type of window, and that’s what you’ll look into next.
First, you’ll create a Settings window to allow users to configure the app.
After that, you’ll create an entirely new window with a different SwiftUI view. And you’ll see how to pass data around between different windows.
Creating a Settings View
Launch Xcode and open the project you ended with after the last chapter. If you prefer, you can use the starter project from the downloads for this chapter, but it contains nothing new.
Press Command-R to run the app and open the Snowman menu:
There are the expected menu items, but no Settings… option. Go to to Xcode and open the Xcode menu. There’s a Settings… menu item under the first divider. So how can you add that to Snowman?
Open SnowmanApp.swift. The body contains a single scene that defines the main window.
Add this after the end of WindowGroup:
// 1
Settings {
// 2
Text("Settings View")
.frame(width: 200, height: 100)
}
What’s this?
-
Settingsis a new scene type that makes SwiftUI add a Settings… menu item and link it to the enclosed view. - For now, this is a placeholder view for the Settings window to show. It has a default frame so the window is large enough to see when it opens.
Run the app and look at the Snowman menu again:
Now it has a Settings… option and it has allocated the default keyboard shortcut: Command-,.
Select this option or press Command-, to see your new window with the placeholder text:
Note: Until macOS Ventura, these were Preferences windows and the system options were System Preferences. Ventura brings macOS more into line with iOS, which has always used the term Settings.
A feature of the Settings scene is that it never opens its window more than once. Press Command-, with the Settings window already open and it brings it to the front, but doesn’t duplicate it.
Configuring @AppStorage
You’ve created a Settings window and it’s linked to the correct menu item — now to add some content.
When you imported the words list and added a method to find a random word, you hard-coded some word length limits. It would be better to let the user change these, so you’ll add an upper and lower limit to the Settings view.
Start by making a new view. Select GuessesView.swift in the Project navigator so the next file will appear right below it, inside the Views group.
Choose File ▸ New ▸ File… or press Command-N to make a new file. Select the macOS ▸ User Interface ▸ SwiftUI View template and click Next. Name the file SettingsView.swift and click Create.
The first thing to add is the data. At the top of SettingsView, before body, insert these lines:
@AppStorage("minWordLength") var minWordLength = 4
@AppStorage("maxWordLength") var maxWordLength = 10.0
Another property wrapper!
- The
@AppStorageproperty wrapper is responsible for saving and retrieving user settings and does a lot of work. Any time one of these properties changes, the property wrapper saves it, and any time the app uses one of these, it reads the saved data, so any changes carry over between app launches. - The name in brackets after the property wrapper name is an identifier that the user configuration storage system uses.
- After that, you have a standard variable property declaration, with a name and a default value. It’s a good idea to use the same name for the property and its storage label to avoid confusion.
- The app uses the default values if the user hasn’t set anything yet.
maxWordLengthis aDouble— you’ll see why when you use it.
That’s a lot of work for a couple of lines! Now to use them.
Adding a Stepper
Still in SettingsView.swift, replace the Text view with:
// 1
Form {
// 2
Stepper(
// 3
value: $minWordLength,
// 4
in: 3 ... Int(maxWordLength)
) {
// 5
Text("Minimum word length: \(minWordLength)")
}
// more items here
}
Stepping through this code:
- A
Formis another way of grouping SwiftUI views. You’ve usedHStackandVStackbefore. This is a more specialized group type designed for settings and similar data entry interfaces. - A
Stepperis an interface element for incrementing and decrementing numbers. - Its
valueis bound to theminWordLengthproperty. The$shows that this works both ways, so changing the property adjusts the stepper and changing the stepper edits the property. - The
inargument is arangeto limit its upper and lower limits. The lower end is set to 3, and the upper end usesmaxWordLength. Since this is aDouble, you convert it to anIntfirst. - Inside the curly braces, you have the
labelargument. This displays a header and the current value.
This provides the UI for the first setting, but if you run the app now, it won’t appear. Open SnowmanApp.swift and in the Settings scene, replace the Text placeholder with:
SettingsView()
This tells the Settings scene to use your new SettingsView as the content for the Settings window. Leave the frame modifier there for now, and run the app.
Open Settings and test your stepper:
Click the arrows to go up and down between 3 and 10. Chose something different to the default 4. Quit the app, then run it again and open Settings. The property wrapper restores your selection.
Adjusting the Settings Window
Earlier, I told you to turn off Stage Manager but now I want you to turn it on and configure it to test a certain behavior.
Open your Mac’s System Settings… and select Desktop & Dock. Scroll down until you see Stage Manager. Use the toggle to turn it on and then, click Customize…. In the dialog that pops up, choose One at a Time for Show windows from an application:
Back in Xcode, build and run the app again and open Settings. Annoyingly, the main window disappears until you close the Settings window. This is not very user-friendly, but Apple has provided a way around this: If your Settings window has tabs, it won’t hide the other windows for its app.
This means you should add a tab to the Settings window.
In Xcode, open SettingsView.swift, Command-click Form and select Embed…. This wraps the form in curly braces, indents the code and gives a Container placeholder. Replace Container with:
// 1
TabView
Next, find the end of the Form by double-clicking the curly brace at the end of its line. Add this modifier to Form:
// 2
.tabItem {
// 3
Image(systemName: "snowflake")
Text("Settings")
}
How does this make tabs?
- First, you wrap the
Formview in aTabView. ATabViewcan have multiple views and shows a tab for each one. This tab view only has one view: theForm. - The tab shows the
Formview, and you use atabItemmodifier to configure the tab bar display. - In a Settings window, a tab label has an image and text, unlike a regular tab that only shows text. As before, this image comes from SF Symbols.
Run the app now and open Settings:
This time, the main window stays visible behind the Settings window. And now you can turn off Stage Manager again. It seems a bit odd to have a single tab, but you want your apps to work well for all users, regardless of their system settings.
While you’re adjusting the window, add this modifier after the close of TabView:
.frame(width: 420, height: 160)
With most app windows, you’ll set a minimum frame and allow users to expand them, but for a window like Settings that has fixed content, you can set the frame exactly.
Previously, you set a frame for SettingsView() in SnowmanApp.swift. Delete that frame. Now that you have a SettingsView structure, the frame is better attached to it, so that the preview is accurate.
Back in SettingsView.swift, resume the preview to see your new frame:
Notice how the preview only shows the standard text tab label. You have to run the app to see the special Settings tab label.
Limiting the Maximum Word Length
You’ve used a Stepper to set the minimum word length. Now you’ll use a Slider to set the maximum. In a production app, you’d keep the user experience (UX) consistent and only use one type, but for a learning app like this one, it’s more interesting to see some variety.
Open SettingsView.swift and resume the preview if it isn’t already running. Add a few blank lines before // more items here and position the cursor there.
Open the Xcode library by clicking the + in the toolbar or by pressing Shift-Command-L. Select the Views tab icon and search for label. When you find LabeledContent, drag it into the blank space:
Replace "Label" with:
"Maximum word length: \(Int(maxWordLength))"
This gives a label in the same format as you used for the Stepper. You don’t want the selected length to show any decimals, so you convert it to an Int for display.
Now, to add the Slider, delete the Content placeholder and type:
Slider
Xcode now suggests a lot of options. Use the arrow keys to move up and down through the list. The one you want is Slider(value:in:), ignoring its optional grayed out arguments:
Note: If the autocomplete menu disappears, press Esc to bring it back. Drag the edges to change the width of the menu if you can’t see enough.
Double-click the relevant line, or arrow to it and press Return to insert it into LabeledContent. And now you get some strange placeholders and a lot of red:
Don’t panic! Providing valid arguments will make the red vanish. The placeholder for the first argument tells you that it expects a value with a type of Binding<BinaryFloatingPoint>. This means that it wants a binding to a floating point number that can be either a Double or a Float. And that’s why you initialized maxWordLength as a Double.
Replace the value placeholder with:
$maxWordLength
Again, autocomplete helps as you type. And this provides what the placeholder specified.
The remaining placeholder has the type ClosedRange<BinaryFloatingPoint>. Remembering back to the earlier chapters, ... is the closed range operator and it creates a range going from the first number to the last, inclusively.
Add this instead of that placeholder:
Double(minWordLength) ... 12
The slider can’t go lower than the minimum word length, but that’s an Int, so you convert in to a Double and fix the upper limit at 12.
While it may seem clumsy to add code using the library and autocomplete, it’s good to practice both those techniques. The library suggests views that you can use, and autocomplete helps fill them in.
Using a Toggle
There’s one more setting to add: The word list contains some proper nouns — mostly place names. Your users may not want these to show up.
First, add a new @AppStorage property after the others:
@AppStorage("useProperNouns") var useProperNouns = false
The first two properties are numbers, but this one is a Boolean. You’ve seen several different input views now, and it’s important to choose one that suits the data type. A Toggle is an on/off switch and that’s perfect for a Boolean property.
Next, replace // more items here with:
Toggle("Allow proper nouns", isOn: $useProperNouns)
Like Slider, Toggle has several initialization options. This one sets a title and a Boolean binding.
The toggle is a standard Mac checkbox by default. But to keep up with the look of Ventura, this should be a switch like the ones in the System Settings app.
Add a new line after the Toggle line, and type:
.toggle
Press Return to accept the suggestion when autocomplete offers toggleStyle.
Now you have a placeholder, looking for a ToggleStyle. Select the placeholder and type a period to show the possible options:
Choose switch and check the preview, using Shift-Command-P to resume it if necessary:
Note: The minimum word length you’ll see is the value you set in Settings.
The Toggle is using the switch style but it’s huge, like on iOS. For a Mac where you have a precise pointing device, there’s no need for a big switch. The System Settings app has small versions of these toggles, so how can you make this one small?
The answer may surprise you. It isn’t a Toggle style, it’s a Form style.
Add this after the frame modifier:
.formStyle(.grouped)
If you want more practice using autocomplete, start typing formStyle, select the suggestion and then press period to see the options.
The preview has changed, but as it doesn’t show the tab properly, run the app and open Settings:
Doesn’t that look great! Everything is neatly arranged, the toggle switch is small, the labels and controls all line up. Terrific work!
Now to apply these settings in the app.
Applying the Settings
All these user settings change how Game selects a random word, so open Models ▸ Game.swift.
Start by changing the import at the top to:
import SwiftUI
You’re about to start using @AppStorage and Foundation doesn’t know what this is.
Next, add the three @AppStorage properties to the other properties:
@AppStorage("minWordLength") var minWordLength = 4
@AppStorage("maxWordLength") var maxWordLength = 10
@AppStorage("useProperNouns") var useProperNouns = false
These are the same as in SettingsView except that here, maxWordLength is an Int and not a Double. @AppStorage can extract its value in either format. It had to be a Double for the Slider, but here it works better as an Int.
Scroll down to getRandomWord and find where you filtered based on word.count with fixed values of 4 and 10.
Replace word.count >= 4 && word.count <= 10 with:
word.count >= minWordLength && word.count <= maxWordLength
And that takes care of the word length limits. Whenever you start a new game, this method reads the latest settings for minWordLength and maxWordLength and filters out words that are outside either limit.
Now, you need to implement the setting for proper nouns. These all start with an uppercase letter, so that’s what you’ll use to remove them if needed.
Add a second filter block after the first:
// 1
.filter { word in
// 2
if useProperNouns {
return true
}
// 3
let firstLetter = word[word.startIndex]
// 4
return !firstLetter.isUppercase
}
How does this work?
- You can chain filter methods to perform a sequence of operations. You already limited the words by length and now you’re filtering the words that remain.
- If
useProperNounsis true, returntrueto include all the remaining words. - Get the first letter of the word. This isn’t a simple process in Swift. Many languages allow you to use
word[0]to get the first character, but Swift strings are fully Unicode compliant. This means that a single visible symbol isn’t always one character long: Accented characters are two different characters superimposed, and some Emoji can include four or five symbols. But aStringhas astartIndexproperty that you can use as the index value to find the first character. - Apply a
Charactermethod to check if the first letter is uppercase. The not operator reverses the result and returns it. So if the first letter is uppercase,isUppercaseistrueand applying!changes it tofalseto exclude that word.
Build and run the app. Play one game with the current settings, but before clicking New Game, open the Settings window and make some changes:
Notice how changing the stepper or the slider adjusts the limits on the other so you can’t choose an impossible combination.
Now click New Game and Game uses your settings to decide on a new word.
And that’s how you create a Settings window, store the user options and apply them to the app.
Opening a Secondary Window
The Settings window is a special case, and SwiftUI provides a preset Scene for handling that. But you’’ll often want to have more than one window type in an app. You can add more scenes to the @main body to do this.
You’ll add a secondary window with a tab view. In the next chapter, you’ll draw charts in these tabs to display your game statistics, but for now, you’ll set up a new window and pass data into it.
Begin by adding the new Scene; open SnowmanApp.swift.
Add a blank line after the end of Settings and insert this:
// 1
Window("Statistics", id: "stats") {
// 2
Text("Statistics will go here")
}
// 3
.keyboardShortcut("t", modifiers: .command)
This adds a new scene, but how?
- A
Windowis a type of scene that presents a single window. WhereWindowGroupcan open multiple copies of its window,Windowis more likeSettingsand only ever opens one, bringing it to the front if it’s already open. It has anidso you can refer to this window to open it programmatically, if required. - When developing in SwiftUI, using
Textas a placeholder view is a convenient way to set up and test an interface without having to have all the structures ready in advance. - This adds a keyboard shortcut of Command-T. The
keyboardShortcutmodifier changes the supplied letter to uppercase. The default modifier key is Command, so you can omit this argument, but leaving it in makes your intentions clearer.
You’re probably wondering where this keyboard shortcut appears, so press Command-R to run the app and open the Window menu:
Select the menu item or press Command-T to see the new window. Command-S would have been more logical for the Statistics menu item, but it’s always used for Save and even though this app doesn’t save, it’s a bad idea to change a standard shortcut to do something completely different.
You can open a single instance of the Statistics window, but its very boring.
Populating the Statistics Window
Now, you’ll replace the placeholder Text with a new view for your new window. In the Project navigator, select Views ▸ SettingsView.swift and press Command-N to make a new file.
Choose macOS ▸ User Interface ▸ SwiftUI View and click Next. Set the file name to StatsView.swift and click Create.
Replace the contents of body with:
// 1
TabView {
// 2
Text("Games view")
// 3
.tabItem {
// 4
Text("Games Won & Lost")
}
// 5
Text("Words view")
.tabItem {
Text("Length of Words")
}
}
// 6
.padding()
More tabs:
- Like with
SettingsView, you wrap the contents in aTabView. - This
Textis another placeholder until you create a new view to show here. - You add a
tabItemto configure the actual tab. - This is a standard Mac tab, not the special Settings type, so it only needs
Textand noImage. - Repeat the process to create a second tab.
- Add some padding to inset the tab view and its borders from the edges of the window.
Resume the preview to show your tabs with the first tab selected, but don’t run the app yet. You need to set the Window scene to use this view.
Open SnowmanApp.swift and replace Text("Statistics will go here") with:
StatsView()
Build and run the app, open your new window and switch between the tabs:
The tabs are in place, and now you can add some real content.
Passing Data to the New Window
If this window is to show any game data, it must be able to read games from appState.
First, open StatsView.swift and add this property at the top of the structure:
var games: [Game]
This tells StatsView that its parent view will pass it games. Any changes to games are published so this view will receive the updates.
Now that StatsView expects this property, the preview has an error. Replace the contents of previews with:
StatsView(games: [])
This provides the preview with an empty array of Game objects. The preview won’t show anything interesting, so you’ll run the app and play some games to see the new views in action.
Now, there’s an error in SnowmanApp because it isn’t providing the expected data.
Back in SnowmanApp.swift, click the red dot beside the error. (If the error isn’t visible yet, press Command-B to try to build the app, which displays the red marker.)
Click the Fix button in the error report, and Xcode adds the missing argument label and a placeholder. Type appState.games into the placeholder and you’ll end up with this:
StatsView(games: appState.games)
And now SnowmanApp passes its appState.games to StatsView, which is expecting it.
Next, you’ll add some views to use this data.
Adding the Subviews
You’ll add two new SwiftUI view files: one for each tab. Select StatsView.swift in the Project navigator and use the technique you used before to create two new SwiftUI View files called GameStats.swift and WordStats.swift.
You now have three views related to the Statistics window. They are already in the Views group, but you can make a sub-group to keep them together. Select StatsView.swift, GameStats.swift and WordStats.swift in the Project navigator. Right-click and choose New Group from Selection. Set the name of the new group to Statistics.
Now you’ve grouped your views like this:
This app won’t end up with hundreds of files, but developing organizational skills like this will make your life a lot easier when you start working on large projects.
Both of these new structures need access to games, so add this property to both GameStats and WordStats:
var games: [Game]
Both also need the argument for the previews, so change their previews to:
GameStats(games: [])
And:
WordStats(games: [])
These views will end up showing charts, but for now, make them show text so you can see the data changing.
Showing the Game Statistics
Open GameStats.swift and add this computed property:
// 1
var gameReport: String {
// 2
let wonGames = games.filter {
$0.gameStatus == .won
}
// 3
let lostGames = games.filter {
$0.gameStatus == .lost
}
// 4
return """
Games won: \(wonGames.count)
Games lost: \(lostGames.count)
"""
}
Stepping through this:
- The computed property returns a
String. - It uses
filterto find the games the player has won. - Another
filtergets the games that the player lost. Remember there may be one or more games in progress. - This is a multi-line string. Enclosing text inside three quotes lets you format a string over multiple lines. You’re using
countto get the number of each type of game.
Change the Text view to:
Text(gameReport)
This displays the new property in the Text view.
To use GameStats, open StatsView.swift and replace Text("Games view") with:
GameStats(games: games)
Run the app now, open the Statistics window and play a few games. Remember you’re still printing the random word in the Xcode console, so you can always cheat to make sure you get some wins and some losses. ;]
You can see the GameStats view updating as you play:
In the Settings view, data had to flow both ways, but these views never change the data, they only display it, reacting to any changes. And you’re passing only the required data, which is the games array.
Showing the Words Statistics
Adding data to WordStats is a similar process, so open WordStats.swift now. This view will list each completed game, showing how many letters were in each word.
Insert this computed property to supply the String:
// 1
var wordCountReport: String {
// 2
let completedGames = games.filter {
$0.gameStatus != .inProgress
}
// 3
let gameReports = completedGames.map { game in
// 4
let statusText = game.gameStatus == .won ? "won" : "lost"
// 5
return "\(game.id): \(game.word.count) letters - \(statusText)"
}
// 6
return gameReports.joined(separator: "\n")
}
This has some familiar code and some new code:
- Define a computed string property.
- Use
filterto get a list of completed games. - Next, use
mapto convertGameinstances to strings.mapworks likefilter, looping through each element in the array, but wherefilterincludes or excludes certain elements,maptransforms each of them into something else. - Apply the ternary operator to get a string showing the game status.
- Create a report for each game using string interpolation.
-
gameReportsis an array ofStrings. Merge this into a single string using thejoinedmethod.
Like before, change Text to:
Text(wordCountReport)
With this in place, open StatsView.swift and replace Text("Words view") with:
WordStats(games: games)
Run the app again. Play a few games and open the Statistics window. Check the data in both tabs, then play a few more games and confirm that the statistics update:
Now you have a secondary window, opening from the Window menu and receiving live game data.
Key Points
- A
Settingsscene adds a Settings… menu item and keyboard shortcut that you can link to any SwiftUI view to show as the user settings interface. - The
@AppStorageproperty wrapper saves and restores user settings. - SwiftUI has a variety of input views, so you can choose the ones that suit your data types.
- You add secondary windows using a
Windowscene, which adds an item to the Window menu.
Where to Go From Here
You’ve configured two different additional windows. The Settings window is complete, but the Statistics window only shows plain text reports.
In the next chapter, you’ll learn about SwiftUI’s Charts framework and add two styles of chart to display these two sets of statistics.