LANARS

DEN ULTIMATE GUIDEN TIL Å LAGE EN SIDEMENY I SWIFTUI: DEL 2. EGENTILPASSET OVERGANG OG ANIMASJONER

DEN ULTIMATE GUIDEN TIL Å LAGE EN SIDEMENY I SWIFTUI: DEL 2. EGENTILPASSET OVERGANG OG ANIMASJONER
Forfatter
Tid til å lese
24 min
Seksjon
Del
Abonner
Dette nettstedet er beskyttet av reCAPTCHA og Googles Personvernerklæring og Vilkår for bruk gjelder.

VELKOMMEN TILBAKE

Heisann, folkens. I del 1 av denne serien med artikler dekket vi skriving av egne komponenter i SwiftUI-designet ved å bruke Label, LabelStyle, ButtonStyle og flere. I tillegg kunne du se hvor enkelt det er å designe din egen beholder og overføre presentasjon informasjon til søsken, akkurat som Apple gjør med deres Sheet og lignende presentasjonsbeholdere.

I dag vil vi fortsette vår reise inn i SwiftUI ved å lære hvordan man lager egne overganger, hvordan man lage fancy animasjoner og får layout data i en deklarativ verden. 

SELEKSJON

I denne delen vil vi implementere håndteringen av en valgt eksakt enhet i listen. Du kan starte fra denne commit hvor vi avsluttet i forrige del.

La oss minne deg om hva vi trenger å implementere:

Anchor og AnchorPreference

Som du kan se fra en forhåndsvisning ovenfor, kommer vi til å fremheve den eksakte knappen. Vi kunne slå av og på en bakgrunn for en valgt knapp, men det er verken interessant eller utfordrende, og det ville føre til en enkel fade inn-/ ut-animasjon.

Eksemplet over ser ut som en animasjon av bakgrunnen ved å flytte den fra en knapp til en annen.

I plain UIKit, kunne vi lese ramme for hver under-visning og deretter flytte en enkelt bakgrunns visning til en passende posisjon, men hva med SwiftUI? Dens deklarative natur lar oss ikke engang lese disse dataene direkte, men det er noen få verktøy som adresserer dette:

  • GeometryReader — en spesiell visning som gir deg tilgang til en GeometryProxy - en proxy til forskjellige geometri karakteristika som ramme, størrelse og en Anchor. 
  • matchedGeometryEffect — en modifikator som lar deg synkronisere geometri på tvers av en gruppe visninger. Vi anbefaler denne serien fra swiftui-lab.com av artikler for å få en bedre forståelse av hva og hvordan.
  • anchorPreferences — en modifikator som lar deg lagre geometri karakteristikk fra en gitt visnings koordinatrom til preferanser og deretter konvertere til ditt koordinatrom via GeometryReader. 

Dette er ikke en fullstendig liste over tilgjengelige modifikatorer, visninger og effekter, men mer enn nok for vår oppgave.

Vi vil starte med det første og det siste alternativet i forbindelse. Her er en nedbrytning av hva som kan gjøres for et dynamisk utvalg som vil fremheve hele knappen:

  1. Få dens grenser i et lokalt koordinatrom gjennom anker preferanser. 
  2. Lagre det i preferanser. Som et resultat vil vi ha en ordbok med grenser for hver meny artikkel.
  3. Ved valgt menyartikkel, endre utvalgsbakgrunn til en tilsvarende artikkels grenser, konvertert til vårt koordinatrom. 

Det er på tide å binde planen ovenfor med  praktisk kunnskap. Først av alt trenger vi å lagre mottatt anker et sted — dette er detmetode signaturen — anchorPreference(key:value:transform:) krever at vi skal gjøre. Lag en ny fil som heter MenuItemGeometryPreferenceKey:

struct MenuItemGeometry {

  let item: MenuItem
  let bounds: Anchor<CGRect>
}

struct MenuItemGeometryPreferenceKey: PreferenceKey {

  static var defaultValue: [MenuItem: MenuItemGeometry] = [:]

  static func reduce(value: inout [MenuItem : MenuItemGeometry], nextValue: () -> [MenuItem: MenuItemGeometry]) {
    value.merge(nextValue()) { _, rhs in rhs }
  }
}

