Hopp til innhold

Ringen som ikke ville krympe: slik bygde vi hviletimer-animasjonen i Musklr

Musklrs hviletimer-ring som morfer mellom fullskjerm og statuslinjen, bygget med SwiftUI matchedGeometryEffect.

matchedGeometryEffect animerer rammen den foreslår, og et view som tegner seg selv i fast størrelse ignorerer forslaget, så bare posisjonen flytter seg. Pakk begge endepunktene i en GeometryReader som leser den foreslåtte størrelsen og bruker scaleEffect, og ringen krymper faktisk fra 220 til 50 punkter mens den flyr.

Fullfører du et sett i Musklr, tar en hviletimer over hele skjermen. En stor nedtellingsring, mørkt bakteppe, ingenting annet som kjemper om oppmerksomheten. Trykk hvor som helst, og timeren flytter seg ut av veien. Frem til versjon 1.180 tonet den bare ut. Nå krymper ringen, flyr til hjørnet og lander i den lille timer-plassen i statuslinjen, i én sammenhengende bevegelse.

Trykk hvor som helst for å minimere. Nedtellingen flyr hjem til statuslinjen.

Det er en detalj de fleste aldri legger bevisst merke til. Vi mener det er nettopp poenget. Et grensesnitt som beveger seg som en fysisk ting, føles til å stole på, på en måte funksjonslister ikke kan fake. Dette innlegget er historien om å bygge den detaljen: hero-animasjonsmønsteret vi brukte i SwiftUI, feilen som gjorde førsteversjonen vår hemmelig falsk, og en playground du kan lime inn i Xcode for å oppleve begge deler selv.

Hva kjennetegner gode endepunkter for en hero-animasjon i SwiftUI?

Musklr hadde allerede begge endepunktene på skjermen. Hviletimer-popupen tegner en nedtellingsring på 220 punkter, og statuslinjen viser en miniring på 50 punkter når en timer kjører. Begge er samme TimerRingView under panseret, og det gjør dem perfekte som endepunkter for en hero-animasjon: lukk popupen, og den store ringen skal fysisk bli den lille. Trykk på minitimeren, og samme flytur skal gå i revers.

SwiftUI har et innebygd verktøy for akkurat dette: matchedGeometryEffect. Merk et view i scene A og et view i scene B med samme id og namespace, bytt mellom dem inne i withAnimation, og SwiftUI animerer rammen fra den ene til den andre. Det er teorien. Her er en versjon du kan kjøre.

Hvordan prøver du en hero-animasjon i SwiftUI i en Xcode-playground?

Du trenger ikke noe prosjekt til dette. Åpne Xcode, File, New, Playground, velg iOS Blank-malen og lim inn denne filen. Det er en forenklet utgave av vårt virkelige oppsett: en ring med fast størrelse, en fullskjerm-tilstand og en hjørne-tilstand, merket med matchedGeometryEffect og byttet med en spring-animasjon.

import SwiftUI
import PlaygroundSupport

/// A countdown ring that draws at ONE fixed size.
/// This fixed frame is exactly what breaks the hero morph below.
struct TimerRing: View {
  let size: CGFloat
  let lineWidth: CGFloat

  var body: some View {
    ZStack {
      Circle()
        .stroke(.gray.opacity(0.25), lineWidth: lineWidth)
      Circle()
        .trim(from: 0, to: 0.62)
        .stroke(.cyan, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
        .rotationEffect(.degrees(-90))
      Text("0:45")
        .font(.system(size: size * 0.24, weight: .semibold, design: .rounded))
        .monospacedDigit()
    }
    .frame(width: size, height: size)
  }
}

struct HeroDemo: View {
  @Namespace private var ns
  @State private var expanded = true

