Creating a macOS App

You’ll start by adding a macOS target to your project, and then reusing views and data you created earlier. With a foundation in place, you’ll add some new views tailored to macOS, and modify others to work better across platforms.

Section 1

Add a macOS Target to the Project

Start by adding a macOS target to the project. Xcode adds a new group and set of starter files for the macOS app, along with the scheme needed to build and run the app. You’ll then add some existing files to the new target.

To be able to preview and run the app, be sure your Mac is running macOS Monterey or later.

An illustration showing Xcode creating projects for iPhone, Mac, and Apple Watch.

Step 1

Choose File > New > Target. When the template sheet appears, choose the macOS tab, select the App template, and click Next.

This template adds a new macOS app target to the project.

Step 2

In the sheet, enter MacLandmarks as the Product Name. Set the interface to SwiftUI, the life cycle to SwiftUI App, and the language to Swift, and then click Finish.

Step 3

Set the scheme to MacLandmarks > My Mac.

By setting the scheme to My Mac, you can preview, build, and run the macOS app. As you move through the tutorial, you’ll use the other schemes to keep an eye on how other targets respond to changes in shared files.

Step 4

In the MacLandmarks group, select ContentView.swift, open the Canvas, and click Resume to see the preview.

SwiftUI provides both a default main view and its preview provider, just like for an iOS app, enabling you to preview the app’s main window.

Step 5

In the Project navigator, delete the MacLandmarksApp.swift file from the MacLandmarks group. When asked, choose Move to Trash.

Like with the watchOS app, you don’t need the default app structure because you’ll reuse the one you already have.

Next, you’ll share view, model, and resource files from the iOS app with the macOS target.

Step 6

In the Project navigator, Command-click to select the following files: LandmarksApp.swift, LandmarkList.swift, LandmarkRow.swift, CircleImage.swift, MapView.swift, and FavoriteButton.swift.

The first of these is the shared app definition. The others are views that will work on macOS.

Step 7

Continue Command-clicking to select all the items in the Model and Resources folders, as well as Asset.xcassets.

These items define the app’s data model and resources.

Step 8

In the File inspector, add MacLandmarks to the Target Membership for the selected files.

Add a macOS app icon set to match those for the other targets.

Step 9

Select the Assets.xcasset file in the MacLandmarks group and delete the empty AppIcon item.

You’ll replace this in the next step.

Step 10

Drag the AppIcon.appiconset folder from the downloaded projects’ Resources folder into the MacLandmark’s Asset catalog.

Step 11

In ContentView in the MacLandmarks group, add LandmarkList as the top level view, with constraints on the frame size.

The preview no longer builds because the LandmarkList uses LandmarkDetail, but you haven’t defined a detail view for the macOS app yet. You’ll take care of that in the next section.

ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        LandmarkList()
            .frame(minWidth: 700, minHeight: 300)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .environmentObject(ModelData())
    }
}
Section 2

Create a macOS Detail View

The detail view displays information about the selected landmark. You created a view like this for the iOS app, but different platforms require different approaches to data presentation.

Sometimes you can reuse a view across platforms with small adjustments or conditional compilation, but the detail view differs enough for macOS that it’s better to create a dedicated view. You’ll copy the iOS detail view as a starting point, and then modify it to suit the larger display of macOS.

An illustration of the app's detail view, including the map and circle views placed along with the text for a given landmark.

Step 1

Create a new custom view in the MacLandmarks group targeting macOS called LandmarkDetail.

You now have three files called LandmarkDetail.swift. Each serves the same purpose in the view hierarchy, but provides an experience tailored to a particular platform.

Step 2

Copy the iOS detail view contents into the macOS detail view.

The preview fails because the navigationBarTitleDisplayMode(_:) method isn’t available in macOS.

Step 3

Delete the navigationBarTitleDisplayMode(_:) modifier and add a frame modifier to the preview so you can see more of the content.

The MapView remains blank unless you start the live preview.

The changes you’ll make in the next few steps improve the layout for the larger display of a Mac.

Step 4

Change the HStack holding the park and state to a VStack with leading alignment, and remove the Spacer.

Step 5

