Animații SwiftUI 2026: PhaseAnimator, KeyframeAnimator și Accesibilitate
Ghid practic pentru animații SwiftUI în 2026: withAnimation, PhaseAnimator, KeyframeAnimator, curbe spring, matchedGeometryEffect și cum respecți Reduce Motion pentru accesibilitate reală, cu exemple de cod testate.
Animațiile în SwiftUI sunt schimbări de stare interpolate automat între două valori. Declari „unde vrei să ajungi", iar framework-ul se ocupă de restul, inclusiv de curba de timing, de întreruperi și de contextul de accesibilitate. Începând cu iOS 17 și consolidat în iOS 18, ai trei familii distincte pe care le folosesc zilnic în producție: withAnimation pentru schimbări simple de stare, PhaseAnimator pentru secvențe multi-fază, și KeyframeAnimator atunci când vrei control granular pe fiecare proprietate. În acest ghid le tratez pe toate, împreună cu Reduce Motion și VoiceOver, pentru că o animație inaccesibilă e un bug, nu o funcționalitate.
withAnimation declanșează animații pentru toate schimbările de stare care se produc în closure. Este alegerea implicită pentru interacțiuni scurte.
PhaseAnimator (iOS 17+) rulează o secvență de faze predefinite fără să fie nevoie de @State intermediar, ideal pentru bucle idle.
KeyframeAnimator permite animarea independentă a mai multor proprietăți în paralel, cu curbe diferite per proprietate.
@Environment(\.accessibilityReduceMotion) este singura sursă corectă pentru a decide dacă suprimi mișcarea. Nu presupune niciodată.
Curbele spring cu bounce și duration au înlocuit vechiul API bazat pe response/dampingFraction începând cu iOS 17.
matchedGeometryEffect creează tranziții morph între două view-uri diferite din arbore fără să scrii tu interpolarea.
Cum fac animații în SwiftUI?
În SwiftUI declari ce se schimbă și lași framework-ul să calculeze cum. Există două forme sintactice, ambele valide: apelul explicit withAnimation { … }, care aplică o animație tuturor modificărilor de stare produse înăuntru, respectiv modifier-ul .animation(_:value:) atașat unui view, care reacționează automat când valoarea observată se schimbă. În practică, alegerea depinde de cine declanșează schimbarea: dacă e un buton într-un view, folosesc withAnimation; dacă view-ul trebuie să răspundă la orice sursă externă care schimbă starea, folosesc modifier-ul.
Iată exemplul minim pe care îl scriu mereu când introduc pe cineva în subiect.
import SwiftUI
struct AnimatiiSimple: View {
@State private var expandat = false
var body: some View {
VStack(spacing: 24) {
RoundedRectangle(cornerRadius: 16)
.fill(.tint)
// Modifier-ul reactiv: animează orice schimbare a lui `expandat`
.frame(width: expandat ? 240 : 120, height: 120)
.animation(.smooth(duration: 0.4), value: expandat)
Button("Comută dimensiunea") {
// Formă explicită: aplică animația și pentru alte view-uri afectate
withAnimation(.spring(duration: 0.5, bounce: 0.35)) {
expandat.toggle()
}
}
}
.padding()
}
}
Cheia este că SwiftUI nu interpolează pixeli, ci valorile care alimentează view-ul. Când expandat devine true, framework-ul calculează o traiectorie de la 120 la 240 pe durata specificată, iar body-ul este re-evaluat de zeci de ori pe secundă cu valori intermediare. Acesta e motivul pentru care orice tip conform Animatable poate fi animat, inclusiv al tău (cum arăt mai jos).
Diferența dintre withAnimation și modifier-ul .animation
Sincer, e cea mai frecventă întrebare pe care o primesc la review-uri: „De ce animația mea funcționează pe iPhone dar nu și după push?" Aproape întotdeauna răspunsul e că cineva a folosit withAnimation într-un context în care schimbarea de stare venea din altă parte, de exemplu dintr-un onReceive sau dintr-un NotificationCenter. Diferența practică e simplă:
withAnimation(_:) { … } este imperativ. Animează schimbările produse doar în interiorul closure-ului. Dacă starea se schimbă din altă cale de execuție, nu se aplică.
.animation(_:value:) este declarativ. Se atașează unui view și declanșează animația de fiecare dată când value se schimbă, indiferent de cine îl setează.
Regula mea empirică: dacă schimbarea vine dintr-un gest al utilizatorului exact în locul acela, folosesc withAnimation. Dacă view-ul trebuie să răspundă la o stare care „vine de undeva", folosesc modifier-ul. Există și o a treia opțiune, Transaction, pe care o discut în secțiunea despre anti-pattern-uri. E utilă când vrei să anulezi o animație moștenită.
Curbe de timing: spring, easeInOut și timingCurve
Curba de timing e ceea ce transformă o animație corectă într-una care simte bine. SwiftUI oferă patru familii de bază: linear, easeIn/easeOut/easeInOut, spring (cu variantele smooth, snappy, bouncy) și timingCurve pentru control cubic-Bezier arbitrar. Începând cu iOS 17, API-ul de spring a fost simplificat radical. În loc să calculezi mental response și dampingFraction, specifici direct duration și bounce.
// iOS 17+ — API-ul recomandat
.animation(.spring(duration: 0.45, bounce: 0.28), value: state)
// Predefiniții semantice: folosește-le când poți
.animation(.smooth, value: state) // spring fără bounce, potrivit pentru UI
.animation(.snappy, value: state) // răspuns rapid, bounce mic
.animation(.bouncy, value: state) // bounce vizibil, potrivit pentru feedback ludic
// Curbă cubic-Bezier personalizată
.animation(.timingCurve(0.2, 0.9, 0.4, 1.0, duration: 0.6), value: state)
Alegerea corectă a curbei este 80% din senzația unei animații bune. În experiența mea, pentru interacțiuni de tip „apas un buton, apare un panou" .smooth câștigă mereu, pentru că se oprește curat, fără oscilație. Pentru feedback fizic (drag, flick, elemente care intră în ecran) folosesc .spring cu bounce între 0.2 și 0.35. Peste 0.5 devine caricatură. Curbele linear le rezerv exclusiv pentru progres continuu (rotații infinite, indicatori de încărcare), pentru că orice altceva le face să pară „ieftine".
PhaseAnimator: animații multi-fază pentru bucle idle
PhaseAnimator e API-ul pe care îl aștept de ani întregi. Introdus în iOS 17, îți permite să declari o secvență de faze (enum-uri, în general) prin care view-ul trece automat, fără să ai nevoie de Timer, DispatchQueue.asyncAfter sau @State-uri intermediare. Framework-ul avansează fazele singur, folosind curbele pe care le specifici pentru fiecare tranziție.
Cazul de utilizare canonic e un buton „Pulsează" sau un indicator care atrage atenția fără să fie deranjant. Iată implementarea reală pe care am shipat-o într-un flow de onboarding luna trecută:
enum FazaPulse: CaseIterable {
case repaus, expandare, contractie
var scala: Double {
switch self {
case .repaus: return 1.0
case .expandare: return 1.15
case .contractie: return 0.95
}
}
var opacitate: Double {
self == .repaus ? 1.0 : 0.85
}
}
struct ButonPulsant: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
Button("Începe") { /* acțiune */ }
.buttonStyle(.borderedProminent)
.phaseAnimator(
FazaPulse.allCases,
trigger: reduceMotion ? false : nil
) { content, faza in
content
.scaleEffect(faza.scala)
.opacity(faza.opacitate)
} animation: { faza in
switch faza {
case .repaus: .easeInOut(duration: 0.8)
case .expandare: .spring(duration: 0.5, bounce: 0.3)
case .contractie: .easeOut(duration: 0.35)
}
}
}
}
Trei detalii pe care le văd greșite frecvent: (1) fazele trebuie să fie Equatable. Pentru enum-uri simple este automat, dar dacă folosești struct-uri trebuie să confirmi conformitatea; (2) parametrul trigger: pornește/oprește ciclul, așa că îl folosesc mereu ca punct de integrare cu Reduce Motion; (3) closure-ul animation: primește faza destinație, nu cea sursă, deci specifici curba spre care te îndrepți. Pentru o secvență finită (nu buclă), folosește varianta cu trigger: care avansează o singură dată la fiecare schimbare a valorii.
KeyframeAnimator: control fin pe fiecare proprietate
Când PhaseAnimator nu îți ajunge, pentru că vrei ca scala și rotația să evolueze pe curbe diferite, în timpi diferiți, cu overshoot doar pe una, KeyframeAnimator este răspunsul. E cel mai apropiat lucru pe care îl are SwiftUI de o linie de timp After Effects, direct în cod. Definești o structură de „valori animate" și un timeline de keyframes per proprietate.
Cheia de citire este că fiecare KeyframeTrack descrie evoluția unei singure proprietăți în timp. Durata totală e suma keyframes-urilor de pe cel mai lung track. Ai patru tipuri de keyframe: LinearKeyframe, CubicKeyframe, SpringKeyframe și MoveKeyframe (ultimul sare instantaneu, fără interpolare, util pentru resetare). Combinația SpringKeyframe pentru scală plus CubicKeyframe pentru rotație produce senzația de „element viu" care nu se obține cu o singură curbă globală.
matchedGeometryEffect și tranziții morph
matchedGeometryEffect este fratele mai deștept al lui Hero Animation din UIKit. Îți permite să spui: „acest view din stânga și acest view din dreapta sunt același element din perspectiva utilizatorului, deci animează geometria între ei". SwiftUI calculează singur interpolarea de poziție, dimensiune și, opțional, formă. Îl folosesc constant pentru expansiunea unui card într-un detaliu full-screen.
struct GaleriaCarduri: View {
@Namespace private var animatie
@State private var selectat: Int? = nil
var body: some View {
ZStack {
if let id = selectat {
CardDetaliu(id: id, namespace: animatie) {
withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
selectat = nil
}
}
} else {
LazyVGrid(columns: [.init(), .init()]) {
ForEach(0..<6) { id in
RoundedRectangle(cornerRadius: 16)
.fill(.tint)
.matchedGeometryEffect(id: id, in: animatie)
.frame(height: 140)
.onTapGesture {
withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
selectat = id
}
}
}
}
.padding()
}
}
}
}
Trucul central este @Namespace: creează un „spațiu de nume" în care perechile de id-uri se pot găsi între ele. Când starea se schimbă și un view dispare iar altul cu același id apare, SwiftUI le tratează ca pe un singur element și interpolează geometria. Am scris despre pattern-uri de navigare similare în ghidul meu despre NavigationStack în SwiftUI. Combinat cu matchedGeometryEffect, obții tranziții de detaliu care rivalizează cu app-uri native precum Photos.
Animatable: cum îți animezi propriul tip
Uneori vrei să animezi o proprietate care nu este suportată nativ, de exemplu un unghi care controlează un Path personalizat, sau un procentaj de umplere într-un desen custom. Aici intri în lumea protocolului Animatable. Regula e simplă: expui o proprietate animatableData de tip conform VectorArithmetic, iar SwiftUI o va interpola pentru tine frame-cu-frame.
struct ArcProgress: Shape, Animatable {
var procent: Double // 0.0 ... 1.0
// Aceasta este proprietatea pe care SwiftUI o va interpola
var animatableData: Double {
get { procent }
set { procent = newValue }
}
func path(in rect: CGRect) -> Path {
var p = Path()
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = min(rect.width, rect.height) / 2 - 8
p.addArc(
center: center,
radius: radius,
startAngle: .degrees(-90),
endAngle: .degrees(-90 + 360 * procent),
clockwise: false
)
return p
}
}
struct IndicatorInel: View {
@State private var progres: Double = 0.0
var body: some View {
ArcProgress(procent: progres)
.stroke(.tint, style: .init(lineWidth: 12, lineCap: .round))
.frame(width: 160, height: 160)
.animation(.smooth(duration: 0.8), value: progres)
.onAppear { progres = 0.72 }
}
}
Pentru proprietăți multiple (de exemplu un punct 2D cu x și y), folosește AnimatablePair<Double, Double>. Pentru trei valori, AnimatablePair<Double, AnimatablePair<Double, Double>>. Da, se aglomerează, dar funcționează. Este exact tehnica pe care Apple o folosește intern pentru Angle, CGPoint, CGSize. E util să înțelegi mecanica dacă lucrezi cu concurență în Swift, pentru că interpolarea rulează pe MainActor și trebuie să nu blochezi thread-ul cu calcule grele în path(in:).
Reduce Motion, VoiceOver și accesibilitate
Aceasta este secțiunea pe care nu o poți sări. Aproximativ 8% din utilizatorii iOS au Reduce Motion activat. Pentru unii este preferință, pentru alții este necesitate medicală (vertij vestibular, epilepsie fotosensibilă, tulburări de procesare senzorială). Dacă ignori setarea, îți excluzi activ o parte din audiență. Regula după care mă ghidez: orice animație non-esențială trebuie să respecte @Environment(\.accessibilityReduceMotion).
struct AnimatieRespectoasa: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var vizibil = false
// Strategie: alege o animație inofensivă când Reduce Motion e pornit
private var animatie: Animation {
reduceMotion ? .easeInOut(duration: 0.15) : .spring(duration: 0.6, bounce: 0.35)
}
// Strategie: dezactivează complet efectele parallax/bounce
private var tranzitia: AnyTransition {
reduceMotion ? .opacity : .asymmetric(
insertion: .scale(scale: 0.6).combined(with: .opacity),
removal: .opacity
)
}
var body: some View {
VStack {
if vizibil {
Text("Salut!")
.padding()
.background(.regularMaterial, in: .rect(cornerRadius: 12))
.transition(tranzitia)
.accessibilityAddTraits(.isHeader)
}
Button(vizibil ? "Ascunde" : "Arată") {
withAnimation(animatie) { vizibil.toggle() }
}
.accessibilityHint("Comută vizibilitatea mesajului de salut")
}
}
}
Trei principii practice: (1) înlocuiește tranzițiile de tip scale, slide, move cu .opacity când Reduce Motion este activ, pentru că cross-fade-ul este mereu sigur; (2) scurtează durata la ~150ms în loc să o elimini complet, pentru că absența totală a feedback-ului vizual dezorientează la fel de mult; (3) pentru VoiceOver, adaugă .accessibilityLabel, .accessibilityHint și .accessibilityAddTraits pe elementele interactive, pentru că animația fără narațiune este invizibilă pentru cei care nu privesc ecranul.
Anti-pattern-uri de evitat
Am făcut ochi review la sute de PR-uri cu animații. Următoarele apar cu o frecvență descurajantă:
Animație moștenită accidentală. Pui .animation(.spring, value: state) pe un părinte și te trezești că toate subview-urile animează la orice re-render. Soluție: aplică .animation pe view-ul concret, nu pe container. Sau folosește Transaction { $0.disablesAnimations = true } pentru a anula moștenirea.
withAnimation în răspunsul unui Task async. Dacă apelezi withAnimation după un await, ești deja într-un context posibil off-main. Ambalează în await MainActor.run { withAnimation { … } } sau, mai bine, folosește @MainActor pe view-model.
Durata în funcție de conținut. „Cu cât e mai mult text, cu atât animez mai lent", nu. Utilizatorul percepe orice animație peste 500ms ca lag. Păstrează sub 600ms și lasă complexitatea vizuală să compenseze.
Loop-uri infinite fără trigger de oprire. Un .repeatForever uitat pe un view detașat continuă să consume cicluri; folosește PhaseAnimator cu trigger: care poate fi controlat de scope.
Testare doar pe iPhone 16 Pro. Rulează animațiile pe cel mai vechi device suportat. Un spring cu bounce mare pe un iPhone SE arată complet altfel decât pe ProMotion 120Hz.
Dacă lucrezi la o suită de teste pentru cod animat, aruncă o privire peste ghidul meu despre Swift Testing. Există trucuri pentru a testa deterministic view-uri care depind de timp, folosind Clock injectat.
Întrebări frecvente
Care este diferența practică între PhaseAnimator și KeyframeAnimator?
PhaseAnimator parcurge automat o secvență de faze predefinite folosind aceeași animație globală per tranziție. KeyframeAnimator îți permite să animezi proprietăți diferite în paralel, fiecare cu propria linie de timp și curbă. Alege PhaseAnimator pentru bucle repetitive simple; KeyframeAnimator pentru momente „hero" cu control granular.
De ce animația mea SwiftUI nu funcționează în interiorul unui Task?
Pentru că withAnimation trebuie apelat pe MainActor. După un await, contextul poate fi altul. Marchează view-model-ul cu @MainActor sau folosește await MainActor.run { withAnimation { … } }. Modifier-ul reactiv .animation(_:value:) nu are această problemă.
Cum dezactivez o animație moștenită de la un părinte?
Folosește .transaction { $0.animation = nil } pe view-ul copil sau .animation(nil, value: state). Aceasta suprascrie orice animație setată de container. E util atunci când un părinte are .animation global și un anumit copil trebuie să sară instant.
Ce înseamnă parametrii bounce și duration la .spring?
duration este timpul aproximativ până când animația se stabilizează. bounce (0.0 – 1.0) controlează cât de mult depășește ținta: 0 = fără oscilație, 0.3 = feedback natural, 0.6+ = ludic/exagerat. Acest API a înlocuit vechiul response/dampingFraction începând cu iOS 17 și e mult mai intuitiv.
Cum testez cum arată animația mea cu Reduce Motion activat?
În Simulator: Features → Toggle Accessibility Shortcut, apoi activează Reduce Motion din setări. Sau folosește .environment(\.accessibilityReduceMotion, true) într-un preview SwiftUI pentru a forța comportamentul fără a modifica setările globale.
Ghid complet Swift Testing în 2026: macro-urile @Test și @Suite, diferența #expect vs #require, teste parametrizate, async/await și pașii migrării de la XCTest, cu exemple reale.
Ghid practic pentru migrarea de la ObservableObject la macro-ul @Observable în SwiftUI iOS 17: pași concreți, exemple de cod, @Bindable, @Environment și capcane reale întâlnite în producție.
Învață concurența modernă în Swift: de la async/await și TaskGroup la Actors, Sendable și regulile stricte din Swift 6. Ghid practic cu exemple funcționale pentru aplicații iOS.