Window Management with SwiftUI 4

Learnings from modernizing the window handling of my Mac app after upgrading to SwiftUI 4. Explaining `\.openWindow`, `.windowResizability` & more.

Window Management with SwiftUI 4
Photo by Mike Kononov / Unsplash

SwiftUI is getting significantly better every year. Last year (in 2022), we not only received improved navigation APIs. Apple also greatly improved the support for macOS – I would argue that the SwiftUI APIs we received for Mac app development in SwiftUI 4 are on a 1.0 level and finally allow for doing all sorts of things within SwiftUI without having to resort to AppKit for some of the most common tasks. I've experienced SwiftUI on the Mac while working on RemafoX using SwiftUI 3 and in some parts, it really was a nightmare.

Coming from iOS development, I was hoping not to have to learn all details of AppKit. But I had to write all sorts of hacky code to do the simplest things, like closing a window. Or disabling the full-screen button on a window. But there's good news: By increasing my app target to macOS 13.0, I could finally do window management through SwiftUI and eliminate all the hacks I've had in my app.

Finally, my app feels really 100% SwiftUI-driven. And here are all the new APIs I could make use of grouped and titled by the task I wanted to achieve. As window management is probably the biggest difference between iOS and macOS development in SwiftUI times, this article could also help anyone switching from iOS to macOS to understand how window management is done on the Mac.

Opening a Window

If you are using a WindowGroup (which was the only type of window available on SwiftUI 3), with SwiftUI 4 you have two options here: The first, which was already supported before, is to use the handlesExternalEvents method like so:

enum Window: String, Identifiable {
   case paywall
   // ...
   var id: String { self.rawValue }
}

@main
struct AppView: App {
   var body: some Scene {
      // ...
      WindowGroup("Plan Chooser") { ... }
         .handlesExternalEvents(matching: [Window.paywall.id])
      // ...
   }
}

Then, when you want to open this window, you would need to open a URL like you can open any external URL but with a custom URL scheme. For example:

@main
struct AppView: App {
   @Environment(\.openURL)
   var openURL

   var body: some Scene {
      // ...
      WindowGroup(...) { ... }
         .commands {
            CommandGroup(after: .windowArrangement) {
               Button("Show Plan Chooser") {
                  self.openURL(URL(string: "remafox://\(Window.paywall.id)")!)
               }
               .keyboardShortcut("1")
            }
         }
   }
}

This method doesn't work on the new Window type though, although it's fully available there, too. The documentation is clear about it:

This modifier is only supported for WindowGroup Scene types.

But the second method works for both WindowGroup & Window: The new \.openWindow environment value! First, we define an id in the initializer:

enum Window: String, Identifiable {
   case paywall
   // ...
   var id: String { self.rawValue }
}

@main
struct AppView: App {
   var body: some Scene {
      // ...
      WindowGroup("Plan Chooser", id: Window.paywall.id) { ... }
      // ...
   }
}

Then, we simply pass that id to openWindow to trigger presentation manually:

@main
struct AppView: App {
   @Environment(\.openWindow)
   var openWindow

   var body: some Scene {
      // ...
      WindowGroup(...) { ... }
         .commands {
            CommandGroup(after: .windowArrangement) {
               Button("Show Plan Chooser") {
                  self.openWindow(id: Window.paywall.id)
               }
               .keyboardShortcut("1")
            }
         }
      // ...
   }
}

This is much nicer! Note that there's also \.openDocument for DocumentGroup.

Prevent Duplicate Windows

Using id for a window does not prevent multiples of it from appearing:

At least not for WindowGroup, but you can simply use Window to ensure multiples of a window with the same id are never created! But that's not always an option.

A Window is much more restricted than a WindowGroup in various ways. For example, you can't call .commands on it like I've done above to set some buttons in the app's main menu. Also, Window is a macOS-only API, therefore you can't reuse the code on iPadOS. The reason I couldn't use it for RemafoX is the restriction I already mentioned before: Window does not support handlesExternalEvents. But I need that API because RemafoX is deeply integrated with Xcode through both an extension and a CLI tool, and those can't make use of \.openWindow because they simply are not part of the same target. But they can still open "external" URLs!

Whatever reason you might have to prevent duplicates of WindowGroup, do this:

WindowGroup("Plan Chooser", id: Window.paywall.id, for: String.self) { _ in
   // ...
} defaultValue: {
   Window.paywall.id
}

It's using an overloaded version of the WindowGroup initializer which takes additional for type and defaultValue arguments. It can be used to open a specific window related to any data type you want (like a Profile type), but I'm simply reusing the id of type String here to make the window unique.

In my case, I had to do this in several places, so I created this extension:

extension WindowGroup {
   init<W: Identifiable, C: View>(_ titleKey: LocalizedStringKey, uniqueWindow: W, @ViewBuilder content: @escaping () -> C)
   where W.ID == String, Content == PresentedWindowContent<String, C> {
      self.init(titleKey, id: uniqueWindow.id, for: String.self) { _ in
         content()
      } defaultValue: {
         uniqueWindow.id
      }
   }
}

With that, the above call with two Window.paywall.id calls became just this:

WindowGroup("Plan Chooser", uniqueWindow: Window.paywall) {
   // ...
}

For opening a window, we additionally pass the value param to openWindow:

Button("Show Plan Chooser") {
   self.openWindow(id: Window.paywall.id, value: Window.paywall.id)
}

Again, the multiple calls to Window.paywall.id bugged me, so I created a helper:

extension OpenWindowAction {
   func callAsFunction<W: Identifiable>(_ window: W) where W.ID == String {
      self.callAsFunction(id: window.id, value: window.id)
   }
}

Now I can simply call, even getting rid of the .id suffix:

self.openWindow(Window.paywall)

Closing a Window

Now that we can (uniquely) open a window, let's also close it. And here, the situation before was much worse than with WindowGroup, where we had some workaround within SwiftUI. There simply was no way of closing a window in SwiftUI directly. I had to implement this hack using AppKit:

struct WindowAccessor: NSViewRepresentable {
   @Binding
   var window: NSWindow?

   func makeNSView(context: Context) -> NSView {
      let view = NSView()
      DispatchQueue.main.async {
         self.window = view.window
      }
      return view
   }

   func updateNSView(_ nsView: NSView, context: Context) {}
}

@main
struct AppView: App {
   @State
   private var window: NSWindow?

   var body: some Scene {
      WindowGroup(...) {
         SomeView(...)
            .background(WindowAccessor(window: self.$window))
      }
   }
}

Then, when I wanted to close the window, I would call self.window.close().

In 2021, the \.dismiss environment value was added that allowed to dismiss presented views like sheet, popover or fullScreenCover right from within them. I'm not sure if this behavior was already available back then or added in 2022, but today the docs additionally state that the dismiss action can be used to:

Close a window that you create with WindowGroup or Window.

This certainly only works if there's currently no modal view presented within the window in question. But then it works like a charm, we can just write this:

@main
struct AppView: App {
   @Environment(\.dismiss)
   var dismiss

   var body: some Scene {
      WindowGroup(...) {
         // ...
         Button("Close") {
            self.dismiss()  // <= this closes the window if no modal
         }
      }
   }
}

Want to see your ad here? Contact me at ads@fline.dev to get in touch.

Disabling Full-Screen Button

Previously, I used the same hack mentioned above which gave me access to an NSWindow to do more configuration like disabling the full-screen button for views with a fixed size, like my welcome window or my about window. But now we have the new modifier windowResizability which allows us to disable the full-screen button indirectly. By default, it's set to contentMinSize for all windows except Settings (which is a Scene type like WindowGroup). But we can do this now:

@main
struct AppView: App {
   var body: some Scene {
      WindowGroup(...) {
         SomeView(...)
            .frame(maxWidth: 400, maxHeight: 400)
         }
         .windowResizability(.contentSize)
      }
   }
}

By setting the windowResizability to .contentSize, we tell SwiftUI to more strictly follow the sizes we provide in the frame modifier. As a logical consequence, if the maximum size specified by the view is smaller than the users current screen size, then SwiftUI will automatically disable the full-screen button for us! 🪄 It's not a very obvious or direct API, but it makes sense. Effectively, if we provide any values below 1366 by 768, which is the native size of an 11-inch MacBook Air (from 2015), we should have disabled the button for most users.

TCA Extras

If you are using The Composable Architecture (TCA) for your apps like me, you might ask yourself how you can best forward these new environment values to your reducers, as logic such as opening/closing windows should happen in those. The great community around TCA helped me solve this elegantly, in particular, Thomas Grapperon provided a type that I renamed to OnChange which you can simply copy & paste into your projects from here. Then, in your view, attach the .onChange modifier passing it any value to forward to the reducer like this:

@Environment(\.openWindow)
var openWindow

var body: some View {
   WithViewStore(...) { viewStore in
      SomeView(...)
         .onChange(of: \.$openWindow, store: self.store) { window in
            self.openWindow(window)
         }
   }  
}

Note that the \.$openWindow refers to a field in the State, so we need to define it:

struct SomeState {
   @OnChange
   var openWindow: Window?
}

Now, in our reducers, we can simply set the state value to a Window enum case and the view will automatically forward the change to the @Environment value. This also works with any other SwiftUI attribute, I also used it for @FocusState!

By the way, while TCA ships with a \.dismiss dependency, calling await self.dismiss() in the reducer will (currently) not behave like the SwiftUI \.dismiss environment value and close the window. Instead, it will do nothing and even produce a warning. As a workaround, I implemented a dependency that makes use of AppKit APIs by traversing the open windows, finding the match, and closing that. You can copy & paste the Gist from here, usage looks like this:

// add the dependency to your reducer
@Dependency(\.closeWindow)
var closeWindow

// close a window in a `run` effect
return .run { _ in await self.closeWindow(Window.paywall) }

And that's all I had to share about window management in SwiftUI 4 today!

💁🏻‍♂️
Enjoyed this article? Check out TranslateKit!
A drag & drop translator for String Catalog files – it's really easy.
Get it now to machine-translate your app to up to 150 languages!
👨🏻‍💻
Want to Connect?Follow me on 👾Twitch, 🎬YouTube, 🐦Twitter, and 🦣Mastodon.