LANARS

DEN ULTIMATE GUIDEN TIL Å SKAPE EN SIDEMENY MED SWIFTUI: DEL 1

DEN ULTIMATE GUIDEN TIL Å SKAPE EN SIDEMENY MED SWIFTUI: DEL 1
Forfatter
Tid til å lese
23 min
Seksjon
Del
Abonner
Dette nettstedet er beskyttet av reCAPTCHA og Googles Personvernerklæring og Vilkår for bruk gjelder.

Introduksjon

Heisann! I dag skal vi vise deg hvordan du kan gå fra å ha en tom sidemeny til å ha en pent animert, UX-bevisst sidemeny, vi skal gå gjennom steg-for-steg hvordan dette gjøres, og vi vil dele de beste praksisene med deg.

Vi vil svare på spørsmål, vi vil starte med det grunnleggende og videre vil vi viseforskjellige hint og triks som du kanskje trenger i produktet ditt, ved å ta hensyn til kodekvalitet og følge SwiftUI API-stilen.

Dette er hva vi skal snakke om i denne serien av artikler ved gradvis økende kompleksitet av dekkede emner:

  • Hvordan lage en enkel sidemeny (aka Drawer) ved bruk av SwiftUI.
  • Hvordan etterligne PresentationMode i dine egne komponenter for å la barn visningen kjenne sin presentasjon tilstand.
  • Hvordan tilpasse app-utseendet ved hjelp av innebygde SwiftUI-verktøy som ButtonStyle, LabelStyle.
  • Hvordan sende og manipulere layoutdata for sømløs animasjon, utvalg og overganger ved hjelp av Preferences og matchedGeometryEffect og mer.
  • Og til slutt vil vi lære om tilpassede overganger for pene fade in-animasjoner.

Her er hva vi ender opp med:

Forutsentninger

Før vi starter, vil du kanskje laste ned det innledende prosjektet fra vår GitHub-repo for å følge koden. Det er bygget på toppen av Swift Package Manager og forutsetter at du har grunnleggende erfaring med det. Når det er lastet ned, åpner du "Example" -prosjektet, og vi er klare til å gå i gang.

Drawer crunnleggende

Fra tid til annen trenger vi å vise en meny eller annet supplerende/navigasjons innhold som skal avsløres fra ledende/etterfølgende kanter. Og vi ønsker ikke å gjenta det igjen og igjen, så vår Drawer bør være generisk nok til atinnholdet kan brukes hvor som helst, la oss begynne med å åpne en Drawer.swift og hoppe inn i kode med en gang:

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

Vi er nesten ferdige med det grunnleggende. Det er overraskende hvor enkelt og konsis koden er takket være SwiftUI. Du kan legge til følgende kode til enten en Preview eller til en  ContentView.swift  i Example-appen:

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

Etter kjøring legger du raskt merke til et problem med at appen ikke reagerer på berøringer utenfor en meny, så vel som at menyen selv forsvinner når du klikker på en "Open"-knapp.

 

Først laoss fikse og forstå et ganske enkelt problem: Color.clear  er usynlig for berøringer, noe som er forventet. For å løse dette finnes det løsningersom å plassere en solid farge med 0.01 opasitet, men vi skal bruke en tiltenkt visning modifikator – contentShape — som setter et tabbar område for en visnings treff test.

La oss bryte ned problemet med utilsiktet forsvinning ved menyavvisning:

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

ZStack legger ut subviews etter en zIndex-ordre. Som standard blir det avledet av rekkefølgen på deklarerte visninger. Når vi fjerner menyen vår fra ZStack er det ingen grunn til å plassere den øverst selv om den er erklært som den nyeste. Men vi kan tvinge visningen til å bli lagt ut som øverste selv ved fjerning ved å bruke zIndex visningsmodifikatoren.

Her er vår oppdaterte 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)
}

Etter bygging og kjøring, kan vi gjøre akkurat det vi ønsket å gjøre:

PresentationMode

Så langt har vi gjort veldig grunnleggende interaksjoner. Men dette er åpenbart ikke toppmoderne for en gjenbrukbar komponent. For å være generisk nok, må vi etablere kommunikasjon med søsken for å dele informasjon om de er presentert eller ikke, og - det som er like viktig - for å la dem avvise seg selv.

I UIKit-verdenen, kunne du traversere foreldre-barn-hierarkiet til ønsket visning kontroller ble funnet — akkurat som navigationController oppfører seg.

I SwiftUI-verdenen, er dette mønsteret ikke lenger aktuelt. For å håndtere denne muligheten, bruker Apple en PresentationMode som lar det presenterte barnet avvise seg selv uansett hvor dypt det er i hierarkiet - dette håndteres av et kraftig miljøverdi system.

Dessverre kan vi ikke opprette vår egen instans av PresentationMode på grunn av en skjult initialisering, men vi bør definitivt etterligne det Apple har gjort.

