LANARS

DER ULTIMATIVE LEITFADEN ZUR ERSTELLUNG EINES SEITENMENÜS MIT SWIFTUI: TEIL 1

DER ULTIMATIVE LEITFADEN ZUR ERSTELLUNG EINES SEITENMENÜS MIT SWIFTUI: TEIL 1
Zeit zum Lesen
23 min
Abschnitt
Teilen
Abonnieren
Diese Website ist durch reCAPTCHA geschützt und es gelten die Datenschutz-Bestimmungen und Nutzungsbedingungen von Google.

EINFÜHRUNG

Hallo! Heute werden wir gemeinsam von Grund auf ein wunderschön animiertes, UX-orientiertes Seitenmenü erstellen, es Schritt für Schritt aufbauen und Ihnen die besten bewährten Vorgehensweisen mit Ihnen teilen.

Wir werden einige Fragen beantworten, beginnend mit den Grundlagen bis hin zu verschiedenen Tipps und Tricks, die Sie für Ihr Produkt benötigen könnten, wobei wir die Codequalität berücksichtigen und dem SwiftUI-API-Stil folgen. 

Das alles werden wir in dieser Artikelserie besprechen, indem wir die Komplexität der behandelten Themen schrittweise erhöhen:

  • Wie Sie ein einfaches Seitenmenü (auch Drawer genannt) mit SwiftUI erstellen.
  • Wie Sie den PresentationMode in eigenen Komponenten nachahmen, um der untergeordneten Ansicht den Präsentationszustand mitzuteilen.
  • Wie Sie das Erscheinungsbild der App mit integrierten SwiftUI-Werkzeugen wie ButtonStyle, LabelStyle anpassen.
  • Wie Sie Layoutdaten zur nahtlosen Animation, Auswahl und Übergänge mit Hilfe von Preferences und matchedGeometryEffect übermitteln und anpasssen und vieles mehr.
  • Und schließlich lernen wir, wie wir benutzerdefinierte Übergänge für saubere Einblendanimationen erstellen.

So sieht das Ergebnis aus:

VORAUSSETZUNGEN

Bevor wir anfangen, sollten Sie das Anfangsprojekt aus unserem GitHub-Repository herunterladen, um den Code selbst zu analysieren. Er basiert auf dem Swift Package Manager und Voraussetzung ist, dass Sie grundlegende Erfahrungen damit haben. Sobald Sie das Paket heruntergeladen haben, öffnen Sie das "Example"-Projekt und wir können es loslegen.

GRUNDLAGEN DES DRAWERS

Von Zeit zu Zeit müssen wir ein Menü oder einen anderen ergänzenden/navigatorischen Inhalt anzeigen, der vom vorderen/letzten Rand aus sichtbar sein soll. Und da wir das nicht immer wiederholen wollen, sollte unser Drawer so generisch sein, dass er an jeder beliebigen Stelle eingesetzt werden kann. Beginnen wir also damit, eine Drawer.swift  zu öffnen und sofort mit dem Code zu beginnen:

public struct Drawer<Menu: View, Content: View>: View {
  @Binding private var isOpened: Bool
  private let menu: Menu
  private let content: Content

  // MARK: - Init
  public init(
    isOpened: Binding<Bool>,
    @ViewBuilder menu:  () -> Menu,
    @ViewBuilder content: () -> Content
  ) {
    _isOpened = isOpened
    self.menu = menu()
    self.content = content()
  }

  // MARK: - Body
  public var body: some View {
    ZStack(alignment: .leading) {
      content

      if isOpened {
        Color.clear
          .onTapGesture {
            if isOpened {
              isOpened.toggle()
            }
          }
        menu
          .transition(.move(edge: .leading))
      }
    }
    .animation(.spring(), value: isOpened)
  }
}

Wir sind fast fertig mit den Grundlagen. Es ist erstaunlich, wie einfach und prägnant der Code dank SwiftUI ist. Sie können den folgenden Code entweder zu einer Vorschau oder zu einer ContentView.swift in der Beispiel-App hinzufügen:

struct ContentView: View {

  @State private var isOpened = true