Enclose everything below MapView in a VStack, and then place the CircleImage and the rest of the header in an HStack.

Step 6

Remove the offset from the circle, and instead apply a smaller offset to the entire VStack.

Step 7

Add a resizable() modifier to the image, and constrain the CircleImage to be a bit smaller.

Step 8

Constrain the ScrollView to a maximum width.

This improves readability when the user makes the window very wide.

Step 9

Change the FavoriteButton to use the plain button style.

Using the plain style here makes the button look more like the iOS equivalent.

The larger display gives you more room for additional features.

Step 10

Add an “Open in Maps” button in a ZStack so that it appears on top of the map in the upper-right corner.

Be sure to include MapKit to be able to create the MKMapItem that you send to Maps.

LandmarkDetail.swift

import SwiftUI
import MapKit

struct LandmarkDetail: View {
    @EnvironmentObject var modelData: ModelData
    var landmark: Landmark

    var landmarkIndex: Int {
        modelData.landmarks.firstIndex(where: { $0.id == landmark.id })!
    }

    var body: some View {
        ScrollView {
            ZStack(alignment: Alignment(horizontal: .trailing, vertical: .top)) {
                MapView(coordinate: landmark.locationCoordinate)
                    .ignoresSafeArea(edges: .top)
                    .frame(height: 300)

                Button("Open in Maps") {
                    let destination = MKMapItem(placemark: MKPlacemark(coordinate: landmark.locationCoordinate))
                    destination.name = landmark.name
                    destination.openInMaps()
                }
                .padding()
            }

            VStack(alignment: .leading, spacing: 20) {
                HStack(spacing: 24) {
                    CircleImage(image: landmark.image.resizable())
                        .frame(width: 160, height: 160)

                    VStack(alignment: .leading) {
                        HStack {
                            Text(landmark.name)
                                .font(.title)
                            FavoriteButton(isSet: $modelData.landmarks[landmarkIndex].isFavorite)
                                .buttonStyle(.plain)
                        }

                        VStack(alignment: .leading) {
                            Text(landmark.park)
                            Text(landmark.state)
                        }
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                    }
                }

                Divider()

                Text("About \(landmark.name)")
                    .font(.title2)
                Text(landmark.description)
            }
            .padding()
            .frame(maxWidth: 700)
            .offset(y: -50)
        }
        .navigationTitle(landmark.name)
    }
}

struct LandmarkDetail_Previews: PreviewProvider {
    static let modelData = ModelData()

    static var previews: some View {
        LandmarkDetail(landmark: modelData.landmarks[0])
            .environmentObject(modelData)
            .frame(width: 850, height: 700)
    }
}
A screenshot from the Xcode preview showing the Open in Maps button overlaid on top of the map near the upper right of the display.
Section 3

Update the Row View

The shared LandmarkRow view works in macOS, but it’s worth revisiting to look for improvements given the new visual environment. Because this view is used by all three platforms, you need to be careful that any changes you make work across all of them.

An illustration of a single row that includes a thumbnail image, a landmark name, and the name of the park in which the landmark resides.

Before modifying the row, set up a preview of the list, because the changes you’ll make are driven by how the row looks in context.

Step 1

Open LandmarkList.swift and add a minimum width.

This improves the preview, but also ensures that the list never becomes too small as the user resizes the macOS window.

Step 2

Pin the list view preview so that you can see how the row looks in context as you make changes.

Step 3

Open LandmarkRow.swift and add a corner radius to the image for a more refined look.

Step 4

Wrap the landmark name in a VStack and add the park as secondary information.

Step 5

Add vertical padding around the contents of the row to give each row a little more breathing room.

The updates improve the look in macOS, but you also need to consider the other platforms that use the list. Consider watchOS first.

Step 6

Choose the WatchLandmarks target to see a watchOS preview of the list.

The minimum row width isn’t appropriate here. Because of this and other changes you’ll make to the list in the next section, the best solution is to create a watch-specific list that omits the width constraint.

Step 7

Add a new SwiftUI view to the WatchLandmarks Extension folder called LandmarkList.swift that targets only WatchLandmarks Extension, and remove the older file’s WatchLandmarks Extension target membership.

Step 8

