Ringen som ikke ville krympe: slik bygde vi hviletimer-animasjonen i Musklr
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.
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())
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.
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.clearmens 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
.offsetville ødelagt landingen, fordi offset flytter piksler etter layout, ogmatchedGeometryEffectmåler layout-rammer. En overdimensjonert ramme medbottomTrailing-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
nilnår brukeren har slått på Redusert bevegelse, så hele funksjonen faller tilbake til den gamle krysstoningen med én linje.
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ørNysgjerrig på hvordan Musklr står seg mot Strong, Hevy, Jefit og Fitbod? Les sammenligningen vår.