  var body: some View {
    Drawer(
      isOpened: $isOpened,
      menu: {
        ZStack {
          Color.gray
          Text("Menu")
        }
        .frame(width: 200)
      },
      content: {
        ZStack {
          Color.white
          Button {
            isOpened.toggle()
          } label: {
            Text("Open")
              .foregroundColor(Color.secondary)
          }
        }
      }
    )
  }
}

Final result

Nach der Ausführung werden Sie schnell feststellen, dass die App nicht auf Berührungen außerhalb eines Menüs reagiert und dass das Menü selbst verschwindet, wenn Sie auf eine Schaltfläche "Öffnen" klicken.

Zunächst einmal wollen wir ein ziemlich einfaches Problem beheben und verstehen: Color.clearist für Berührungen unsichtbar, was in gewisser Weisezu erwarten ist. Um dies zu lösen, gibt es Lösungen Work-arounds, wie zum Beispiel die Platzierung einer soliden Farbe mit 0.01 Opazität, aber wir werden einen dafür vorgesehenen View-Modifier verwenden - contentShape - der den tippbaren Bereich für ein View-Hit-Testing festlegt.

Lassen Sie uns auch das Problem mit dem versehentlichen Verschwinden beim Schließen des Menüs genauer untersuchen:

ZStack(alignment: .leading) {
  content
  if isOpened {
    ...
      menu
        .transition(.move(edge: .leading))
  }
}

Die Sache ist die, dass ZStack die Unteransichten nach einer zIndex-Reihenfolge anordnet. Standardmäßig wird sie durch die Reihenfolge der deklarierten Ansichten abgeleitet. Sobald wir unser Menü aus dem ZStack entfernen, gibt es keinen Grund, es an der Spitze zu platzieren, auch wenn es als das zuletzt deklarierte angegeben ist. Aber wir können erzwingen, dass die Ansicht selbst bei der Entfernung als oberste angeordnet wird, indem wir den zIndex-View-Modifier verwenden.

Hier ist unser aktualisierter Drawer's Body:

public var body: some View {
  ZStack(alignment: .leading) {
    content

    if isOpened {
      Color.clear
         .contentShape(Rectangle())
        .onTapGesture {
          if isOpened {
            isOpened.toggle()
          }
        }
      menu
        .transition(.move(edge: .leading))
        .zIndex(1)
    }
  }
  .animation(.spring(), value: isOpened)
}

Jetzt, nach dem Erstellen und Starten, erhalten wir genau das, was wir erreichen wollten:

PresentationMode

Okay, wir haben bisher wirklich grundlegende Interaktionen durchgeführt. Das ist jedoch offensichtlich nicht der Stand der Technik einer wiederverwertbaren Komponente. Um generisch ausreichend zu sein, müssen wir eine Kommunikation mit Geschwisterkomponenten etablieren, um Informationen darüber zu teilen, ob sie präsentiert werden oder nicht und - was genauso wichtig ist - um sie sich selbst entfernen zu lassen. 

In der UIKit-Welt könnten Sie die übergeordnete Hierarchie durchlaufen, bis Sie den gewünschten View-Controller gefunden haben - so wie sich navigationController verhält.

In der SwiftUI-Welt ist dieses Muster nicht mehr anwendbar. Um dieses Problem zu lösen, verwendet Apple einen PresentationMode, der es dem präsentierten Kind erlaubt, sich selbst zu deaktivieren, unabhängig davon, wie tief es sich in der Hierarchie befindet - dieser wird von einem leistungsstarken System für Umgebungswerte verwaltet.

Leider können wir unsere eigene Instanz von PresentationMode aufgrund eines versteckten Initialisierers nicht instanziieren, aber wir sollten auf jeden Fall nachahmen, was Apple getan hat.

Es ist generell ein großartiger Ansatz, auf Augenhöhe mit einer eingebauten API zu bleiben, da andere Entwickler automatisch all ihr vorheriges Wissen und ihre Erfahrung darüber, wie ähnliche APIs funktionieren, einbringen können. Es versteht sich von selbst, wie ein solcher Ansatz das lokale Verständnis verbessert.

