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.

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 vi ville ha

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.

Bygg det i en 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 matchedGeometryEffect ikke ville skalere ringen

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.

Fiksen: skaler innholdet med den foreslåtte rammen

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.

I den ekte appen

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.

Den andre lærdommen: verifiser størrelse, ikke posisjon

É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.

Små ting, gjort ordentlig

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.

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.