Copy the contents of the old LandmarkList into the new one, but without the frame modifier.

The content now has the right width, but each row has too much information.

Step 9

Go back to LandmarkRow and add an #if condition to prevent the secondary text from appearing in a watchOS build.

For the row, using conditional compilation is appropriate because the differences are small.

Finally, consider how your changes work for iOS.

Step 10

Choose the Landmarks build target to see what the list looks like for iOS.

The changes work well for iOS, so there’s no need to make any updates for that platform.

A screenshot of the Xcode preview of the iOS target, showing the list of landmarks now including both the name and park, as well as the image and optional favorite icon.
Section 4

Update the List View

Like LandmarkRow, LandmarkList already works on macOS, but could use improvements. For example, you’ll move the toggle for showing only favorites to a menu in the toolbar, where it can be joined by additional filtering controls.

The changes you’ll make will work for both macOS and iOS, but will be difficult to accommodate on watchOS. Fortunately, in the previous section you already split the list into a separate file for watchOS.

An illustration showing several landmark rows, each with a thumbnail image, landmark and park names, and favorite icon.

Step 1

Return to the MacLandmarks scheme, and in the LandmarkList file that targets iOS and macOS, add a ToolbarItem containing a Menu inside a new toolbar modifier.

You won’t be able to see the toolbar updates until you run the app.

Step 2

Move the favorites Toggle into the menu.

This moves the toggle into the toolbar in a platform-specific way, which has the additional benefit of making it accessible no matter how long the list of landmarks gets, or how far down the user scrolls.

With more room available, you’ll add a new control for filtering the list of landmarks by category.

Step 3

Add a FilterCategory enumeration to describe filter states.

Match the case strings to the Category enumeration in the Landmark structure so that you can compare them, and include an all case to turn filtering off.

Step 4

Add a filter state variable, defaulting to the all case.

By storing the filter state in the list view, the user can open multiple list view windows, each with its own filter setting, to be able to look at the data in different ways.

Step 5

Update filteredLandmarks to take into account the new filter setting, combined with the category of a given landmark.

Step 6

Add a Picker to the menu to set the filter category.

Because the filter has only a few items, you use the inline picker style to make them all appear together.

Step 7

Update the navigation title to match the state of the filter.

This change will be useful in the iOS app.

Step 8

Add a second child view to the NavigationView as a placeholder for the second view in wide layouts.

Adding the second child view automatically converts the list to use the sidebar list style.

Step 9

Run the macOS target and see how the menu operates.

Step 10

Choose the Landmarks build target, and use the live preview to see that the new filtering works well for iOS as well.

A screenshot of the iOS live preview in Xcode with the menu item selected.
Section 5

Add a Built-in Menu Command

When you create an app using the SwiftUI life cycle, the system automatically creates a menu with commonly needed items, like those for closing the front-most window or for quitting the app. SwiftUI lets you add other common commands with built-in behavior, as well as completely custom commands.

In this section, you’ll add a system-provided command that lets the user toggle the sidebar, to be able to get it back after dragging it closed.

An illustration of the landmark commands file as the source of this app's sidebar commands.

Step 1

Return to the MacLandmarks target, run the macOS app, and drag the separator between the list and detail view all the way to the left.

When you let go of the mouse button, the list disappears with no way to get it back. You’ll add a command to fix this.

Step 2

Add a new Swift file called LandmarkCommands.swift and set its targets to include both macOS and iOS.

You also target iOS because the shared LandmarkList will eventually depend on some of the types you define in this file.

Step 3

Import SwiftUI and add a LandmarkCommands structure that conforms to the Commands protocol, with a computed body property.

Like a View structure, a Commands structure requires a computed body property that uses builder semantics, except with commands instead of views.

Step 4

Add a SidebarCommands command to the body.

This built-in command set includes the command for toggling the sidebar.

To make use of commands in an app, you have to apply them to a scene, which you’ll do next.

Step 5

Open the LandmarksApp.swift file, and apply LandmarkCommands using the commands(content:) scene modifier.

Scene modifiers work like view modifiers, except that you apply them to scenes instead of views.

Step 6

Run the macOS app again, and see that you can use the View > Toggle Sidebar menu command to restore the list view.