Das Ziel ist es, den folgenden Code innerhalb unseres Menüs oder aller nachfolgenden Geschwister weiter unten in der Hierarchie zuzulassen:

struct SiblingView: View {

  @Environment(\.drawerPresentationMode) var presentationMode

  var body: some View {
    Button("Dismiss") {
      presentationMode.wrappedValue.close()
    }
  }
}

Unser Präsentationsmodus wird ähnlich aussehen, aber etwas anders sein, um dem folgenden Bedarf gerecht zu werden: die Möglichkeit, das isOpened-Flag von Drawer zu verstehen/kontrollieren. Lassen Sie uns einen Entwurf für eine API erstellen, die wir benötigen:

struct DrawerPresentationMode {
  var isOpened: Bool
  mutating func open()
  mutating func close()
}

Das sieht ziemlich ähnlich aus wie das, was der Standard PresentationMode bietet, aber wie synchronisieren wir dies mit unserer Quelle der Wahrheit isOpened, die vom Drawer selbst verwaltet wird? Es gibt eine mächtige Fähigkeit in Binding selbst: die Fähigkeit, mit wenig bis gar keinem Aufwand von einem Typ auf einen anderen zu mappen.

Um dies zu implementieren, stellen wir fest, dass DrawerPresentationMode auf einem einfachen Binding<Bool> aufbaut, aber eine etwas ausgefeiltere API bietet:

public struct DrawerPresentationMode {

  @Binding private var _isOpened: Bool

  init(isOpened: Binding<Bool>) {
    __isOpened = isOpened
  }

  public var isOpened: Bool {
    _isOpened
  }

  public mutating func open() {
    if !_isOpened {
      _isOpened = true
    }
  }

  public mutating func close() {
    if _isOpened {
      _isOpened = false
    }
  }
}

Jetzt, nachdem die Implementierung abgeschlossen ist, fügen wir das Mapping von Binding<Bool> zu unserem DrawerPresentationMode hinzu:

extension Binding where Value == Bool {
  func mappedToDrawerPresentationMode() -> Binding<DrawerPresentationMode> {
    Binding<DrawerPresentationMode>(
      get: {
        DrawerPresentationMode(isOpened: self)
      },
      set: { newValue in
        self.wrappedValue = newValue.isOpened
      }
    )
  }
}

Nachdem Sie sich mit der Implementierung von PresentationMode vertraut gemacht haben, können Sie nahezu die gleiche API wie Apple verwenden - genau das ist es, was wir anstreben - um all das übernommene Wissen für die Apple-Toolchain wiederzuverwenden.

Das Weitergeben dieses Bindings an die Geschwister des Drawers ist unkompliziert durch die Verwendung von EnvironmentValues:

extension DrawerPresentationMode {
  static var placeholder: DrawerPresentationMode {
    DrawerPresentationMode(isOpened: .constant(false))
  }
}
private struct DrawerPresentationModeKey: EnvironmentKey {
  static var defaultValue: Binding<DrawerPresentationMode> = .constant(.placeholder)
}

extension EnvironmentValues {
  public var drawerPresentationMode: Binding<DrawerPresentationMode> {
    get { self[DrawerPresentationModeKey.self] }
    set { self[DrawerPresentationModeKey.self] = newValue }
  }
}

Was bleibt, ist, diese Daten in den Body des Drawers weiterzugeben:

public var body: some View {
  ZStack(alignment: .leading) {
    content
    ...
    
    }
  }
  .animation(.spring(), value: isOpened)
  .environment(\.drawerPresentationMode, $isOpened.mappedToDrawerPresentationMode())
}

Jetzt haben wir alles, was wir brauchen, um endlich ein Menü zu implementieren.

Ein aufmerksamer Mensch könnte feststellen, dass es keinen Grund gibt, einen Wrapper um Binding<Bool> zu machen und das ist auch völlig richtig. Für unseren Zweck ist es ausreichend, Binding direkt zu übergeben. Dennoch lohnt es sich zu verstehen, wie die Dinge im Hintergrund ablaufen können.

Sie können die endgültige Version dieses Abschnitts auch von unserem Repo herunterladen.

EIN SEITENMENÜ ERSTELLEN