Det er generelt en flott tilnærming å være på nivå med en innebygd API, da du automatisk lar andre utviklere løfte opp all deres tidligere kunnskap og erfaring om hvordan lignende API fungerer. Det er ikke nødvendig å si hvordan en slik tilnærming forbedrer lokal resonnering.

Målet er å tillate følgende kode innenfor vår meny eller alle nedstigende søsken ned i hierarkiet:

struct SiblingView: View {

  @Environment(\.drawerPresentationMode) var presentationMode

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

Vår presentasjonsmodus vil se lik ut, men litt annerledes for å passe til følgende behov: å kunne forstå/kontrollere Drawers isOpened-flagg. La oss lage et utkast til en API vi trenger:

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

Dette ser ganske likt ut som det stardard PresentationMode tilbyr, men hvordan synkroniserer vi det med vår kilde til sannhet isOpened som håndteres av Drawer selv? Det er en kraftig evne innen Binding: en evne til å kartlegge fra en type til en annen med liten til ingen innsats.

For å implementere det, la oss finne ut at DrawerPresentationMode er bygget rundt en vanlig Binding<Bool>, men tilbyr litt mer fancy API:

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

Når implementeringen er gjort, la oss legge til kartlegging fra Binding<Bool> til vår DrawerPresentationMode:

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

Etter å ha blitt kjent med hvordan PresentationMode kan implementeres, kan du utnytte nesten den samme APIen som Apple gjorde - dette er nøyaktig det vi streber etter - å gjenbruke all den vedtatte kunnskapen for Apples verktøy kjede.

Å sende denne bindingen til Drawers søsken er enkelt ved å bruke 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 }
  }
}

Det som gjenstår er å sende disse dataene videre på veien i Drawers body:

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

Nå har vi alt vi trenger for endelig å starte implementeringen av en meny.

En oppmerksom person kunne legge merke til at det ikke er noen grunn til å lage en innpakning rundt Binding<Bool> og det er helt riktig. For vårt formål er det tilstrekkelig å sende bindingen direkte. Imidlertid er det fortsatt verdt å forstå hvordan ting kan gjøres.

Du kan også laste ned den endelige versjonen av denne delen fra vår repo.

Bygging av sidemeny

Nå er det på tide å begynne å implementere menyen. La oss oppsummere hva som må gjøres og dele det inn i en rekke funksjoner:

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

Dette er det vi har implementert trinn-for-trinn så langt: 

  1. Liste over MenuItem's med overskrift med støtte av utvidet og kompakt tilstand på plass.
  2. Utvalg av en spesifikk vare.
  3. Tilpasset bakgrunnsovergang under fargeskjema skifte.

Som utgangspunkt kan du bruke følgende commit eller fortsette der vi slapp i forrige del.

Utvide/Sammenfalle en meny

Før vi går dypere inn i UI, er det verdt å klargjøre hvordan vi kan håndtere forskjellige tilstander. La oss erklære en MenuAppearance med følgende innhold:

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

Siden det bare er to tilstander vi bør ta vare på, passer enum våre behov perfekt. Nytte Koden etter enum-deklarasjonen lar oss omgå gjeldende meny status ned i hierarkiet akkurat som vi gjorde før med Drawer.isOpened-flagget.

Det kule her er at på grunn av SwiftUI-layout modellen, kan barnet stole på en gitt menyvisning for å justere layouten. Dette har en dramatisk effekt på kode simplifisering: i utgangspunktet er det ingen presentasjonsdetent involvert for å håndtere forskjellige layout størrelser - SwiftUI gjør riktig størrelse endring for oss.

Label & LabelStyle

Nå skal vi gå videre til grunnleggende byggeklosser. Som nevnt ovenfor, streber vi for å holde koden vår så nær som mulig SwiftUI innebygd API ikke bare når det gjelder navngivning og semantikk, men også for å gjenbruke eksisterende komponenter og begreper.

For å få en bedre forståelse av hva jeg snakker om, ta en titt her og prøv å finne ut vanlige visninger:

Det er åpenbart fra et design, at vi må opprettholde en slags HStack med konstant avstand og en lignende oppførsel når menyen kollapser for å bare vise et ikon.

I UIKit-verdenen, er det ikke en katastrofe, men en oppgave å tenke på. Men det er det ikke, når det kommer til SwiftUI. Ved første øyekast, er disse to separate typer visninger: bruker overskrift og en celle som kan velges. Imidlertid, i SwiftUI kan begge beskrives av en enkelt visning kalt Label og dens medfølgende LabelStyle som beskriver måten Icon og Title skal presenteres på.

Dette er en veldig sterk idé, på grunn av at de fleste av iOS-apper har mange abstraksjoner som kan beskrives perfekt som et par av Title og et Icon. SwiftUI tar denne ideen til neste nivå ved at den lar oss gi den et hvilken som helst utseende modifikator gjennom stil.