Unfortunately, the watchOS app fails to build because Commands has no watchOS availability. You’ll fix that next.

Step 7

Add a condition around the commands modifier to omit it for the watchOS app.

The watchOS app builds again.

LandmarksApp.swift

import SwiftUI

@main
struct LandmarksApp: App {
    @StateObject private var modelData = ModelData()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(modelData)
        }
        #if !os(watchOS)
        .commands {
            LandmarkCommands()
        }
        #endif

        #if os(watchOS)
        WKNotificationScene(controller: NotificationController.self, category: "LandmarkNear")
        #endif
    }
}
Section 6

Add a Custom Menu Command

In the previous section, you added a built-in menu command set. In this section, you’ll add a custom command for toggling the favorite status of the currently selected landmark. To know which landmark is currently selected, you’ll use a focused binding.

An illustration of a selected landmark row, calling out the connection between the star on the right and the landmark's favorite state.

Step 1

In LandmarkCommands, extend the FocusedValues structure with a selectedLandmark value, using a custom key called SelectedLandmarkKey.

The pattern for defining focused values resembles the pattern for defining new Environment values: Use a private key to read and write a custom property on the system-defined FocusedValues structure.

Step 2

Add a @FocusedBinding property wrapper to track the currently selected landmark.

You’re reading the value here. You’ll set it later in the list view, where the user makes the selection.

Step 3

Add a new CommandMenu to your commands called Landmarks.

You’ll define content for the menu next.

Step 4

Add a button to the menu that toggles the selected landmark’s favorite status, and that has an appearance that changes depending on the currently selected landmark and its state.

Step 5

Add a keyboard shortcut for the menu item with the keyboardShortcut(_:modifiers:) modifier.

SwiftUI automatically shows the keyboard shortcut in the menu.

The menu now contains your new command, but you need to set the selectedLandmark focused binding for it to work.

Step 6

In LandmarkList.swift, add a state variable for the selected landmark and a computed property that indicates the index of the selected landmark.

Step 7

Initialize the List with a binding to the selected value, and add a tag to the navigation link.

The tag associates a particular landmark with the given item in the ForEach, which then drives the selection.

Step 8

Add the focusedValue(_:_:) modifier to the NavigationView, providing a binding the value from the landmarks array.

You perform a look-up here to ensure that you are modifying the landmark stored in the model, and not a copy.

Step 9

Run the macOS app and try out the new menu item.

Section 7

Add Preferences with a Settings Scene

Users expect to be able to adjust settings for a macOS app using the standard Preferences menu item. You’ll add preferences to MacLandmarks by adding a Settings scene. The scene’s views define the contents of the preferences window, which you’ll use to control the initial zoom level of the MapView. You communicate the value to the map view, and store it persistently, by using the @AppStorage property wrapper.

A diagram showing the effect of three different zoom settings on a map view.

You’ll start by adding a control in the MapView that sets the initial zoom to one of three levels: near, medium, or far.

Step 1

In MapView.swift, add a Zoom enumeration to characterize the zoom level.

Step 2

Add an @AppStorage property called zoom that takes on the medium zoom level by default.

Use a storage key that uniquely identifies the parameter like you would when storing items in UserDefaults, because that’s the underlying mechanism that SwiftUI relies on.

Step 3

Change the longitude and latitude delta used to construct the region property to a value that depends on zoom.

To ensure that SwiftUI refreshes the map whenever delta changes, you’ll have to alter the way you calculate and apply the region.

Step 4

Replace the region state variable, the setRegion method, and the map’s onAppear modifier with a computed region property that you pass to the Map initializer as a constant binding.

Next, you’ll create a Settings scene that controls the stored zoom value.

Step 5

Create a new SwiftUI view called LandmarkSettings that targets only the macOS app.

Step 6

Add an @AppStorage property that uses the same key as you used in the map view.

Step 7

Add a Picker that controls the zoom value through a binding.

You typically use a Form to arrange controls in your settings view.

Step 8

In LandmarksApp.swift, add the Settings scene to your app, but only for macOS.

Step 9

Run the app and try setting the preferences.

Notice that the map refreshes whenever you change the zoom level.