Jetzt ist es an der Zeit, mit der Implementierung des Menüs zu beginnen. Lassen Sie uns noch einmal zusammenfassen, was zu tun ist, und das Ganze in eine Reihe von Funktionen unterteilen:

Default Menu with dark color scheme   Compact Menu with dark color scheme

Bisher werden wir Folgendes Schritt für Schritt implementieren:

  1. Liste von MenuItem's mit Überschrift, mit Unterstützung des erweiterten und kompakten Zustands vor Ort.
  2. Auswahl eines bestimmen Elements.
  3. Benutzerdefinierte Hintergrundübergänge während des Farbschemas wechseln.

Als Ausgangspunkt können Sie den folgenden Commit verwenden oder dort weitermachen, wo wir im vorherigen Abschnitt aufgehört haben.

Erweitern/Zusammenklappen eines Menüs

Bevor wir in die Benutzeroberfläche eintauchen, ist es sinnvoll zu klären, wie wir verschiedene Zustände verwalten können. Lassen Sie uns ein MenuAppearance mit folgendem Inhalt deklarieren:

enum MenuAppearance: String {
  case `default`, compact

  mutating func toggle() {
    switch self {
    case .default:
      self = .compact
    case .compact:
      self = .default
    }
  }
}

private struct MenuAppearanceEnvironmentKey: EnvironmentKey {
  static var defaultValue: MenuAppearance = .default
}

extension EnvironmentValues {
  var menuAppearance: MenuAppearance {
    get {
      self[MenuAppearanceEnvironmentKey.self]
    }
    set {
      self[MenuAppearanceEnvironmentKey.self] = newValue
    }
  }
}

extension View {
  func menuAppearance(_ appearance: MenuAppearance) -> some View {
    environment(\.menuAppearance, appearance)
  }
}

Da wir nur zwei Zustände zu berücksichtigen haben, passt enum perfekt zu unseren Bedürfnissen. Der Hilfscode nach der enum-Deklaration ermöglicht es uns, den aktuellen Menüzustand genauso weiter unten in der Hierarchie zu übergeben, wie wir es zuvor mit der Drawer.isOpened-Flag gemacht haben.

Das Coole daran ist, dass das Kind aufgrund des SwiftUI Layout-Modells auf ein gegebenes Menü-Erscheinungsbild vertrauen kann, um sein Layout anzupassen. Dies hat eine entscheidende Auswirkung auf die Vereinfachung des Codes: Grundsätzlich gibt es keine Präsentationstaste, um verschiedene Layoutgrößen zu verwalten - SwiftUI führt die richtige Größenänderung für uns durch.

Label & LabelStyle

Jetzt gehen wir zu den grundlegenden Bausteinen über. Wie oben erwähnt, bemühen wir uns, unseren Code so nah wie möglich an der eingebauten SwiftUI-API zu halten, nicht nur in Bezug auf Namensgebung und Semantik, sondern auch, um bestehende Komponenten und Konzepte zu nutzen.

Um ein besseres Verständnis davon zu bekommen, wovon ich spreche, schauen Sie hier und versuchen Sie, gemeinsame Ansichten zu finden:

Es ist offensichtlich, dass wir eine Art HStack mit konstantem Abstand und einem ähnlichen Verhalten beim Zusammenklappen des Menüs aufrecht erhalten müssen, um nur ein Icon anzuzeigen.

 In der UIKit-Welt wäre das keine Katastrophe, sondern eine Aufgabe, über die man nachdenken muss. Aber das ist nicht der Fall, wenn es um SwiftUI geht. Auf den ersten Blick handelt es sich dabei um zwei separate Arten von Ansichten: Benutzerkopfzeile und eine Zelle, die ausgewählt werden können. In SwiftUI können jedoch beide durch eine einzelne Ansicht namens Label und ihren begleitenden LabelStyle beschrieben werden, der beschreibt, wie das Icon und der Titel präsentiert werden sollen.

Dies ist eine sehr leistugsvolle Idee, da in den meisten iOS-Apps viele Abstraktionen vorhanden sind, die perfekt als Paar aus Titel und Icon beschrieben werden können. SwiftUI hebt diese Idee auf die nächste Ebene, indem es uns ermöglicht, jeden Erscheinungsbild-Modifikator über den Stil zur Verfügung zu stellen. 