  var body: some View {
    ZStack(alignment: .topTrailing) {
      Color(.systemGroupedBackground).ignoresSafeArea()

      if expanded {
        ZStack {
          Color.black.opacity(0.4).ignoresSafeArea()
          TimerRing(size: 220, lineWidth: 12)
            .matchedGeometryEffect(id: "ring", in: ns)
        }
        .transition(.opacity)
        .onTapGesture {
          withAnimation(.spring(duration: 0.6)) { expanded = false }
        }
      } else {
        TimerRing(size: 50, lineWidth: 4)
          .matchedGeometryEffect(id: "ring", in: ns)
          .padding(24)
          .onTapGesture {
            withAnimation(.spring(duration: 0.6)) { expanded = true }
          }
      }
    }
    .frame(width: 390, height: 700)
  }
}

PlaygroundPage.current.setLiveView(HeroDemo())
Koden fra veiledningen i en Xcode-playground ved siden av live-visningen, med nedtellingsringen i fullskjerm på det dempede overlegget.

Kjør den og trykk på det mørke overlegget. Ringen reiser til hjørnet, den lille ringen dukker opp, alt lander på riktig sted. Klart til å shippe?

Vi var nære. Se nøyere etter. Senk farten på springen, eller ta skjermopptak og gå gjennom bilde for bilde. Den store ringen krymper aldri. Den glir mot hjørnet i konstant 220 punkter og toner ut, mens en egen ring på 50 punkter dukker opp og toner inn. To ringer som glir forbi hverandre, forkledd som én. I simulatoren i full fart lurer det deg. På en ekte telefon i hånden føles det billig, og vi sendte akkurat dette til våre egne testenheter før vi oppdaget det.

Den naive versjonen. Se på ringen: den krymper aldri, den bare glir og tones ut.

Hvorfor skalerer ikke matchedGeometryEffect et view?

matchedGeometryEffect interpolerer rammen den foreslår for viewet ditt. Midt i flyturen får viewet tilbud om en ramme på 180 punkter, så 120, så 60. Men et rammeforslag er bare et tilbud. Et view som tegner seg selv i fast størrelse, ignorerer det. Vår TimerRing gjør akkurat det: Circle().frame(width: size, height: size) er 220 punkter uansett hva forelderen foreslår. Så det eneste som synlig animeres, er posisjonen. Størrelsesinterpolasjonen skjer, og viewet vårt kaster den hvert eneste bilde.

Ingenting advarer deg om det. Det kommer ingen konsollmelding, og begge endepunktene ser perfekte ut når ingenting beveger seg, for i ro er foreslått ramme og fast ramme enige. Feilen finnes bare midt i flyturen.

Hvordan får du matchedGeometryEffect til å skalere innholdet?

Ringen må godta rammen morfen foreslår, og skalere den fast tegnede størrelsen til å passe. Det er en liten, gjenbrukbar wrapper: en GeometryReader som leser foreslått størrelse og bruker scaleEffect.

/// Scales fixed-design-size content to whatever frame matchedGeometryEffect
/// proposes mid-flight. Without this, fixed-size content ignores the
/// interpolated frame and only its POSITION animates.
struct HeroScalableRing<Content: View>: View {
  let designSize: CGFloat
  @ViewBuilder let content: Content

  var body: some View {
    GeometryReader { geo in
      content
        .frame(width: designSize, height: designSize)
        .scaleEffect(min(geo.size.width, geo.size.height) / designSize)
        .position(x: geo.size.width / 2, y: geo.size.height / 2)
    }
  }
}

To detaljer bærer hele løsningen. For det første skal wrapperen på begge endepunktene, med matchedGeometryEffect-merket utenpå, slik at begge ringene skalerer langs samme interpolerte ramme. For det andre betyr .frame(width: 220, height: 220) ETTER merket mer enn det ser ut som: GeometryReader tar grådig all plassen den tilbys, så uten å låse hvilestørrelsen sprenger layouten. Med den er skaleringsfaktoren nøyaktig 1.0 når ringen ikke er i flukt, og da er den fiksede versjonen piksel for piksel lik den naive i ro.

if expanded {
  ZStack {
    Color.black.opacity(0.4).ignoresSafeArea()
    HeroScalableRing(designSize: 220) {
      TimerRing(size: 220, lineWidth: 12)
    }
    .matchedGeometryEffect(id: "ring", in: ns)
    // Load-bearing: GeometryReader is greedy. This outer frame pins the
    // at-rest size so scale == 1 when the ring is not in flight.
    .frame(width: 220, height: 220)
  }
  .transition(.opacity)
  .onTapGesture {
    withAnimation(.spring(duration: 0.6)) { expanded = false }
  }
} else {
  HeroScalableRing(designSize: 50) {
    TimerRing(size: 50, lineWidth: 4)
  }
  .matchedGeometryEffect(id: "ring", in: ns)
  .frame(width: 50, height: 50)
  .padding(24)
  .onTapGesture {
    withAnimation(.spring(duration: 0.6)) { expanded = true }
  }
}

Kjør den fiksede filen og trykk. Nå krymper ringen faktisk mens den flyr, strek, sifre, alt sammen, fra 220 punkter ned til 50. Ett objekt, én bevegelse.

Hva må til for en hero-animasjon i en ekte app?

Playgrounden viser mønsteret. Produksjonsversjonen har noen ekstra grep som er verdt å stjele om du bygger noe lignende:

  • Minitimerens plass byttes til Color.clear mens popupen er oppe. Plassen beholder fotavtrykket på 50 punkter, så statuslinjen flyter aldri om midt i flyturen, og heroen har alltid en stabil landingsplass.
  • Bare ringen er merket. Bakteppet, tittelen og Hopp over-knappen toner gjennom containerens opacity-overgang. En hero-animasjon leses best når nøyaktig ett element flyr.
  • Hjørnejusteringen er layout, ikke offset. Vi ville ha minitimeren litt inn mot hjørnet. Å gjøre det med .offset ville ødelagt landingen, fordi offset flytter piksler etter layout, og matchedGeometryEffect måler layout-rammer. En overdimensjonert ramme med bottomTrailing-justering flytter den ekte rammen.
  • Redusert bevegelse er én egenskap, ikke fire sjekker. Alle animasjonene funksjonen utløser, går gjennom én beregnet egenskap som returnerer nil når brukeren har slått på Redusert bevegelse, så hele funksjonen faller tilbake til den gamle krysstoningen med én linje.
Motsatt vei: trykk på minitimeren, og ringen flyr tilbake til midten.

Hvorfor avslører ikke en test av posisjonen at en hero-animasjon er ødelagt?

Én ting til bet oss, og det er ikke noe SwiftUI-spesifikt. Vår første automatiske sjekk tok opp simulatoren, fulgte ringens senter gjennom bildene og bekreftet en jevn flytur til hjørnet. Den passerte. Den målte også den ene egenskapen som animeres selv når morfen er ødelagt. Senteret til en ring i konstant størrelse som glir til hjørnet, beveger seg nøyaktig som senteret til en ring som krymper på veien.

Etter fiksen måler sjekken ringens omsluttende boks per bilde i stedet: én klump, diameter som marsjerer monotont fra 231 punkter ned til 52. Verifiserer du geometri-animasjoner med skjermopptak, så mål det som kan feile. Posisjon lyver sjelden om posisjon, men den sier ingenting om størrelse.

Hvorfor gjør små animasjonsdetaljer at en app føles bygget i stedet for sammensatt?

En hviletimer som flyr til plassen sin, havner aldri i en funksjonsliste på App Store. Men det er forskjellen på en app som føles sammensatt og en app som føles bygget. Musklr er full av slike detaljer, som settlogging med to trykk og det levende muskelkartet, og alle får samme oppmerksomhet, fordi styrketrening er repetitivt og appen du ser fire ganger i uka bør føles bra hver gang.

Ofte stilte spørsmål

Hvorfor advarer ingenting når matchedGeometryEffect bare animerer posisjonen?

Fordi det ikke er noe ugyldig ved koden. Det kommer ingen konsollmelding, og begge endepunktene ser perfekte ut når ingenting beveger seg, for i ro er foreslått ramme og fast ramme enige. Feilen finnes bare midt i flyturen, og i simulatoren i full fart lurer den deg. Senk farten på springen, eller gå gjennom et skjermopptak bilde for bilde, og da glir ringen tydelig i fast størrelse i stedet for å krympe.

Hvorfor må en GeometryReader-wrapper ha en fast ramme etter matchedGeometryEffect-merket?

Fordi GeometryReader tar grådig all plassen den tilbys, så uten å låse hvilestørrelsen sprenger layouten. Legg rammen etter matchedGeometryEffect-merket, og skaleringsfaktoren er nøyaktig 1.0 når ringen ikke er i flukt, slik at den fiksede versjonen er piksel for piksel lik den naive i ro. Wrapperen skal på begge endepunktene, så begge ringene skalerer langs samme interpolerte ramme.

Bør flere enn ett view merkes i en hero-animasjon?

Vanligvis ikke. Her er bare ringen merket. Bakteppet, tittelen og Hopp over-knappen toner gjennom containerens opacity-overgang i stedet, fordi en hero-animasjon leses best når nøyaktig ett element flyr. De to endene av denne flyturen er samme TimerRingView på 220 og på 50 punkter, og det er nettopp derfor de er perfekte endepunkter å merke.

Hvorfor må landingsplassen i en hero-animasjon holdes av mens popupen er oppe?

For at heroen alltid skal ha en stabil landingsplass. Mens popupen er oppe byttes plassen til Color.clear i stedet for å forsvinne, så den beholder fotavtrykket på 50 punkter og statuslinjen flyter aldri om midt i flyturen. Det betyr noe fordi matchedGeometryEffect måler layout-rammer: ringen interpolerer mot der layouten legger den plassen, så plassen må bli stående.

Hvorfor ødelegger .offset landingen i matchedGeometryEffect når en overdimensjonert ramme ikke gjør det?

Fordi offset flytter piksler etter layout, og matchedGeometryEffect måler layout-rammer. Å skyve minitimeren inn mot hjørnet med .offset flytter det du ser, uten å flytte det morfen sikter mot, og det er nettopp det som ødelegger landingen. En overdimensjonert ramme med bottomTrailing-justering flytter den ekte rammen i stedet, så målet flytter seg med den og ringen lander fortsatt i plassen.

Hva skjer med en hero-animasjon i SwiftUI når Redusert bevegelse er slått på?

Hele funksjonen faller tilbake til den gamle krysstoningen. Alle animasjonene den utløser går gjennom én beregnet egenskap som returnerer nil når brukeren har slått på Redusert bevegelse, så det er én linje i stedet for fire sjekker, og ingen ekstra kodevei å holde i takt. Frem til versjon 1.180 tonet timeren ut for alle, så det er oppførsel appen allerede hadde levert.

Gratis på iPhone og Apple Watch. Logg et sett med to trykk.

Se hva Musklr gjør

Nysgjerrig på hvordan Musklr står seg mot Strong, Hevy, Jefit og Fitbod? Les sammenligningen vår.

Del dette innlegget