DEV Community

TrozWare
TrozWare

Posted on • Originally published at troz.net on

SwiftUI for Mac 2022

In December 2019, I wrote a series of articles about using SwiftUI to build a Mac app. And in July 2020, I re-visited the sample app to apply the new SwiftUI features made available in macOS BigSur. Now that macOS Ventura and Xcode 14 are in beta, it’s time to build the app again while learning how to incorporate the new APIs.

Right now, it’s June 2022 and I’m using macOS Ventura 13 beta 2 (22A5286j) with Xcode 14 beta 2 (14A5229c). There will undoubtedly be changes before release, but I’ll try to update this article or add notes to point out any major differences.

I don’t intend to cover features that haven’t changed much, but here are the new APIs that I am interested in:

Note : In Xcode 14 beta 2, the SwiftUI Previews are crashing unless I go to Project > Target > Signing & Capabilities and select my developer team and set Signing Certificate to Development. So if you can’t see the Previews, make these changes, or run the app directly.

Navigation

The first version of this app used a two pane layout with a sectioned list in the sidebar leading to a detail view in the main area. With the Big Sur version, I switched to a three pane layout because the sections were not displaying neatly. This time, I’ve reverted to two panes with sections. As before, I’m using HTTP Cats which is a site that serves up a cat image to match almost every HTTP status code.

Previous versions used NavigationLinks inside a NavigationView. NavigationView is now deprecated and we should use either NavigationStackView or NavigationSplitView. NavigationStackView is better suited to a small screen where each view is pushed on to a stack and can be popped off to navigate back. I am going to use NavigationSplitView which works well for bigger displays with all the panes visible at once.

My data model is exactly the same as I developed originally and consists of an array of HttpSections, each containing an array of HttpStatuses. So I was able to render my navigation like this:

NavigationSplitView {
  List(httpSections, selection: $selectedStatus) { section in
    Section(header: Text("\(section.headerCode) - \(section.headerText)")) {
      ForEach(section.statuses) { status in
        SidebarRowView(code: status.code, title: status.title)
          .tag(status)
      }
    }
    .font(.headline)
  }
  .frame(minWidth: 250)
} detail: {
  DetailView(sectionTitle: sectionHeader, httpStatus: selectedStatus)
}

Enter fullscreen mode Exit fullscreen mode

The NavigationSplitView takes various parameters, depending on the result you want. In this case, the first parameter lays out the sidebar, which is a List. The detail parameter contains the detail view. For the List, I loop through the sections, adding a section header for each one and a custom SidebarRowView to display the individual status entries. The List selection is bound to an optional HttpStatus. The key here is to give each row a tag that maps to the status displayed in that row. If you omit the tag, the sidebar draws but none of the entries are selectable.

Interestingly, when you create a navigation setup like this, Xcode automatically adds a toolbar with a button to toggle the sidebar. This has always been essential as there is a long-standing bug where a hidden sidebar cannot be dragged back into view. But adding it manually meant digging into AppKit to find a method to call.

Not so interestingly, the sidebar is often hidden on app launch. I tried giving NavigationStackView its optional columnVisibility parameter, but no setting made it appear consistently.

With the detail, right now there is a bug where you cannot unwrap a conditional and show a view based on that. One workaround is to wrap the entire detail in a ZStack, but in the interest of keeping my ContentView code simple and as short as possible, I added an intermediary view. DetailView takes optional parameters and it decides whether to show the StatusView or a placeholder. Note: the bug may have gone in beta 2, but as I want to display other UI elements in the detail view anyway, this is still a neater approach.

There is nothing much new in either of these views, except where StatusView downloads the selected cat image. I tried using AsyncImage which worked really well except for one problem. When I selected a new status, the old image stayed in place until the new one arrived. I was unable to work out how to clear or reset an AsyncImage so it would show its placeholder again.

However I did switch to using async/await to download the image. Updating this was also slightly complicated. I downloaded the first selected image using a task modifier and downloaded subsequent selections using an onChange modifier. Both of these were required to show the image every time.

.task {
  await getCatImage(status: httpStatus)
}
.onChange(of: httpStatus) { newStatus in
  catImage = nil
  Task {
    await getCatImage(status: newStatus)
  }
}

