Home iOS & Swift Books macOS by Tutorials

2
Working With Windows Written by Sarah Reichelt

In the previous chapter, you looked at the API that will provide the data for the On This Day app. You worked out how to parse the incoming JSON, and you designed the data models.

In this chapter, you’ll get to use all that preparatory work as you create the main window for your app.

A great number of macOS apps like Finder, Mail and even Xcode, have a window design with a navigation side bar on the left and a larger detail view on the right. You’re going to use that style in the app you’ll build in this chapter.

A New Mac App

So at last you get to the fun bit — creating your own macOS app. Start Xcode and create a new project.

Create a new macOS project.
Create a new macOS project.

Choose the macOS App template, click Next and name the app OnThisDay. Select SwiftUI for the interface and Swift for the language. Click Next again and save your new project.

Before making any changes, build and run the app to see what features you already have. At first glance, it might seem that you’ve very little — just a rather small window showing the default “Hello, world!” text — but take a closer look:

Build and run the app template.
Build and run the app template.

You can resize the window by a small amount, minimize it, make it full screen (which looks rather odd at this stage). You can close the window and open a new one. In fact, you can open lots of new windows. When you have a few windows open, take a look at the Window menu. All the usual window and tab tools are already there and available to you.

Window menu
Window menu

The only control on this window is a static Text view, so there is nothing to edit, but the Edit menu is there and ready for action, as are all the other standard menus that people expect to see in a Mac app.

Comparing with an iOS App

Quit the app and go back to look at the Xcode project, which has two Swift files:

Swift files in template
Swift files in template

Interestingly, these two files are absolutely identical to the same two files you’d get if you created an iOS app from the basic iOS app template.

This serves to emphasize an important point. Swift and SwiftUI are extremely similar across all Apple’s platforms. The differences are in how SwiftUI displays the different views based on the selected platform. Your job as a macOS developer is to understand the expectations of the users on the macOS platform so that your app ends up looking like a macOS app and not like an iOS port.

Windows are a major area where the platforms diverge. iPhone apps are single window apps. iPad apps can have multiple windows but without the enormous flexibility that macOS apps can have. In both iPhones and iPads, apps always use the full screen, unlike the Mac.

As you work through the chapters in this section, you’ll encounter more areas where Mac users have different expectations. However, in this chapter, you’re going to concentrate on the main window and its design. But first, it’s time to add in all your hard work from the last chapter.

Adding the Data Models

Download the materials for this chapter and open the assets folder. Select and drag the Models and Utilities folders into Xcode’s Project navigator, checking Copy items if needed and selecting Create groups:

Options when adding Models and Utilities
Options when adding Models and Utilities

Make sure that you have selected the OnThisDay target and click Finish.

After expanding the new groups, your Project navigator looks like this:

After adding the models
After adding the models

Note: If you don’t see the file extensions, change the setting in Xcode preferences:

Hide or show file extensions.
Hide or show file extensions.

These files contain the data models that you developed in the last chapter but split up into separate files for clarity and maintainability. I wrapped getDataForDay(month:day:) in a Networker enumeration for easy use. AppState.swift contains more methods that you’ll use later on. They are not macOS-specific so this book won’t cover them in detail, but they are all documented in the comments if you want to check them out.

Designing the Window

Your app’s main window is going to have a sidebar and a detail area. The sidebar will allow you to select which of the three types of events to display and the detail area will show these events in a grid.

In an iOS app, you’d create such a system using a NavigationView with a NavigationLink for each event type leading to a detail view. This works really well on iOS devices where the detail view either replaces or overlays the navigation view, but it doesn’t work well on Macs. So forget everything you know about NavigationLink; you aren’t going to use it here. :]

In a macOS app with multiple panes, you still start with a NavigationView but, because the window can show all the panes all the time, you have to initialize NavigationView with all its panes already in place. Then, you use a selection property to work out what to display in the detail view.

To illustrate this, open ContentView.swift and replace the contents of body with this:

// 1
NavigationView {
  // 2
  Text("Fake sidebar")
  Text("Fake details")
}
// 3
.frame(
  minWidth: 700,
  idealWidth: 1000,
  maxWidth: .infinity,
  minHeight: 400,
  idealHeight: 800,
  maxHeight: .infinity)