Und da sowohl Icon als auch Titel generische Typen sind, ist es nicht überraschend, dass VStack aus Benutzerkopfzeile und einem Menüpunkt-Titel aus der Zelle beide als Titel dargestellt werden. Mit diesem Wissen im Hinterkopf können wir dieses Konzept sogar noch weiter in unseren Anwendungen skalieren, indem wir das Layout hier und da vereinfachen und wiederverwenden. In was für einer Welt wir nur leben! :)

Genug geredet, implementieren wir nun unseren MenuLabelStyle:

struct MenuLabelStyle: LabelStyle {

  @Environment(\.menuAppearance) var appearance

  func makeBody(configuration: Configuration) -> some View {
    HStack(spacing: 24) {
      configuration.icon
        .frame(width: 48, height: 48, alignment: .center)

      if appearance == .default {
        configuration.title
          .typographyStyle(.title)
          .transition(.move(edge: .trailing).combined(with: .opacity))
      }
    }
  }
}

extension LabelStyle where Self == MenuLabelStyle {
  static var menuLabel: Self {
    MenuLabelStyle()
  }
}

typographyStyle ist nur ein praktischer Wrapper, um ein einheitliches Gestaltungssystem zu erreichen. Er kann in unserem Beispielcode gefunden werden.

Wie Sie sehen können, haben wir hier nur ein total einfaches Layout, das auf das Erscheinungsbild des Menüs reagiert, indem es den Titel der gegebenen Konfiguration ausblendet (siehe LabelStyleConfiguration für mehr Informationen).

Der einzige bemerkenswerte Modifikator hier ist die Übergang eines Titels, der unser Label ein-/ausblendet. Dies ist ein Teil unserer Animation. Sie können damit herumspielen, um die endgültigen Effekte zu sehen.

ButtonStyle

Wenn wir mit dem Label-Layout fertig sind, wie wäre es dann mit den Zellen? Das hier sind keine Zellen, sondern ein VStack mit Schaltflächen für jedes MenuItem, das wir haben. Und genau wie bei LabelStyle bietet SwiftUI uns die Möglichkeit, das Aussehen einer Schaltfläche durch einen benutzerdefinierten ButtonStyle zu ändern. Dies ist ein gängiger Ansatz, bei dem die Zusammensetzung ihre Vorteile gegenüber der Vererbung ausspielt. Wir können buchstäblich jeden Stil in jeder beliebigen Kombination erweitern und zusammenstellen, und das ist umwerfend.

Hier und da finden Sie Implementierungen eines benutzerdefinierten Buttons mit Gesture Recognizers, der das Hervorheben und Auswählen handhabt, aber das ist eindeutig nicht der beabsichtigte Ansatz und ein eigentlich ein Work-around.

ButtonStyle hingegen übergibt Ihnen nicht nur die Ansicht, die Sie anzeigen müssen, sondern gibt auch an, ob die Ansicht gedrückt ist oder nicht. Button hat gleichzeitig die Fähigkeit, das Hervorhebungsverhalten richtig zu verwalten, indem es ein iOS-weites Verhalten bereitstellt - versuchen Sie, Ihren Finger auf eine beliebige Systemschaltfläche zu drücken und zu bewegen, um zu sehen, was ich meine.

Um es kurz zu machen: ButtonStyle sagt uns noch einmal, wie SwiftUI uns dazu bringen möchte, Ansichten zu erweitern und zu gestalten. 

Lassen Sie mir die Implementierung eines MenuButtonStyle zeigen:

struct MenuItemButtonStyle: ButtonStyle {

  var isSelected: Bool = false

  func makeBody(configuration: Configuration) -> some View {
    configuration
      .label
      .labelStyle(.menuLabel)
      .foregroundColor(configuration.isPressed || isSelected ? Color("color/selectionForeground") : Color("color/text"))
      .animation(.spring(), value: isSelected != configuration.isPressed)
      .frame(maxWidth: .infinity, minHeight: .contentHeight, alignment: .leading)
      .contentShape(Rectangle())
  }
}