Enter fullscreen mode Exit fullscreen mode

The onChange was able to set the previous image to nil, so that the progress indicator appeared. I had to use the newStatus explicitly as onChange seems to be triggered before the property actually changes.

With all this in place, I had the basis of the app and the navigation was complete:

Navigation

Detecting the Active Window

macOS apps can have multiple windows open at once, and in previous iterations of SwiftUI, it has been difficult to detect the active one. Last year, we got @FocusedBinding but it didn’t really work. This year it works, but we also have a new EnvironmentValue called controlActiveState.

As I did last time, I set up some menus by adding a commands modifier to my WindowGroup in the main _App.swift file. First, I added the pre-built SidebarCommands() which added a menu item and keyboard shortcut for toggling the sidebar. Next, I added a menu item for flipping the image:

CommandGroup(after: .textEditing) {
  Button("Flip Image") {
    NotificationCenter.default.post(name: .flipImage, object: nil)
  }
  .keyboardShortcut("f")
}

Enter fullscreen mode Exit fullscreen mode

This new item appears in the Edit menu, after the standard items. It has a shortcut of Command-F. The Command key is the default shortcut modifier, so if you only specify a letter, it automatically uses Command. All this menu item does is to broadcast a notification.

The image is shown in a subview called CatImageView which now includes the following properties:

@Environment(\.controlActiveState) private var controlActiveState

private let flipImageMenuItemSelected = NotificationCenter.default
  .publisher(for: .flipImage)
  .receive(on: RunLoop.main)

@State private var imageIsFlipped = false

Enter fullscreen mode Exit fullscreen mode

The image has a rotation3Deffect modifier with the angle set to either 0 degrees, or 180 degrees, depending on the value of imageIsFlipped.

The view subscribes to the flipImage notification and receives it like this:

.onReceive(flipImageMenuItemSelected) { _ in
  if controlActiveState == .key || controlActiveState == .active {
    imageIsFlipped.toggle()
  }
}

Enter fullscreen mode Exit fullscreen mode

When the notification arrives, the code checks the value of controlActiveState. In my tests, this was always either key or inactive, but I saw that active was an option and added it too, in case it becomes relevant later. If the CatImageView is in the active window, it flips the image and if not, this notification is ignored.

Target Active Window

As you can see from the text in the images, only the front window image is flipped.

Opening New Windows

In early versions of SwiftUI, opening a secondary window was a very complex process. Read part 2 of the original series to see how I struggled with this. Later, we got an easy way to open a Preferences window, using a Settings scene. Preferences are now called Settings, which makes this nomenclature more logical.

Last year, we got the ability to use a NavigationLink as a SwiftUI menu item and this will open the destination view in a new window.

This year, we have a new method that uses another new EnvironmentValue called openWindow. First, in the _App.swift file, I added another new scene to the body - this time a second WindowGroup. A WindowGroup can be passed an id, a data object of a specific type, or both. In this case, I didn’t want to pass any data, so I gave it an id:

WindowGroup(id: "ui_samples") {
  SamplesView()
}

Enter fullscreen mode Exit fullscreen mode

I want to display a window with a view demonstrating some UI elements. The view is called SamplesView, so I set this as the content of the WindowGroup.

To use it, I added a property to DetailView:

@Environment(\.openWindow) private var openWindow

Enter fullscreen mode Exit fullscreen mode

And inserted a Button to call it:

Button("Show UI Samples") {
  openWindow(id: "ui_samples")
}

Enter fullscreen mode Exit fullscreen mode

This opens a new window with the appropriate id. This can open multiple copies of the same window. If I had passed a data object to the window, it would have brought the window containing that data object to the front instead of opening a new window. To test this behavior, I changed the WindowGroup to this:

WindowGroup(for: String.self) { _ in
  SamplesView()
}

Enter fullscreen mode Exit fullscreen mode

And changed the Button to:

Button("Show UI Samples") {
  openWindow(value: "samples")
}

Enter fullscreen mode Exit fullscreen mode

Now the WindowGroup expects a String. When you pass a String that has already been attached to a window, that window is brought to the front. If you pass a different String, you get a new window.