What does this do?

  1. Wraps the entire display inside a NavigationView.
  2. Adds placeholder views for each of the panes.
  3. Sets limits for the size of a window. This frame modifier sets a lower limit and an ideal size but allows the window to expand infinitely.

Build and run the app, and you’ll see that already you’ve got what looks like a real macOS app with two panes:

Two pane layout
Two pane layout

Note: This also appears in the preview canvas, without the app title, but you need the actual app window for the next task.

Sizing the Window

You used a frame modifier to set the size for your window. This isn’t something that iOS developers need to worry about — iOS apps use all the available space — but it’s very important for a Mac app. The modifier sets three values for the width and three for the height: the minimum, ideal and maximum sizes.

The maximum settings are easy; for the main window of an app, there’s rarely any reason not to allow infinite expansion. For particular sub-windows, you might prefer to set an exact size or to limit the size, but you can nearly always set maxWidth and maxHeight to .infinity.

The ideal settings are your suggestions to the SwiftUI layout system for what every new window could use. In practice, the ideal sizes are not applied, but put them in anyway as this may change. Open windows will store their sizes and re-apply them when the app is re-opened.

The minimum values are the most important: They set the smallest allowed size for the window. You need to make sure these numbers are big enough to show everything important but not any bigger than necessary. You don’t know what size screens people are working with.

But how can you determine the numbers to use? One way is to take a screenshot of the window. Set it to the size you want, press Shift-Command-4, mouse over the window and press Space to highlight it. Hold down Option to turn off the usual screenshot shadow and then click the window to capture it.

To see the screenshot’s details, open it in Preview and press Command-I or select it in Finder and press Command-I, then open More Info:

Screenshot info
Screenshot info

Image size shows width by height in pixels. If you’re using a Retina screen, divide these numbers by 2 to get SwiftUI size units.

Do this twice to get the minimum and ideal sizes and plug your numbers into the frame modifier. You may want to repeat this procedure later, when you have finalized the interface.

With the app running, you can resize the window within the set limits, make new windows, close them, merge them into tabs and do all the usual Mac window operations.

It’s also possible to resize the panes by dragging the divider sideways. If you drag the divider all the way to the right and let it go, the details pane disappears, but you can position your pointer at the edge of the window and drag it back. However, there is a bug in SwiftUI that means if you hide the sidebar, it’s not possible to get it back!

If you lose your sidebar, close the window and open a new one. In the next chapter, you’ll learn about menus and toolbars and find out better ways to restore the sidebar, but for now, simply make a new window.

Setting up the Sidebar

Now that you’ve got the skeleton of the window in place, you can start replacing those placeholders with the real thing.

For the sidebar, create a new SwiftUI View file called SidebarView.swift.

This view will loop through the cases in EventType you added as part of Event.swift, display the title for each and allow the user to switch between them. But if you want to loop through the cases of an enum, it must conform to CaseIterable.

Over in Event.swift, edit the EventType declaration so it looks like this:

enum EventType: String, CaseIterable {

There is no need to do anything else, but now you can use EventType.allCases to loop through these types.

Back in SidebarView.swift, replace the contents of SidebarView with this:

// 1
@Binding var selection: EventType?

var body: some View {
  // 2
  List(selection: $selection) {
    // 3
    Section("TODAY") {
      // 4
      ForEach(EventType.allCases, id: \.self) { type in
        Text(type.rawValue)
      }
    }
  }
  // 5
  .listStyle(.sidebar)
}

What’s happening here?

