Zum Inhalt springen

Fensterverwaltung mit SwiftUI 4

Erkenntnisse aus der Modernisierung der Fensterverwaltung meiner Mac-App nach dem Upgrade auf SwiftUI 4. Erklaerung von `\.openWindow`, `.windowResizability` und mehr.

Fensterverwaltung mit SwiftUI 4

SwiftUI wird jedes Jahr deutlich besser. Letztes Jahr (2022) haben wir nicht nur verbesserte Navigations-APIs bekommen. Apple hat auch die Unterstuetzung fuer macOS erheblich verbessert – ich wuerde behaupten, dass die SwiftUI-APIs, die wir fuer die Mac-App-Entwicklung in SwiftUI 4 erhalten haben, auf einem 1.0-Niveau sind und es endlich ermoeglichen, alle moeglichen Dinge in SwiftUI zu tun, ohne fuer einige der gaengigsten Aufgaben auf AppKit zurueckgreifen zu muessen. Ich habe SwiftUI auf dem Mac erlebt, waehrend ich an RemafoX mit SwiftUI 3 gearbeitet habe, und in einigen Teilen war es wirklich ein Albtraum.

Als iOS-Entwickler hatte ich gehofft, nicht alle Details von AppKit lernen zu muessen. Aber ich musste alle moeglichen Hacks schreiben, um die einfachsten Dinge zu tun – wie ein Fenster zu schliessen. Oder den Vollbild-Button an einem Fenster zu deaktivieren. Aber es gibt gute Neuigkeiten: Durch das Anheben meines App-Targets auf macOS 13.0 konnte ich die Fensterverwaltung endlich ueber SwiftUI erledigen und alle Hacks aus meiner App entfernen.

Endlich fuehlt sich meine App wirklich zu 100% SwiftUI-getrieben an. Und hier sind alle neuen APIs, die ich nutzen konnte – gruppiert und betitelt nach der Aufgabe, die ich erledigen wollte. Da die Fensterverwaltung wahrscheinlich der groesste Unterschied zwischen iOS- und macOS-Entwicklung in SwiftUI-Zeiten ist, koennte dieser Artikel auch jedem helfen, der von iOS zu macOS wechselt, um zu verstehen, wie Fensterverwaltung auf dem Mac funktioniert.

Ein Fenster oeffnen

Wenn du eine WindowGroup verwendest (die der einzige Fenstertyp in SwiftUI 3 war), hast du mit SwiftUI 4 zwei Optionen: Die erste, die auch vorher schon unterstuetzt wurde, ist die Methode handlesExternalEvents:

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])
      // ...
   }
}

Wenn du dieses Fenster dann oeffnen moechtest, muesstest du eine URL oeffnen – so wie du jede externe URL oeffnen kannst, aber mit einem Custom URL Scheme. Zum Beispiel:

@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")
            }
         }
   }
}

Diese Methode funktioniert allerdings nicht mit dem neuen Window-Typ, obwohl sie dort vollstaendig verfuegbar ist. Die Dokumentation ist eindeutig:

This modifier is only supported for WindowGroup Scene types.

Aber die zweite Methode funktioniert sowohl fuer WindowGroup als auch Window: Der neue Environment-Wert \.openWindow! Zuerst definieren wir eine id im 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) { ... }
      // ...
   }
}

Dann uebergeben wir diese id einfach an openWindow, um die Praesentation manuell auszuloesen:

@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")
            }
         }
      // ...
   }
}

Das ist viel schoener! Beachte, dass es auch \.openDocument fuer DocumentGroup gibt.

Doppelte Fenster verhindern

Die Verwendung einer id fuer ein Fenster verhindert nicht, dass mehrere davon erscheinen:

Prevent duplicate windows

Zumindest nicht bei WindowGroup – du kannst aber einfach Window verwenden, um sicherzustellen, dass nie mehrere Fenster mit derselben id erstellt werden! Aber das ist nicht immer eine Option.

Ein Window ist in verschiedener Hinsicht viel eingeschraenkter als eine WindowGroup. Zum Beispiel kann man nicht .commands darauf aufrufen, wie ich es oben getan habe, um Buttons im Hauptmenue der App zu setzen. Ausserdem ist Window eine reine macOS-API – du kannst den Code also nicht auf iPadOS wiederverwenden. Der Grund, warum ich es fuer RemafoX nicht verwenden konnte, ist die Einschraenkung, die ich bereits erwaehnt habe: Window unterstuetzt kein handlesExternalEvents. Aber ich brauche diese API, weil RemafoX tief in Xcode integriert ist – sowohl ueber eine Extension als auch ein CLI-Tool –, und diese koennen \.openWindow nicht nutzen, weil sie einfach nicht Teil desselben Targets sind. Aber sie koennen trotzdem “externe” URLs oeffnen!

Was auch immer dein Grund sein mag, um Duplikate bei WindowGroup zu verhindern – mach Folgendes:

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

Hier wird eine ueberladene Version des WindowGroup-Initializers verwendet, die zusaetzliche for type- und defaultValue-Argumente akzeptiert. Sie kann genutzt werden, um ein bestimmtes Fenster fuer einen beliebigen Datentyp zu oeffnen (wie einen Profile-Typ), aber ich verwende hier einfach die id vom Typ String, um das Fenster eindeutig zu machen.

In meinem Fall musste ich das an mehreren Stellen tun, also habe ich diese Extension erstellt:

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
      }
   }
}