After that, I realized that there was another scene type I could use. If you create a Window scene instead of a WindowGroup, not only does this become a single presentation window, but you get a menu item for it in the Window menu without any extra work. You can add a keyboard shortcut to the Window scene too. Supposedly, you can add default sizing and positioning, but they don’t appear to work yet.

Window("Samples", id: "ui_samples") {
  SamplesView()
}
.keyboardShortcut("u")
.defaultPosition(.topLeading) // doesn't work yet
.defaultSize(width: 600, height: 600) // doesn't work yet

Enter fullscreen mode Exit fullscreen mode

Charts

One of the signature features of SwiftUI at WWDC 2022 was the new Charts API. I have struggled with charts in the past, so this was very interesting. I need to learn more, but I added a simple bar chart to the samples window:

Bar chart

This charts the number of entries in each category of HTTP status. I drew a line across at the 8 value because drawing a marker line is a feature I have often needed in charts.

Creating the chart was smooth:

Chart {
  ForEach(chartData.keys.sorted(), id: \.self) { key in
    BarMark(
      x: .value("Category", key),
      y: .value("Count", chartData[key] ?? 0)
    )
    .foregroundStyle(by: .value("Color", key))
  }

  RuleMark(y: .value("Threshold", 8))
    .foregroundStyle(.gray)
}

Enter fullscreen mode Exit fullscreen mode

Having assembled a dictionary of category codes and counts, I looped through the keys, setting an x and y value for each in its own BarMark. The line used a RuleMark.

To get the different colors, I added this modifier to the Chart:

.chartForegroundStyleScale([
  "1xx": .green,
  "2xx": .purple,
  "3xx": .blue,
  "4xx": .yellow,
  "5xx": .red
])

Enter fullscreen mode Exit fullscreen mode

which was used by foregroundStyle to assign a different color to each of the categories.

Next, I wanted to add the ability to switch between vertical and horizontal bars. This requires swapping the x and y data so that y has the category and x has the count. After testing this manually, I added a Boolean to specify the orientation, and a method for returning the appropriate BarMark:

func barMark(for key: String) -> BarMark {
  if drawHorizontal {
    return BarMark(
      x: .value("Count", chartData[key] ?? 0),
      y: .value("Category", key)
    )
  } else {
    return BarMark(
      x: .value("Category", key),
      y: .value("Count", chartData[key] ?? 0)
    )
  }
}

Enter fullscreen mode Exit fullscreen mode

A similar method provided the RuleMark as either a vertical or horizontal line.

A segmented picker switches between the two and the only thing now was to animate the change. There is an animation modifier added last year that allows you to connect an animation to a property, so that when the property changes, the animation is triggered.

.animation(.easeInOut, value: drawHorizontal)

Enter fullscreen mode Exit fullscreen mode

Animated chart

I added Text views for the title of the chart and for the axes, inside a VStack and an HStack, rotating the text for vertical axis. Maybe there is a chart setting that I’m missing for adding these labels automatically using the Chart library.

Image Rendering

While investigating charts, I experimented with another new SwiftUI feature: ImageRenderer. When I have made charts, I’ve often wanted to export them as images for upload. ImageRenderer allows us to convert a SwiftUI view into an image: on macOS, either NSImage or CGImage.

The first step was to convert the chart view plus its labels, into a variable (modifiers and RuleMark omitted from this code snippet for brevity):

let chartView = Group {
  Text("Status codes by category")
    .font(.title3).bold()
    .padding()

  HStack {
    Text(drawHorizontal ? "Category" : "Count").bold()
      .rotationEffect(.degrees(270))

    Chart {
      ForEach(chartData.keys.sorted(), id: \.self) { key in
        barMark(for: key)
      }
    }
  }

  Text(drawHorizontal ? "Count" : "Category").bold()
}

Enter fullscreen mode Exit fullscreen mode

Then I displayed the view using the variable:

VStack {
  chartView

  // Picker & Button
}

Enter fullscreen mode Exit fullscreen mode

This left the display unchanged, but allowed me to use this variable to create a view for rendering, omitting the controls that should not be part of the image:

Button("Save Chart as Image") {
  let view = chartView
    .padding()
    .frame(width: 1200, height: 800)

  let renderer = ImageRenderer(content: view)
  if let exportImage = renderer.nsImage {
    saveImage(exportImage)
  }
}

Enter fullscreen mode Exit fullscreen mode

I added padding and frame modifiers to the view variable as it came out very small without this. Then I created an ImageRenderer using this view and converted it into an NSImage. The saveImage method uses an NSSavePanel to get a URL and then convert the NSImage to JPG data before writing it out. I used the same technique in part 3 of the original series but that only exported the downloaded image. This creates an image from a complete SwiftUI view.

I would be interested to try the SwiftUI fileExporter, but that’s for another day.

I also tried to use the new ShareLink to share this image, but couldn’t get that to work. The ImageRenderer seems to work asynchronously so that didn’t work well with ShareLink. Again, that’s for another day.

Forms

SwiftUI has always had a Form object, but the new System Settings app (previously System Preferences) has taken this to a new level. So I added some UI elements to a second tab in the UI Samples window.

Form

There are a few interesting things here. Firstly, I achieved something I was unable to do last time and that is to make the text edit field have focus when the view opens. This was a three part operation:

Firstly, I added this property to the view:

@FocusState private var emailFieldHasFocus: Bool

Enter fullscreen mode Exit fullscreen mode

Then, I added this modifier to the edit field:

TextField("Enter your email address", text: $email)
  .focused($emailFieldHasFocus)

Enter fullscreen mode Exit fullscreen mode

And finally, I set the Boolean property in the onAppear modifier:

.onAppear {
  emailFieldHasFocus = true
}

Enter fullscreen mode Exit fullscreen mode

With this in place, the email text entry field had the focus whenever this view appeared.

The date picker is slightly confused. It can’t seem to decide whether it’s a graphical picker or a numeric picker, but it works. I haven’t tried the new date range picker yet.

With the check marks, I was trying to emulate the toggles in System Settings, where they are very small switches. I set the toggleStyle to switch which gave the right shape but it was big, like on an iPhone or iPad. The outermost form is set to use the columns formStyle. This lines up the labels and controls very neatly. But when I put the Toggles and Picker into an inner form with a style of grouped, I got the exact System Settings look I was going for.

As an aside, I love the way you can now apply control-specific modifiers to a container view and every appropriate view inside the container will use that setting. For example, I added .toggleStyle(.switch) to the Form view and it was applied to every Toggle inside it.

The final item in the form is a color picker. It opens up the standard macOS color picker and uses the selected color to fill the capsule beside it. The interesting thing is that the color has a gradient modifier. So you can see the faint gradient that is automatically applied to the shape.

Menu Bar Apps

Writing a menu bar app has previously required using AppKit to create a NSStatusItem. Now, we can add a MenuBarExtra scene to the app body to create a menu bar app component.

MenuBarExtra("HTTP Status Code", systemImage: "number.circle", isInserted: $showMenuBar) {
  MenuBarView(httpSections: $httpSections)
}

Enter fullscreen mode Exit fullscreen mode

Setting the title without a systemImage shows the text as the menu bar title. when you specify a systemImage, the text is not displayed and the menu bar only shows the image.

MenuBarExtra takes an isInserted parameter, so you can turn it off and on. I added this to the app’s settings and stored it using @AppStorage.

I set up a new SwiftUI view that looped through the HTTP Status data and created a submenu for each category and set this as the MenuBarExtra view. This works and my menu bar component was functional. Selecting a status uses NSWorkspace to open the relevant documentation page at MDN.

MenuBarExtra

Conclusion

2022 is an exciting year to be a SwiftUI programmer. Apple has made it very clear that this is the way forward and as early adopters, we have the ability to influence how the framework develops, so keep filing those feedback reports! There have been some great new features this year: navigation is better than ever, the platforms are unifying and macOS is not getting left behind.

The project from this article is available on GitHub. And as usual, I’d be thrilled to hear any suggestions, corrections or improvements. Please contact me using one of the links below or through the Contact page. And if you found this article, I’d love you to buy me a coffee.

Top comments (0)