  1. In order to track the selected event type, you need a property. The parent ContentView will supply this.
  2. The property is bound to the list as its selection.
  3. A Section adds a collapsible heading.
  4. ForEach loops through the event types and shows their names.
  5. Setting the list style to .sidebar makes it slightly translucent.

The preview code shows an error now because it has no selection property. And since this is going to be in a sidebar, which will usually be quite narrow, it makes sense to make the preview narrow, so edit previews in SidebarView_Previews to contain this:

SidebarView(selection: .constant(nil))
  .frame(width: 200)

Instead of building and running now, make sure that the preview canvas is showing and click Resume or press Option-Command-P to make it display your changes:

Sidebar Preview
Sidebar Preview

Previewing macOS Views

You’ve just used the preview canvas to see how your sidebar view will look in your app. When you’re working on an iPhone app, you see an outline of the iPhone in the preview, and you’re able to turn on Live Preview to make this view interactive. The same features are available when developing macOS apps, but they work a bit differently.

As with iOS apps, you can click the Plus button in the toolbar over the preview to create a second preview. Then, you can use the Modifiers button to set up how you want the previews to look. Having one preview showing the light color scheme and one the dark can be very useful.

The Mac differences appear when you want to have a live preview. With your sidebar showing in the preview canvas, click the Live Preview button. I’m not sure what you expected to see, but I’ll bet it wasn’t this!

Live Preview
Live Preview

Click Bring Forward to see another surprising sight:

Bring preview forward.
Bring preview forward.

Why are there two windows? When you run a live preview in an iOS app, Xcode runs the simulator in the background so you can interact with your view from inside the preview canvas. In a macOS app, Xcode actually runs the complete app and that is the larger window you see here. The smaller window titled Xcode Preview, which always appears in the bottom left corner of the screen, is the actual live preview.

Position your Xcode window so that you can watch the sidebar in the Xcode Preview window while you change the section header. Your change appears immediately.

Change section header during Live Preview.
Change section header during Live Preview.

Since it’s faster to build and run a macOS app than an iOS app running in the simulator, use the preview to build component views, but actually run the app to test interactions.

Note: For anyone interested in why the live preview window always appears anchored to the bottom left of the screen, I presume that this is because in AppKit (the macOS equivalent of UIKit), the screen coordinates origin is at the bottom left, unlike in UIKit where the origin is at the top left. Thankfully, SwiftUI saves you from having to wrestle with two different coordinate systems.

Now that you’ve designed your sidebar, it’s time to get ContentView to display it. In ContentView.swift, add a property to track the sidebar’s selection and set its default value to events:

@State private var eventType: EventType? = .events

Next, replace the first placeholder Text view with this:

SidebarView(selection: $eventType)

Now build and run the app. Select an event type: It highlights with your system accent color. Open System Preferences ▸ General and change the Accent color: The sidebar highlight color changes too. Move the window over a strongly colored background (like this bright orange wallpaper) to see that the sidebar is slightly translucent:

Build & run with the sidebar.
Build & run with the sidebar.

Note: If your sidebar isn’t translucent, verify your System Preferences ▸ Accessibility ▸ Display and uncheck Reduce transparency.

Creating an Event Card

The right pane is going to display the events in a grid, but before building the grid itself, you need to design a view for each event in the grid.

Add a new SwiftUI View file and call it EventView.swift.

Now that you have three view files, it would be good to put them into a group like the Models group. Select the three …View.swift files, right-click and choose New Group from Selection. Set the name of this new group to Views.

Create Views group.
Create Views group.

Each EventView will need an Event to display, so open EventView.swift and add this property definition at the top of the structure:

var event: Event

After a while, Xcode will work out that this causes an error in EventView_Previews. The preview’s EventView(event:) needs an Event value.

Adding Preview Content

To avoid hitting the usage limit on the API, you’ll use a sample event in the preview. Looking at the Xcode Project navigator, there is a group at the bottom called Preview Content. This is where you can put sample code and data for use in the previews but not in the final app.

Go back to the assets folder you downloaded and look for SampleEvent.swift. Drag it into the Preview Content group using the same settings as before. This file contains the JSON for a single event decoded into an Event. Since this is only used in the previews and not in the actual app, it uses force-unwrapping to avoid returning an optional. And now your previews will be able to access Event.sampleEvent whenever they need to display a preview with an event.

To fix the preview in EventView.swift, change the content of previews to:

EventView(event: Event.sampleEvent)

Building the Event Card

Take a look at Event.swift to remind yourself of its properties. You don’t need to display id as it’s only used for looping. You can show the text and year strings in Text views, but links will require some special treatment.

Start by displaying the strings: Replace the default Text view in EventView.swift with this:

VStack {
  Text(event.year)
  Text(event.text)
}

You wrap two Text views in a VStack for a very simple display.

Now, resume the preview canvas and you’ll see this:

Event card preview
Event card preview

The view is getting the data, but the formatting needs work.

Note: If you can’t see it all, select Editor ▸ Layout ▸ Canvas on Bottom or use the zoom controls underneath the preview canvas to adjust the preview .

Formatting the Event Card

Now to make EventView look like a card. Replace the VStack with this styled version:

// 1
HStack {
  // 2
  VStack(alignment: .leading, spacing: 30) {
    // 3
    Text(event.year)
      .font(.title)

    Text(event.text)
      .font(.title3)

    // links go here
    
    // 4
    Spacer()
  }
  // 5
  Spacer()
}
// 6
.padding()
.frame(width: 250)

This looks like a lot of code, but here’s what each bit is doing:

  1. Wrap the whole thing in an HStack so that a spacer can push everything to the left. This will be important for lining up views in cards with different amounts of text.
  2. Wrap the contents in a VStack, aligning everything to the left (or right, for right-to-left languages) and with a larger than standard space between subviews.
  3. Set the two Text views to use larger fonts.
  4. Push them to the top of the VStack with a Spacer.
  5. Another Spacer pushes the VStack to the left of the HStack.
  6. Set the HStack to a fixed width with padding all around.

And, when you resume the preview, you’ll see this, which already looks better:

Event card styled
Event card styled

Adding Links

Moving on to the EventLinks, each has a title and a URL. SwiftUI has a view that is perfect for this. Link takes a title and a URL and creates a clickable link that will open the URL in the appropriate default app. In this case, since the links are all web links, they will open in the user’s default browser.

If you’d been writing this app for iOS, you might have preferred to keep the user inside your app and used SFSafariViewController to display the linked web pages. This view is not available in macOS apps, but even if it was, Mac app users are quite accustomed to swapping between apps, where iOS app users tend to prefer to stay inside the current app. On a Mac, it’s easier when you can see windows from multiple apps at the same time.

Replace // links go here with this code:

// 1
VStack(alignment: .leading, spacing: 10) {
  // 2
  Text("Related Links:")
    .font(.title2)

  // 3
  ForEach(event.links) { link in
    Link(link.title, destination: link.url)
      // modifier goes here
  }
}

And what does this do?

  1. Wrap the links section in its own VStack so you can set a different spacing.
  2. Add a subheading for the links with a larger font.
  3. Loop through the event’s links and add a Link view for each. Because you made EventLink conform to Identifiable in the previous chapter, you don’t have to add an id parameter.

Resume the preview again to see this:

Event card with links
Event card with links

Now start Live Preview, click Bring Forward and, in the Xcode Preview window, click one of the links to open it in your browser.

Changing the Cursor Over Links

The styling is looking good now, but there’s a neat touch you can add. Users expect clickable links to change the mouse pointer as they mouse over them. SwiftUI has an onHover modifier that’s perfect for this.

Add this modifier to the Link view replacing // modifier goes here:

// 1
.onHover { inside in
  if inside {
    // 2
    NSCursor.pointingHand.push()
  } else {
    // 3
    NSCursor.pop()
  }
}

So how is this setting the cursor?

