Chapters

Hide chapters

macOS Apprentice

First Edition · macOS 13 · Swift 5.7 · Xcode 14.2

Section II: Building With SwiftUI

Section 2: 6 chapters
Show chapters Hide chapters

Section III: Building With AppKit

Section 3: 6 chapters
Show chapters Hide chapters

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:

Snowman menu
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?

  1. Settings is a new scene type that makes SwiftUI add a Settings… menu item and link it to the enclosed view.
  2. 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:

Snowman menu with Settings.
Snowman menu with Settings.

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:

Settings window with placeholder.
Settings window with placeholder.

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!

  1. The @AppStorage property 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.
  2. The name in brackets after the property wrapper name is an identifier that the user configuration storage system uses.
  3. 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.
  4. The app uses the default values if the user hasn’t set anything yet. maxWordLength is a Double — 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:

  1. A Form is another way of grouping SwiftUI views. You’ve used HStack and VStack before. This is a more specialized group type designed for settings and similar data entry interfaces.
  2. A Stepper is an interface element for incrementing and decrementing numbers.
  3. Its value is bound to the minWordLength property. The $ shows that this works both ways, so changing the property adjusts the stepper and changing the stepper edits the property.
  4. The in argument is a range to limit its upper and lower limits. The lower end is set to 3, and the upper end uses maxWordLength. Since this is a Double, you convert it to an Int first.
  5. Inside the curly braces, you have the label argument. 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:

Stepper in Settings window.
Stepper in Settings window.

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:

Stage Manager settings
Stage Manager settings

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?

  1. First, you wrap the Form view in a TabView. A TabView can have multiple views and shows a tab for each one. This tab view only has one view: the Form.
  2. The tab shows the Form view, and you use a tabItem modifier to configure the tab bar display.
  3. 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:

Tab view in Settings.
Tab view in 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:

Settings preview
Settings preview

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:

Adding a view from the library.
Adding a view from the library.

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:

Slider autocomplete options
Slider autocomplete options

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:

Slider argument errors
Slider argument errors

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:

Toggle style options
Toggle style options

Choose switch and check the preview, using Shift-Command-P to resume it if necessary:

Settings preview
Settings preview

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:

Settings formatted.
Settings formatted.

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?

  1. 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.
  2. If useProperNouns is true, return true to include all the remaining words.
  3. 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 a String has a startIndex property that you can use as the index value to find the first character.
  4. Apply a Character method to check if the first letter is uppercase. The not operator reverses the result and returns it. So if the first letter is uppercase, isUppercase is true and applying ! changes it to false to 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:

Applying Settings
Applying Settings

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?

  1. A Window is a type of scene that presents a single window. Where WindowGroup can open multiple copies of its window, Window is more like Settings and only ever opens one, bringing it to the front if it’s already open. It has an id so you can refer to this window to open it programmatically, if required.
  2. When developing in SwiftUI, using Text as a placeholder view is a convenient way to set up and test an interface without having to have all the structures ready in advance.
  3. This adds a keyboard shortcut of Command-T. The keyboardShortcut modifier 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:

Statistics menu item
Statistics menu item

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:

  1. Like with SettingsView, you wrap the contents in a TabView.
  2. This Text is another placeholder until you create a new view to show here.
  3. You add a tabItem to configure the actual tab.
  4. This is a standard Mac tab, not the special Settings type, so it only needs Text and no Image.
  5. Repeat the process to create a second tab.
  6. 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:

Statistics tabs with placeholders.
Statistics tabs with placeholders.

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.)

Xcode error fix
Xcode error fix

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:

Statistics group
Statistics group

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:

  1. The computed property returns a String.
  2. It uses filter to find the games the player has won.
  3. Another filter gets the games that the player lost. Remember there may be one or more games in progress.
  4. This is a multi-line string. Enclosing text inside three quotes lets you format a string over multiple lines. You’re using count to 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:

Wins and Losses
Wins and Losses

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:

  1. Define a computed string property.
  2. Use filter to get a list of completed games.
  3. Next, use map to convert Game instances to strings. map works like filter, looping through each element in the array, but where filter includes or excludes certain elements, map transforms each of them into something else.
  4. Apply the ternary operator to get a string showing the game status.
  5. Create a report for each game using string interpolation.
  6. gameReports is an array of Strings. Merge this into a single string using the joined method.

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:

Word length statistics
Word length statistics

Now you have a secondary window, opening from the Window menu and receiving live game data.

Key Points

  • A Settings scene adds a Settings… menu item and keyboard shortcut that you can link to any SwiftUI view to show as the user settings interface.
  • The @AppStorage property 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 Window scene, 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.

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.