Ta et øyeblikk for å sjekke hva vi nettopp har gjort: Denne preferansen lagrer et anker per element. Vi kommer tilbake til naturen og kapabilitetene til Anchor litt senere, men for nå kan vi bevare geometri dataene til hver knapp. For å gjøre dette, gå til MenuItemList og oppdater itemsList-egenskapen som følger:

@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))
      .anchorPreference(key: MenuItemGeometryPreferenceKey.self, value: .bounds) {
        [item: $0]
      }
    }
  }
  .backgroundPreferenceValue(MenuItemGeometryPreferenceKey.self, alignment: .topLeading) { preferences in
    fatalError("To be implemented")
  }
}
 

Ingenting spesielt denne gangen heller, men denne koden oppfylte vår første krav: ved å legge til en anchorPreference-modifikator for hver knapp, gir vi den deretter via preferanser hierarkiet høyere opp til backgroundPreferenceValue, hvor preferansene er av typen [MenuItem: Anchor<CGRect>]. Dermed har vi alle rammene på ett sted, men hva gjør backgroundPreferenceValue? Apple er klar på det:

Leser den spesifiserte preferanse verdien fra visningen, og bruker den til å produsere en annen visning som er brukt som bakgrunn til den originale visningen.

Med det sagt, er det et perfekt sted å returnere vår utvalgsbakgrunn. Plasser følgende kode under vår itemsList-egenskap:

@ViewBuilder
private func selectionBackground(from preferences: MenuItemGeometryPreferenceKey.Value, in geometry: GeometryProxy) -> some View {
  if let selection, let frame = preferences[selection].map({ geometry[$0] }) {
    RoundedRectangle(cornerRadius: 13)
      .frame(width: frame.width, height: frame.height)
      .offset(x: frame.minX, y: frame.minY)
  }
}

For å bruke denne nye visningen i vår liste, trenger vi bare å oppdatere eno backgroundPreferenceValue fra en itemsList over:

.backgroundPreferenceValue(MenuItemGeometryPreferenceKey.self, alignment: .topLeading) { preferences in
  GeometryReader { proxy in
    selectionBackground(from: preferences, in: proxy)
      .foregroundColor(Color("color/selectionBackground"))
  }
}

Før vi forklarere videre, la ossbygge og kjøre for å se hva vi har:

Wow, dette ser allerede ut som det vi ønsket til å begynne med. Likevel, ikke fancy nok, så vi skal forbedre valganimmasjonen enda mer om noen minutter. Nå er det på tide å forklare hva vi gjorde ovenfor og hvorfor.

I tidligere trinn av implementeringen samlet vi geometri karakteristikken (rammen) til hver knapp i en preferanse. Etter det klarte vi å flytte vår utvalgsbakgrunn til rammen av en valgt visning eller til og med forsvinne helt hvis det ikke er noe valg i det hele tatt.

Som du kanskje la merke til, fungerer Anchor<CGRect> samlet i en ordbok som en proxy som vet hvordan man oversetter eksakte geometri karakteristikker i henhold til et gitt koordinatrom. Og GeometryReader, som holder vår selectionBackground automatisk.

I tillegg lar ikke SwiftUI oss sette rammen direkte, og i stedet er vi tvunget til å forskyve visningen først og sette en rammestørrelse.

Vi kunne lett avslutte her og gå over til en bakgrunns overgang, men vi er ikke ferdige ennå. La oss legge til en subtil overgangseffekt, når valget går fra en knapp til en annen:

Det ser ryddig ut, gjør det ikke? Men hvordan kan vi oppnå denne effekten med SwiftUI?

Ikke bla ned med en gang, gi deg selv et minutt til å tenke over hvordan det kan oppnås først.

Klar til å gå videre? Ok, ideen er enkel og overraskende kort i SwiftUI: på toppen av vår elementliste kan vi legge til allerede valgt liste med en bakgrunn, fylt av en valgt farge. Deretter trenger vi bare å flytte masken, som "viser" dette overlegget over listen vår. Vanskelig å forestille seg? Ta deg god tid og når du er klar, er det på tide å legge mer kode til MenuItemList.itemsList-egenskapen. Men denne gangen i stedet for en backgroundPreferenceValue skal vi bruke dens motpartoverlayPreferenceValue:

.overlayPreferenceValue(MenuItemGeometryPreferenceKey.self, alignment: .topLeading) { value in
  VStack(alignment: .leading) {
    ForEach(items) { item in
      Button(
        action: { /* noop */ },
        label: { Label(item: item) }
      )
      .buttonStyle(MenuItemButtonStyle(isSelected: true))
    }
  }
  .background(Color("color/selectionBackground"))
}

Dette ser ikke ut som en ferdig versjon, så la oss endelig legge til en maske til VStack fra koden ovenfor: 

.overlayPreferenceValue(MenuItemGeometryPreferenceKey.self, alignment: .topLeading) { value in
  VStack(alignment: .leading) {
  ...
  .background(Color("color/selectionBackground"))
 .mask {
    GeometryReader { proxy in
      selectionBackground(from: value, in: proxy)
    }
  }
}

Ok, nå har vi oppnådd samme utseende som før masken, men et forsøk på å velge noe resulterer i ingenting. Saken er at siden vi har lagt til et overlegg fullt av knapper, reagerer alle på våre trykk, men action-lukkingen er tom. Og dette er gjort med vilje. La meg legge til en endelig linje etter maskemodifikatoren og forklare den:

.overlayPreferenceValue(MenuItemGeometryPreferenceKey.self, alignment: .topLeading) { value in
  VStack(alignment: .leading) {
  ...
  .background(...)
  .mask {  
    ...
  }
  .allowsHitTesting(false)
}

Etter denne lille endringen kan du bygge og kjøre for å få en endelig jevn animasjon fra en valgt meny artikkel til en annen.

Men hvorfor håndterte vi ikke berøringer i overlegget? Ved å gjøre det ville vi effektivt ta berøringer fra den originale listen, og dermed la den være uten noen informasjon om knappen blir trykket på eller ikke. Ikke en stor sak ved første øyekast, men dette vil bryte en veldig viktig UX-del - visuell tilbakemelding, siden ingen fremheving av den valgte meny artikkelen vil bli gjort. Du kan eksperimentere for å se utfallet. Det vi gjorde er rett og slett å utelukke hele overlegget fra en brukerinteraksjon, men likevel blir gjengitt på skjermen.

Den endelige versjonen kan bli funnet her.

EGENDEFINERT OVERGANG

I tillegg til utvalg ønsker vi også å ha en tilpasset overgang mellom fargeskjemaer. Du kan referere til begynnelsen av artikkelen for å se at det er en velger som også kan utvides / kollapse akkurat som resten av menyen.

Under valget av et annet fargeskjema, kommer en ny bakgrunn gradvis inn, og gjentar formen på veksleren. Men før vi begynner, la oss legge til eksisterende ColorSchemePicker for å matche menyen til vårt design: den skal gå rett etter alle meny artiklene. Denne visningen finnes allerede i prøve appen, så alt vi trenger er å legge den til i MenuView:

var body: some View {
  VStack {
    MenuItemList(selection: $selection)
    ...
    Spacer()
      .frame(height: 10)

    ColorSchemePicker(selection: Binding($colorScheme, default: systemColorScheme))
      .overrideColorScheme($colorScheme.animation(.default))

    ...
  }
  ...
}

Du kan legge merke til overrideColorScheme-modifikatoren som vi ikke dekket før, og den er ikke presentert i SwiftUI-biblioteket. Hele formålet med det er å overstyre farge skjemaet til det omsluttende UIWindow. Grunnen til å gå denne veien er fordi preferredColorScheme overstyrer farge skjemaet, men etter at menyen forsvinner, er det ikke flere Views i hierarkiet som setter et foretrukket fargeskjema, så det vil bli nullstilt automatisk rett etter at menyen går av skjermen. Du kan undersøke implementeringen, hvis du vil. Hvis det er noe i koden som vi ikke dekket, vennligst utforsk kildekode repo.

Når det er gjort, la oss bygge og kjøre for å se hvordan menyen vår sakte men sikkert blir den fra gif i del 1:

Men på dette tidspunktet, hvis du velger noen av fargeskjemaene, utfører UI ganske enkelt en fade-overgang. Det vi virkelig ønsker her er vår form baserte fade in. Ta et øyeblikk til å tenke over hvordan du ville oppnå dette trinn-for-trinn uten å ta SwiftUI i betraktning. Klar til å gå videre?

Ok, her er hva vi kan gjøre for å oppnå dette: tydeligvis burde det være en form med en farge vi vil ha, som ville fungere som vår bakgrunn. Vi kan enten klippe hele bakgrunnen vår eller skalere den, faktisk spiller det ikke så stor rolle her.

Den neste delen er å få en ramme som vi kan begynne å fadein fra og selvfølgelig en ramme til som vi skal endre størrelsen på vår form. Når du gårtilbake til UI, blir det tydelig at den opprinnelige rammen er den samme som Picker har, mens den endelige - hele visningens grenser.

AnyTransition

Har du noen gang lurt på hvordan AnyTransition fungerer? Vi oppfordrer deg sterkt til å gå gjennom en serie artikler om animasjon fra swiftui-lab.com: Animasjoner og spesielt Advanced Transitions for å få en virkelig dyp forståelse av overgang og animasjon muligheter. La oss kort gå gjennom et eksempel på en opasitets overgang og deretter implementere vår form-fade overgang.

ViewModifier og AnyTransition.modifier(active:idenity:) - dette er bokstavelig talt alt som står bak overgang magi. SwiftUI identifiserer gjennom maskineriet visningen som vil dukke opp eller forsvinne, og animasjon forskjellen mellom to tilstander gjennom de gitte modifikatorene. Det er viktig å forstå at modifikatoren forblir anvendt selv etter visningens fremtreden, i tilfelle det er en innsetting overgang.

Du kunne legge merke til en annen animasjon for noen øyeblikk siden, men hvordan vet SwiftUI hva som har endret seg? Dette er hvor den andre spilleren kommer inn i spillet - Animatable: 

public protocol Animatable {
    /// The type defining the data to animate.
    associatedtype AnimatableData : VectorArithmetic
    /// The data to animate.
    var animatableData: Self.AnimatableData { get set }
}

Vi skal ikke gå veldig dypt med denne - animasjonen går langt utover dagens emne, men merk bare at SwiftUI vet gjennom VectorArithmetic-protokollen og kan interpolere mellom to øyeblikksbilder.

Som et eksempel kan du tenke på å ha to verdier av opasitet: 0 og 1. SwiftUI's jobb under animasjonen er å endre verdien av opasitet mellom 0 og 1 i henhold til animasjonens kurve (lineær, lett inn og ut, osv). Ved å anvende vår opasitet modifikator om og om igjen med en interpolert verdi kan vi se en jevn endring av opasitet. Dette er i hovedsak hele ideen om en animert overgang.

Nå er det på tide å endelig bygge videre med all kunnskapen vi har fått. Første trinn vil være å få en Pickers ramme som vi vil fade in fra. Som før kan vi bruke Anchor for å få en ramme og overføre disse dataene over hierarkiet ved preferanser og spesielt ved å kalle en anchorPreference-modifikator. Siden all data vi trenger er Anchor selv, la oss ganske enkelt overføre den til preferanser. Kildekoden har allerede alle strukturer den trenger, men la oss gjennomgå dem først:

struct AnchorPreferenceKey<Value>: PreferenceKey {

  static var defaultValue: Anchor<Value>? {
    nil
  }

  static func reduce(value: inout Anchor<Value>?, nextValue: () -> Anchor<Value>?) {
    value = nextValue() ?? value
  }
}

Som du kan se, er det bare en generisk innpakning for å sende hvilken som helst Anchor gjennom preferanse. Det eneste trikset her er at vi bruker tidligere verdi i tilfelle nextValue() returnerer null, og grunnen til slik kode er at preferanser ser ut til å kaste bort ankerets verdi når layout er gjort, men overgangen vår skal utføres når som helst, så vi vil beholde den mest nylige gyldige verdien. 

Når det er gjort, kan vi oppdatere vår MenuView for både å anvende anchor preference og oppdatere bakgrunnen for å bruke anchor for overgangen vi skal bygge:

var body: some View {
  VStack {
    ...

    ColorSchemePicker(selection: Binding($colorScheme, default: systemColorScheme))
      .anchorPreference(key: AnchorPreferenceKey<CGRect>.self, value: .bounds) { $0 }
      .overrideColorScheme($colorScheme.animation(.default))

    ...
  }
  ...
  .backgroundPreferenceValue(AnchorPreferenceKey<CGRect>.self) { anchor in
    MenuBackground(colorScheme: colorScheme ?? systemColorScheme)
  }
  ...
}

En liten forklaring: vi la til innstilling av en Pickers anchor til AnchorPreferenceKey og endret bakgrunnen til backgroundPreferenceValue. Sistnevnte gir oss muligheten til å lese en sist kjent anchor, og vi kan bruke den til overgangen vår.

Opprett en ny fil MenuBackgroundFadeInModifier og lim inn følgende kode

private struct MenuBackgroundFadeInModifier: ViewModifier {

  private let identity: Bool
  private let anchor: Anchor<CGRect>?

  init(identity: Bool, anchor: Anchor<CGRect>?) {
    self.identity = identity
    self.anchor = anchor
  }

  func body(content: Content) -> some View {
    content
  }
}

extension AnyTransition {

  static func menuBackgroundFadeIn(from anchor: Anchor<CGRect>?) -> AnyTransition {
    .asymmetric(
      insertion: .modifier(
        active: MenuBackgroundFadeInModifier(identity: false, anchor: anchor),
        identity: MenuBackgroundFadeInModifier(identity: true, anchor: anchor)
      ),
      removal: .identity
    )
  }
}

Foreløpig har vi brukt enda et nytt element av AnyTransition: assymetric, som lar oss spesifisere forskjellige overganger for innsetting og fjerning av en visning, og siden vi egentlig ikke ønsker å bruke den samme animasjonen for fade in, la oss beholde identitet for fade out.

MenuBackgroundFadeInModifier gjør ikke noe annet enn å returnere det umodifiserte innholdet. Du kunne legge merke til at vi sendte et anker fra Picker samt et boolsk flagg, men hva med en endelig rammen?

Her bør vi ta hensyn til SwiftUIs layoutprosess: en forelder foreslår størrelsen sin til et barn, og barnet returnerer deretter sitt eget motforslag. Forelderen må respektere det valget og posisjonere barnet deretter. Det betyr at bakgrunnen vår vil motta hele den tilgjengelige plassen til en omkransende visning, og derfor trenger vi ikke å sende denne størrelsen direkte.

Neste trinn vil være å interpolere mellom to rammer. Men hvordan gjør vi dette? Vi kan ta hensyn til den passerte identiteten , og i tilfelle det er den, bruke den. Ellers bruk hele den gitte rammen.

Som du husker, for å konvertere Anchor til et lokalt koordinatrom trenger vi en GeometryReader, siden Anchor i seg selv koder kilden til geometrien sin, og GeometryReader vet hvordan den kan konvertere den riktig til en absolutt verdi.

En annen ting å tenke på før vi begynner å kode er at et anker kan være valgfritt, derfor kan vi returnere hele den tilgjengelige rammen i dette tilfellet, og behandle det som et ugyldig scenario:

func body(content: Content) -> some View {
  GeometryReader { proxy in
    let frame = effectiveFrame(using: proxy)

    content
      .frame(width: frame.width, height: frame.height)
      .position(x: frame.midX, y: frame.midY)
  }
}

private func effectiveFrame(using proxy: GeometryProxy) -> CGRect {
  guard let anchor, !identity else {
    return proxy.frame(in: .local)
  }

  return proxy[anchor]
}

Her er hva som skjer i code snippet over: i tilfelle det er en fullført overgang, får visningen en full foreslått størrelse av forelderen akkurat som vi beskrev over. GeometryReader godtar foreslått størrelse fullt ut, når overgangen er fullført, får bakgrunnen en full meny størrelse også. I tilfelle overgangen startet og det er et anker tilgjengelig, vil vi sette det som en basisramme.

Du kan spørre: "Ok, men du nevnte Animatable-protokollen for å animere endringer, men bruker den fortsatt ikke. Hva er greia?". Grunnen til at vi ikke bruker eksplisitt animasjon er at ramme- og posisjonsmodifikatorer vet hvordan de kan animere seg selv. Vi gjenbruker bare eksisterende kapabiliteter.

Når vi er ferdig med fade in, la oss oppdatere bakgrunnen i MenuView for å bruke animasjon::

MenuBackground(colorScheme: colorScheme ?? systemColorScheme)
  .transition(.menuBackgroundFadeIn(from: anchor))

Bygg og kjør. Det fungerer ikke. Visningen endrer bare farge animert. Noen ideer?

Som du kan se, går visningen vår aldri bort, og for SwiftUI er det alltid det samme. Men hvordan tvinger vi den til å overgå hver gang fargeskjemaet endres for å utløse overgangen vår?

Dette er der eksplisitt identitet kommer inn i bildet. SwiftUI bruker i stor grad strukturell identitet for å finne ut om det er den samme visningen eller ikke. Men hva om vi vil fortelle at selv om strukturen til visningene er den samme, er dette en annen visning? id-modifikatoren lar oss gjøre nettopp dette. Ved å sende en annen verdi, vil SwiftUI behandle visningen som en helt ny en:

MenuBackground(colorScheme: colorScheme ?? systemColorScheme)
  .transition(.menuBackgroundFadeIn(from: anchor))
  .id(colorScheme ?? systemColorScheme)

Bygg og kjør:

Det ser nesten ok ut, bortsett fra at den gamle bakgrunnen forsvinner umiddelbart. Dette er ikke noe vi vil ha. Ønsket effekt her ville være å holde den i live under hele animasjonen uten noen modifikasjon. Men hvordan kan vi oppnå det?

Dette er hvor Animatable kan hjelpe oss: vi kan tvinge SwiftUI til å animere "noe" unyttig, mens vi vil beholde visningen i hierarkiet uendret. I MenuBackgroundTransition, legg til en annen modifikator og oppdater overgangen vår:

private struct MenuBackgroundFadeOutModifier: ViewModifier, Animatable {

  var animatableData: Double

  func body(content: Content) -> some View {
    content
  }
}

extension AnyTransition {

  static func menuBackgroundFadeIn(from anchor: Anchor<CGRect>?) -> AnyTransition {
    .asymmetric(
      insertion: .modifier(
        active: MenuBackgroundFadeInModifier(identity: false, anchor: anchor),
        identity: MenuBackgroundFadeInModifier(identity: true, anchor: anchor)
      ),
      removal: .modifier(
        active: MenuBackgroundFadeOutModifier(animatableData: 0),
        identity: MenuBackgroundFadeOutModifier(animatableData: 1)
      )
    )
  }
}

Som du tydelig kan se, endrer vi ikke noe, og vi animerer egentlig ikke, men ved å gjøre modifikatoren vår animatable og passere forskjellige verdier, tvinger vi SwiftUI til å bruke vår fade out-modifikator for hele animasjonens varighet, noe som resulterer i en fin og ren overgang:

Den endelige versjonen av kildekoden kan du finne her.

OPPSUMMERING

I løpet av de to delene av denne historien gikk vi fra ingenting til en fullverdig og pent animert meny, dekket en haug med detaljer om hva og hvorfor det fungerer slik det gjør, vi guidet deg langs veien for å mestre SwiftUI med oss. En slik tilnærming er hva som manglet i forskjellige opplæringer som ville vise deg separate deler og konsepter av SwiftUI, og du måtte kombinere alt sammen for å få et produkt-lignende prosjekt på egen hånd. Vi håper denne menyguiden svarte på alle spørsmålene dine langs veien.

Som du kan se, blir SwiftUI komplisert når det kommer til forskjellige interessante interaksjoner, animasjoner, men blir veldig kraftig i sine egenskaper. Det er vanskelig å få alt gjort første gang, men jo mer kunnskap og erfaring du får, jo lettere blir det.

Det var en flott reise, og vi setter pris på tålmodigheten din. Takk for at du leste, og vi ses neste gang!