  1. The onHover action is called whenever the mouse pointer enters or leaves the view’s frame. The parameter passed to the closure is true if the mouse pointer is inside and false if it’s outside the view’s frame.
  2. If the mouse pointer is inside the frame, push the pointing hand cursor on to the top of the cursor stack, making it the active cursor.
  3. When the mouse pointer leaves the view, pop the cursor off the stack, reverting to the default cursor.

Test this in the live preview and watch the cursor change as you move the mouse around.

Pointing hand cursor
Pointing hand cursor

Using Live Data

You’re nearly ready to start displaying a grid, but first the app needs to download some data: the dictionary of Day objects in AppState. Since the entire app will use AppState, you’ll initialize it in OnThisDayApp.swift and access it as an EnvironmentObject.

Open OnThisDayApp.swift and add a StateObject at the top of the structure:

@StateObject var appState = AppState()

Next, you need to pass this on to ContentView, so add an environmentObject modifier to ContentView:

.environmentObject(appState)

The final piece of this data trail is to tell ContentView to expect this EnvironmentObject. Jump over to ContentView.swift and add this at the top of the structure:

@EnvironmentObject var appState: AppState

Now ContentView, and any of its subviews, can use appState.

ContentView will detect when appState publishes any changes to days but after that, it needs a way to query appState for the relevant data to use in its display.

Add this computed property to ContentView:

var events: [Event] {
  appState.dataFor(eventType: eventType)
}

This uses an AppState method for getting the data for a specific event type. It returns an empty array if there is nothing to show.

To prove this is working, replace the Text("Fake Details") placeholder with this:

Text("\(events.count)")

Before you can test this, you need to take care of something that’s specific to macOS apps.

Sandboxing

When the app initializes appState, it downloads the data for the current day, and ContentView updates its display to match. But if you try it right now, it won’t work because your app is sandboxed. iOS apps are sandboxed into their own area of memory and storage, so they cannot interfere with other apps or data without your permission. A macOS app has a similar sandbox but by default, it won’t even let you download data from the internet. iOS apps can download from any secure address without changing anything but, for some reason, macOS is more restrictive about this.

To allow downloads, select the project at the top of the Project navigator and then click the OnThisDay target in the sidebar. Choose the Signing & Capabilities tab.

Check Outgoing Connections (Client) in the App Sandbox section.

Check Outgoing Connections.
Check Outgoing Connections.

This enables the app to request and download data. You might think you need to turn on Incoming Connections (Server) to allow downloads, but the client setting is enough as your app initiates the download request.

Now build and run the app. You’ll see a zero in the middle of the screen and after a few seconds, this changes to a higher number, which tells you that ContentView now has data to display. Select the different event types in the sidebar to see different numbers:

Data check
Data check

Showing Data in a Grid

Now, you’re ready to put it all together. Add a new SwiftUI View file to the Views group and call it GridView.swift.

When setting up a grid layout, the first tasks are to specify the data and the column arrangement for the grid, so add these properties to GridView:

// 1
var gridData: [Event]

// 2
var columns: [GridItem] {
  [GridItem(.adaptive(minimum: 250, maximum: 250), spacing: 20)]
}

What do these properties give you?

  1. The parent view will pass an array of Events to GridView.
  2. The grid uses the columns property to work out its layout. Instead of setting a fixed number of rows or columns, this tells the grid to work out the best arrangement to fit the available space, setting the width of each column to 250 and spacing the columns at least 20 apart.

Now, you get an error in the preview because it doesn’t have a gridData parameter. Since you’re going to run the app to test this view, delete GridView_Previews to make Xcode happy. :]

Next, replace the default Text in body with this code:

// 1
ScrollView {
  // 2
  LazyVGrid(columns: columns, spacing: 15) {
    // 3
    ForEach(gridData) {
      EventView(event: $0)
        // 4
        .frame(height: 350, alignment: .topLeading)
        // styling modifiers go here
    }
  }
}

Not much code here for a lot of action, but stepping through, you:

  1. Wrap the content in a ScrollView. This allows scrolling to see entries that are outside the visible area of the window.
  2. Display the data in a LazyVGrid. This is a grid that draws itself in rows down the window, but it draws the components lazily — on demand — rather than all at the start. You configure the LazyVGrid to use the columns defined earlier and give it a spacing value to separate the rows.
  3. Inside the grid, loop through the events and display each using the EventView that you created earlier. As with the links, this is easier because the events all have an id property.
  4. Set the frame of each EventView so they are all the same height and aligned to the top left corner.

Back in Content View.swift, replace Text("\(events.count)") with:

GridView(gridData: events)

Build and run the app again. It starts up with a blank detail area but once the data arrive, you’ll see something like this:

Grid starter
Grid starter

It needs more styling, but the events are there. You can select different event types and open the related links. Notice how the spacers you added to EventView make the different events line up at their tops and left sides, regardless of the amount of text or the number of links. Great work!

Styling the Event Card

To style the grid elements into a more card-like view, you’re going to add a border with a shadow.

Go back to GridView.swift and replace // styling modifiers go here with:

// 1
.border(.secondary, width: 1)
// 2
.padding(.bottom, 5)
// 3
.shadow(color: .primary.opacity(0.3), radius: 3, x: 3, y: 3)

What do these modifiers do?

  1. You add a border using the secondary color, which will change depending on whether your Mac is in dark mode or light mode and in response to some accessibility settings.
  2. Since you’re about to add a shadow, you need some padding on the bottom to stop the grid cutting it off on the last row.
  3. You apply a shadow using the primary color with reduced opacity. This will also change with the color scheme and accessibility settings.

Build and run the app again. When it has some data, you’ll see a display like this:

Event cards with borders
Event cards with borders