Damit wurde der obige Aufruf mit zwei Window.paywall.id-Aufrufen einfach zu:

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

Zum Oeffnen eines Fensters uebergeben wir zusaetzlich den value-Parameter an openWindow:

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

Wieder haben mich die mehrfachen Aufrufe von Window.paywall.id gestoert, also habe ich einen Helper erstellt:

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

Jetzt kann ich einfach aufrufen – sogar ohne das .id-Suffix:

self.openWindow(Window.paywall)

Ein Fenster schliessen

Jetzt, wo wir ein Fenster (eindeutig) oeffnen koennen, schliessen wir es auch. Und hier war die Situation vorher viel schlimmer als bei WindowGroup, wo wir zumindest einen Workaround innerhalb von SwiftUI hatten. Es gab einfach keine Moeglichkeit, ein Fenster direkt in SwiftUI zu schliessen. Ich musste diesen Hack mit AppKit implementieren:

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

Wenn ich dann das Fenster schliessen wollte, rief ich self.window.close() auf.

2021 wurde der Environment-Wert \.dismiss hinzugefuegt, mit dem praesentierte Views wie sheet, popover oder fullScreenCover direkt von innen heraus geschlossen werden konnten. Ich bin mir nicht sicher, ob dieses Verhalten damals schon verfuegbar war oder 2022 hinzugefuegt wurde, aber heute steht in der Dokumentation zusaetzlich, dass die dismiss-Aktion genutzt werden kann, um:

Close a window that you create with WindowGroup or Window.

Das funktioniert natuerlich nur, wenn gerade keine modale View innerhalb des betreffenden Fensters praesentiert wird. Aber dann funktioniert es einwandfrei – wir koennen einfach schreiben:

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

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

Den Vollbild-Button deaktivieren

Disabling full screen

Zuvor habe ich denselben oben erwahnten Hack verwendet, der mir Zugriff auf ein NSWindow gab, um weitere Konfigurationen vorzunehmen – wie das Deaktivieren des Vollbild-Buttons fuer Views mit fester Groesse, etwa mein Willkommensfenster oder mein About-Fenster. Aber jetzt haben wir den neuen Modifier windowResizability, der es uns erlaubt, den Vollbild-Button indirekt zu deaktivieren. Standardmaessig ist er auf contentMinSize gesetzt fuer alle Fenster ausser Settings (das ein Scene-Typ wie WindowGroup ist). Aber wir koennen jetzt Folgendes tun:

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

Indem wir windowResizability auf .contentSize setzen, sagen wir SwiftUI, den Groessen, die wir im frame-Modifier angeben, strenger zu folgen. Als logische Konsequenz: Wenn die von der View angegebene Maximalgroesse kleiner ist als die aktuelle Bildschirmgroesse des Nutzers, deaktiviert SwiftUI automatisch den Vollbild-Button fuer uns! Das ist keine besonders offensichtliche oder direkte API, aber es ergibt Sinn. Effektiv sollte der Button fuer die meisten Nutzer deaktiviert sein, wenn wir Werte unter 1366 mal 768 angeben – das ist die native Groesse eines 11-Zoll MacBook Air (von 2015).

TCA-Extras

Wenn du wie ich The Composable Architecture (TCA) fuer deine Apps verwendest, fragst du dich vielleicht, wie du diese neuen Environment-Werte am besten an deine Reducer weiterleiten kannst, da Logik wie das Oeffnen/Schliessen von Fenstern dort stattfinden sollte. Die grossartige Community rund um TCA hat mir geholfen, das elegant zu loesen – insbesondere Thomas Grapperon hat einen Typ bereitgestellt, den ich in OnChange umbenannt habe und den du einfach per Copy & Paste in deine Projekte uebernehmen kannst – von hier. Dann haenge in deiner View den .onChange-Modifier an und uebergib ihm einen beliebigen Wert, der an den Reducer weitergeleitet werden soll:

@Environment(\.openWindow)
var openWindow

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

Beachte, dass sich \.$openWindow auf ein Feld im State bezieht, also muessen wir es definieren:

struct SomeState {
   @OnChange
   var openWindow: Window?
}

Jetzt koennen wir in unseren Reducern einfach den State-Wert auf einen Window-Enum-Case setzen, und die View leitet die Aenderung automatisch an den @Environment-Wert weiter. Das funktioniert auch mit jedem anderen SwiftUI-Attribut – ich habe es auch fuer @FocusState verwendet!

Uebrigens: Obwohl TCA mit einer \.dismiss-Dependency ausgeliefert wird, wird der Aufruf von await self.dismiss() im Reducer sich (derzeit) nicht wie der SwiftUI-\.dismiss-Environment-Wert verhalten und das Fenster schliessen. Stattdessen passiert nichts und es wird sogar eine Warnung erzeugt. Als Workaround habe ich eine Dependency implementiert, die AppKit-APIs nutzt, indem sie die offenen Fenster durchlaeuft, den passenden Match findet und diesen schliesst. Du kannst das Gist von hier per Copy & Paste uebernehmen. Die Verwendung sieht so aus:

// 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) }

Und das war alles, was ich heute ueber Fensterverwaltung in SwiftUI 4 zu teilen hatte!

Du fandest diesen Artikel hilfreich? Hol dir meinen Expertenrat!

Hat dir dieser Beitrag gefallen? Folge mir auf Bluesky und Mastodon für mehr Swift-Tipps und Indie-Dev-Updates.