Schauen Sie sich genauer an, was wir hier haben. Es sieht nicht so komplex aus, aber einige Zeilen benötigen eine Erklärung:

- Die animation für isSelected != configuration.isPressed gibt Ihrem benutzerdefinierten Button ein Systemaussehen und -gefühl. Wenn der Benutzer also ein Touch-Down-Event macht, erhalten Sie bei jedem visuellen Feedback eine schöne Animation. Denken Sie daran, dass visuelles Feedback wichtig ist, da der Benutzer keine Ahnung hat, was für ihn kommt. 

- frame - Dieser Modifikator ist etwas knifflig. Er lässt den Button auf jede Breite erweiterbar sein, sonst würde er schrumpfen, um seinen Inhalt perfekt zu umarmen. Später verwenden wir ihn innerhalb von VStack, um vollbreite Tasten zu erhalten, obwohl ihre tatsächliche Inhaltsgröße viel kleiner sein könnte. Spielen Sie mit diesem Modifikator herum, um zu sehen, was passieren wird.

Frames zu verwalten ist ein weites Feld und wir empfehlen dringend ein Pro SwiftUI, um praktisches Wissen über das SwiftUI-Layout-System zu erwerben.

- contentShape - gibt eine Ansicht zurück, die die gegebene Form für das Hit-Testing verwendet - Die Apple-Dokumentation sagt deutlich, was das ist. Um jedoch zu klären, was diese Funktion hier genau macht, müssen wir zum Frame-Modifikator oben zurückkehren. Denken Sie daran, der Button wird sich selbst schrumpfen, um seinen Inhalt perfekt zu umarmen. Daher hat er keinen Grund, Berührungen anderswo zu behandeln, als in seinem Icon und Titel. Aber unser Button sollte interaktiv innerhalb der vollen Breite des VStack sein und durch Anwenden dieses Modifikators zwingen wir dieses Verhalten explizit.

- isSelected - und nicht zuletzt. ButtonStyle und Button behandeln den ausgewählten Status nicht und überlassen es Ihnen, wie Sie dieses Flag speichern und wie Sie es dem Benutzer präsentieren. Unsere Liste sollte das ausgewählte MenuItem behalten, also übergeben wir dieses Flag explizit an unseren benutzerdefinierten Stil.

Liste

struct MenuItemList: View {

 ...

  // MARK: - Body

  var body: some View {
    VStack(alignment: .leading) {
      UserHeader(user: user)
        .onTapGesture {
          onUserSelection?()
        }

      Spacer()
        .frame(height: .headerBottomSpacing)

      itemsList
    }
    .animation(.spring(), value: selection)
  }

  @ViewBuilder
  private var itemsList: some View {
    VStack(alignment: .leading) {
      ForEach(items) { item in
        Button(
          action: {
            if selection == item {
              selection = nil
            } else {
              selection = item
            }
          },
          label: {
            Label(item: item)
          }
        )
        .buttonStyle(MenuItemButtonStyle(isSelected: item == selection))
      }
    }
  }
}

Einige der Eigenschaften und Hilfscodes werden übersprungen. Es wird empfohlen, das GitHub-Repo zu durchsuchen, um den vollständigen Code zu erhalten.

Jetzt ist es an der Zeit, unseren ContentView zu aktualisieren und unseren Code zu erstellen und auszuführen, um zu sehen, was wir haben:

struct ContentView: View {
  @State var selection: MenuItem? = .dashboard
  @State var appearance: MenuAppearance = .default

  var body: some View {
    MenuItemList(selection: $selection) {
      withAnimation(.spring()) {
        appearance.toggle()
      }
    }
    .fixedSize(horizontal: true, vertical: false)
    .menuAppearance(appearance)
  }
}

Hier sind unsere Ergebnisse nach den endgültigen Änderungen an unserem ContentView:






Und das mit nur wenigen Ergänzungen, um alles zusammenzusetzen:

Während die endgültige Version des obigen Codes mit Hintergrund usw. hier gefunden werden kann, ist es an der Zeit, zum unterhaltsamen Teil 2 - Auswahl und benutzerdefinierte Übergänge - überzugehen.