The borders look great, but every element in the card has a shadow, not just the border. Also, you can now see that the grid starts too high up in the pane and, if you scroll to the end, it goes too close to the bottom, too.

Fixing the Styling

In GridView.swift, fix the positioning by adding this modifier to the ScrollView:

.padding(.vertical)

Note: Double-click on the curly brace at the end of the ScrollView line to select the entire view. This tells you where that view ends, so you know to put the new modifier on the line after that. Or use the code folding ribbon, if you have that enabled.

For the shadow problem, insert these modifiers before the border modifier you just added:

.background()
.clipped()

Without any parameters, background() fills the view with the default background color for the current color scheme.

clipped() stops the view contents overflowing the view’s frame, which is good for events with lots of text or links, but it also stops the shadow being applied to the inner views.

Note: It’s not clear why this works. The shadow being applied to all the internal views is apparently a bug and clipped() works around this bug, at least for now. Even after Apple fixes the shadow bug, you’ll still need to use clipped() to keep the view contents inside the frame.

Now build and run again. Select different event types, resize the window, scroll up and down the grid and test some links. You really have a useful app now.

Grid and event cards
Grid and event cards

Note: You may be wondering why you didn’t apply these modifiers to EventView directly. Later on, you’re going to reuse EventView for another purpose, and the modifiers you added in GridView won’t be used there.

Working with Multiple Windows

When you ran the app after creating it from the project template, you investigated opening multiple windows, and maybe you tried merging them into tabs.

Run the app again if it isn’t already running and open a second window. Notice how it shows the data immediately because it’s sharing the data in appState, which already contains today’s events.

Now change one of the windows to show a different type of event. See how this only changes the active window? Although the windows are sharing the appState EnvironmentObject, each one has its own ContentView and its own eventType selection.

Multiple windows, same title
Multiple windows, same title

Take a look in the Window menu. It lists all the open windows, but they all have the same title, so it’s impossible to determine which one is which. To solve this, you’re going to give each one a title based on the type of data it’s showing.

Setting the Window Title

In ContentView.swift, add this computed property to derive the window’s title from the selected event type, if there is one:

var windowTitle: String {
  if let eventType = eventType {
    return "On This Day - \(eventType.rawValue)"
  }
  return "On This Day"
}

This checks for a selection and appends its raw string value to the app name to create a window title. If there is no selection, it uses the app name as a default. List selection properties are always optionals, so even though you set eventType to a default value, you must still make this check.

To use the new property as the window’s title, add this modifier to the NavigationView below where you set its frame:

.navigationTitle(windowTitle)

Build and run the app again. If you had multiple windows open, they will all open again but reset to show Events as that’s the default type. Change the event types and notice how the window titles change to match. Open the Window menu and see how it lists the new titles. To confirm that the default title appears if there is no selection, Command-click on the selected event type in one of your windows to clear the selection.

Multiple windows
Multiple windows

Use Environment Overrides or change the color scheme of your Mac from light to dark or the other way round and confirm that your app adjusts too, and that everything still looks good.

Challenge

Take a look at EventView.swift, where you designed the cards for each event and think about how you could add some more style.

  • Would some of the text look good in a different color? Make sure you test your colors in both light and dark mode — you can add a second preview to show the second mode.
  • Add some SF Symbol icons to the card to decorate the various text elements. Download and install Apple’s SF Symbols app if you don’t have it already.

Have a look at the project in the challenge folder if you need some ideas, but there’s no wrong answer here — you make it look the way you would like it.

Key Points

  • Having already planned out all the data models, a SwiftUI app can come together quickly.
  • This multi-pane display style is common in macOS apps, but you don’t put it together the way you would in an iOS app.
  • Setting size limits for your windows is important.
  • SwiftUI Live Preview works differently for a macOS app.
  • The Mac sandbox blocks all internet connections by default.
  • Grids are a great way to display lots of data.

Where to Go From Here?

You started this chapter with some data models that could have worked just as well in an iOS app as a macOS app. You’ve ended up with an app that looks like a real native macOS app. Well done!

Along the way, you’ve learned important things about Mac apps and windows and how to work with SwiftUI in a Mac app.

In the next chapter, you’ll get to work with menus and toolbars to make your app into an even more Mac-like experience.

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.

© 2022 Razeware LLC