Og siden både Icon og Title er generiske typer, er det ikke overraskende at VStack fra brukeroverskrift og en menyelementtittel fra cellen begge er representert som en Title. Med dette i bakhodet kan vi skalere dette konseptet enda lenger i våre applikasjoner ved å forenkle og gjenbruke layout. For en tid å være i live! :)

La oss implementere vår 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 er bare en hendig innpakning for å oppnå et enkelt stylingsystem. Den kan bli funnet i vår eksempel kode.

Som du kan se, er alt vi har her et dødt enkelt layout som reagerer på en menyvisning ved å skjule Title av den gitte konfigurasjonen (se LabelStyleConfiguration for mer info).

Den eneste merkbare modifikatoren her er overgangen til en tittel, som flytter og bleker merkelappen vår inn/ut. Dette er en del av animasjonen vår. Du kan leke rundt for å se endelige effekter.

ButtonStyle

Når vi er ferdig med label layout, skal vi da av slutte med cellene? Disse er ikke celler her - det er heller en VStack med knapper i den for hver MenuItem vi har. Og akkurat som med LabelStyle, gir SwiftUI oss muligheten til å endre utseendet på en Button med en egendefinert ButtonStyle. Dette er en vanlig tilnærming der komposisjonen viser alle sine fordeler over arv. Vi kan bokstavelig talt utvide og komponere enhver stil i enhver kombinasjon som er tankevekkende.

Du kan finne implementeringene av en egendefinert knapp med Gesture Recognizers, som håndterer utheving og valg, men det er tydeligvis ikke en tiltenkt tilnærming og en løsning i et nøtteskall.

ButtonStyle på den andre siden, gir deg ikke bare visningen du må vise, men også tilstander om visningen er trykket eller ikke. Knappen har samtidig muligheten til å håndtere uthevet oppførsel ordentlig ved å gi en iOS-bred oppførsel - prøv å trykke og flytt fingeren rundt en hvilken som helst systemknapp for å se hva jeg mener.

Kort sagt: ButtonStyle forteller oss hvordan SwiftUI vil at vi skal utvide og style visninger.

La meg vise deg implementeringen av en MenuButtonStyle:

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

Ta en nærmere titt på hva vi har her. Ser ikke så komplekst ut, men noen få linjer krever litt forklaring:

- animation for isSelected != configuration.isPressed  gir systemets utseende og følelse til din kunde knapp. Når brukeren gjør en touchdown-hendelse, i tilfelle det er noen visuell tilbakemelding, får du en fin animasjon. Husk, visuell tilbakemelding er viktig, siden brukeren ikke har noen anelse om hva som skjer.

- frame - denne er en vanskelig modifikator. Den lar knappen være utvidbar til hvilken som helst bredde, ellers vil den krympe for å omfavne innholdet sitt perfekt. Senere bruker vi den innen VStack for å få fullt breddeknapper, selv om deres faktiske innhold størrelse kan være mye mindre. Lek rundt med denne modifikatoren for å se hva som kommer til å skje.

Å håndtere rammer er et bredt emne og vi anbefaler sterkt en Pro SwiftUI for å få praktisk kunnskap om SwiftUI layout system.

- contentShape - Returnerer en visning som bruker den gitte formen for hit testing - Apple Documentation sier tydelig hva det er. Men for å klargjøre hva nøyaktig det gjør her, må vi komme tilbake til ramme-modifikatoren ovenfor. Button vil krympe seg selv for å omfavne innholdet sitt perfekt. Derfor har den ingen grunn til å håndtere berøringer andre steder, kun innenfor sitt ikon og tittel. Men knappen vår bør være interaktiv innenfor full bredde av VStack og ved å bruke denne modifikatoren tvinger vi uttrykkelig denne oppførselen.

- isSelected - sist men ikke minst. ButtonStyle og Button håndterer ikke valgt tilstand og lar deg bestemme hvordan du skal lagre dette flagget og hvordan du skal presentere det for brukeren. Vår liste skal beholde valgt MenuItem så vi passerer dette flagget eksplisitt til vår egendefinerte 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))
      }
    }
  }
}

Noen av egenskapene og nytte koden er utelatt. Det anbefales å bla gjennom GitHub-repo for å få full kode. 

Nå er det på tide å oppdatere vår ContentView og bygge og kjøre koden vår for å se hva vi har fått: 

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

Her er resultatene våre etter å ha gjort endelige endringer i vår ContentView:






Og med bare noen få tillegg for å sette ting sammen:

Selv om den endelige versjonen av koden ovenfor med bakgrunn, osv., kan finnes her, er det på tide å gå videre til den morsomme delen 2 — valg og egendefinerte overganger.