Въведение: Защо анимациите са толкова важни в SwiftUI
Ако някога сте натиснали бутон в iOS приложение и сте усетили онова приятно „подскачане" или плавен преход — знаете колко много значат анимациите. Те не са просто визуална украса. Добре направената анимация помага на потребителя да разбере какво се случва, къде отива и защо. Без нея интерфейсът се усеща... мъртъв.
И ето добрата новина: SwiftUI прави анимациите изненадващо лесни.
В тази статия ще минем през всичко — от най-простите имплицитни анимации до по-сериозните инструменти като PhaseAnimator и KeyframeAnimator. Ще видим hero анимации с matchedGeometryEffect, персонализирани преходи, както и практики за производителност, които наистина правят разлика. И разбира се — всичко с работещи примери, които можете да хвърлите направо в проекта си.
Как работят анимациите в SwiftUI
Преди да се хвърлим в кода, нека изясним нещо важно: SwiftUI не анимира изгледи директно — анимира промени в състоянието. Когато някоя стойност се промени, фреймуъркът изчислява разликите и (ако има прикачена анимация) плавно интерполира между старата и новата стойност.
Зад кулисите всяка промяна на състоянието създава транзакция (Transaction). Може да звучи малко абстрактно, но на практика транзакцията просто носи информацията за анимацията. И withAnimation, и модификаторът .animation всъщност са построени върху withTransaction и .transaction — транзакциите са основният примитив.
Имплицитни анимации с модификатора .animation()
Имплицитните анимации са най-лесният начин да вкарате движение в SwiftUI. Работят пасивно — казвате как да се анимира елементът, и SwiftUI се грижи за останалото. Просто закачате модификатора .animation(_:value:):
struct ImplicitAnimationDemo: View {
@State private var isScaled = false
var body: some View {
Circle()
.fill(.blue)
.frame(width: 100, height: 100)
.scaleEffect(isScaled ? 1.5 : 1.0)
.animation(.easeInOut(duration: 0.5), value: isScaled)
.onTapGesture {
isScaled.toggle()
}
}
}
Натискате кръга — той плавно расте. Натискате отново — свива се. Ключът е параметърът value:, който казва на SwiftUI коя стойност да наблюдава.
Важно: Старият синтаксис .animation(.spring()) без value: е deprecated. И за добра причина — без него несвързани промени на състоянието можеха случайно да задействат анимации, което водеше до доста объркващо поведение.
Видове вградени криви на анимация
SwiftUI идва с няколко готови криви за контрол на скоростта:
.linear— постоянна скорост от начало до край.easeIn— бавно стартиране, ускоряване към края.easeOut— бързо стартиране, забавяне към края.easeInOut— бавно в началото и края, бързо в средата.spring— пружинна анимация (по подразбиране от iOS 17)
Пружинни анимации (Spring Animations)
Честно казано, пружинните анимации са любимите ми. Те симулират поведението на реална пружина и изглеждат просто по-добре от линейните криви — елементите леко преминават крайната си позиция и се връщат обратно, точно както бихте очаквали от физически обект.
От iOS 17 нататък SwiftUI ги използва по подразбиране, и това е правилното решение.
// Бърза пружина с голямо отскачане
.animation(.spring(duration: 0.6, bounce: 0.7), value: isActive)
// Плавна пружина без отскачане
.animation(.spring(duration: 0.4, bounce: 0.0), value: isActive)
// Интерполираща пружина за прецизен контрол
.animation(.interpolatingSpring(stiffness: 200, damping: 15), value: isActive)
Параметрите duration и bounce контролират съответно приблизителната продължителност и степента на отскачане (от 0.0 до 1.0). За повечето UI анимации стойности между 0.0 и 0.3 за bounce дават приятен, ненатрапчив ефект. Ако вдигнете над 0.5 — ще започне да прилича на подскачаща топка (което понякога е точно това, което искате).
Експлицитни анимации с withAnimation
Докато имплицитните анимации казват как, експлицитните казват кога. Обвивате промяната на състоянието в withAnimation блок и всичко вътре се анимира:
struct ExplicitAnimationDemo: View {
@State private var rotation: Double = 0
@State private var scale: Double = 1.0
var body: some View {
Image(systemName: "star.fill")
.font(.system(size: 60))
.foregroundStyle(.yellow)
.rotationEffect(.degrees(rotation))
.scaleEffect(scale)
.onTapGesture {
withAnimation(.spring(duration: 0.8, bounce: 0.5)) {
rotation += 72
scale = scale == 1.0 ? 1.3 : 1.0
}
}
}
}
Разликата е в подхода: withAnimation проактивно казва „всяка промяна от този блок трябва да бъде анимирана". Това е особено полезно, когато искате да анимирате няколко свойства наведнъж от едно събитие.
Кога да използвате имплицитни и кога експлицитни анимации?
Ето бързо правило:
- Имплицитни: Когато конкретен изглед трябва винаги да анимира определено свойство, без значение откъде идва промяната
- Експлицитни: Когато искате анимация само от конкретно събитие — натискане на бутон, получаване на данни и т.н.
- Приоритет: Имплицитна анимация на дъщерен елемент винаги презаписва тази на родителския
Повтарящи се и обръщащи се анимации
Понякога ви трябва анимация, която не спира — пулсиращ индикатор, мигаща точка, нещо подобно. SwiftUI предоставя .repeatCount и .repeatForever за точно тези случаи:
struct PulsingDot: View {
@State private var isPulsing = false
var body: some View {
Circle()
.fill(.red)
.frame(width: 20, height: 20)
.scaleEffect(isPulsing ? 1.3 : 1.0)
.opacity(isPulsing ? 0.6 : 1.0)
.animation(
.easeInOut(duration: 0.8)
.repeatForever(autoreverses: true),
value: isPulsing
)
.onAppear {
isPulsing = true
}
}
}
Този код създава пулсиращ червен кръг, който непрекъснато расте и се свива. Параметърът autoreverses: true е важен тук — без него анимацията ще скочи рязко в началното си състояние, вместо да се върне плавно.
Преходи между изгледи (Transitions)
Преходите контролират как изгледите се появяват и изчезват. Прилагат се с .transition() и работят заедно с условно рендиране (if/else блокове):
struct TransitionDemo: View {
@State private var showDetail = false
var body: some View {
VStack(spacing: 20) {
Button("Покажи детайли") {
withAnimation(.spring(duration: 0.5)) {
showDetail.toggle()
}
}
if showDetail {
Text("Това е детайлният изглед")
.padding()
.background(.blue.opacity(0.2))
.cornerRadius(12)
.transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .move(edge: .leading).combined(with: .opacity)
))
}
}
}
}
Вградените преходи включват .opacity, .slide, .scale, .move(edge:) и .push(from:). Можете да ги комбинирате с .combined(with:), а с .asymmetric да зададете различни ефекти за появяване и изчезване — което дава наистина елегантен резултат.
Създаване на персонализиран преход
Ако вградените преходи не ви стигат, можете да си направите собствен чрез ViewModifier:
struct RotateAndFadeModifier: ViewModifier {
let isActive: Bool
func body(content: Content) -> some View {
content
.rotationEffect(.degrees(isActive ? 90 : 0))
.opacity(isActive ? 0 : 1)
.scaleEffect(isActive ? 0.5 : 1.0)
}
}
extension AnyTransition {
static var rotateAndFade: AnyTransition {
.modifier(
active: RotateAndFadeModifier(isActive: true),
identity: RotateAndFadeModifier(isActive: false)
)
}
}
// Използване:
Text("Персонализиран преход")
.transition(.rotateAndFade)
Hero анимации с matchedGeometryEffect
Ако трябва да избера една анимационна техника в SwiftUI, която наистина впечатлява, това е matchedGeometryEffect. Представете си го като Magic Move от Keynote — два изгледа в различни части на йерархията плавно преливат един в друг, защото споделят идентификатор и пространство от имена:
struct HeroAnimationDemo: View {
@Namespace private var animation
@State private var isExpanded = false
var body: some View {
VStack {
if isExpanded {
// Разширен изглед
RoundedRectangle(cornerRadius: 20)
.fill(.blue.gradient)
.matchedGeometryEffect(id: "card", in: animation)
.frame(height: 300)
.overlay(
Text("Детайли на картата")
.font(.title)
.foregroundStyle(.white)
)
} else {
// Компактен изглед
RoundedRectangle(cornerRadius: 12)
.fill(.blue.gradient)
.matchedGeometryEffect(id: "card", in: animation)
.frame(width: 150, height: 100)
.overlay(
Text("Карта")
.foregroundStyle(.white)
)
}
}
.onTapGesture {
withAnimation(.spring(duration: 0.6, bounce: 0.3)) {
isExpanded.toggle()
}
}
}
}
Ключовите моменти:
@Namespaceсъздава уникално пространство, в което SwiftUI търси съвпадения- И двата изгледа ползват еднакъв
idиnamespace - SwiftUI автоматично интерполира рамката между двете позиции
Бърз съвет: За hero анимации при навигация в NavigationStack погледнете matchedTransitionSource — специално направен за запазване на геометрията между изгледи в навигационния стек.
PhaseAnimator: Многостъпкови анимации
Да, PhaseAnimator (iOS 17+) е едно от нещата, за които се зарадвах истински. Вместо да анимирате между две състояния, дефинирате поредица от фази и SwiftUI автоматично преминава през тях една по една:
enum AnimationPhase: CaseIterable {
case initial
case moveUp
case scaleUp
case rotate
var yOffset: Double {
switch self {
case .initial: 0
case .moveUp: -50
case .scaleUp: -50
case .rotate: 0
}
}
var scale: Double {
switch self {
case .initial: 1.0
case .moveUp: 1.0
case .scaleUp: 1.5
case .rotate: 1.0
}
}
var rotation: Double {
switch self {
case .initial: 0
case .moveUp: 0
case .scaleUp: 0
case .rotate: 360
}
}
}
struct PhaseAnimatorDemo: View {
@State private var trigger = false
var body: some View {
VStack(spacing: 40) {
PhaseAnimator(AnimationPhase.allCases, trigger: trigger) { phase in
Image(systemName: "star.fill")
.font(.system(size: 60))
.foregroundStyle(.yellow)
.offset(y: phase.yOffset)
.scaleEffect(phase.scale)
.rotationEffect(.degrees(phase.rotation))
} animation: { phase in
switch phase {
case .initial: .spring(duration: 0.4)
case .moveUp: .easeOut(duration: 0.3)
case .scaleUp: .spring(duration: 0.5, bounce: 0.4)
case .rotate: .easeInOut(duration: 0.6)
}
}
Button("Стартирай анимацията") {
trigger.toggle()
}
.buttonStyle(.borderedProminent)
}
}
}
Как работи на практика:
- Дефинирате фазите чрез
CaseIterableenum - Всяка фаза определя стойностите на свойствата за анимиране
- SwiftUI автоматично преминава от фаза на фаза
- Можете да зададете различна анимация за всеки преход
- Без
triggerанимацията се повтаря безкрайно; сtrigger— стартира при промяна
PhaseAnimator като модификатор
Освен като самостоятелен изглед, PhaseAnimator може да се ползва и като модификатор чрез .phaseAnimator(). Малко по-ограничен е (не можете да променяте параметрите при извикване), но за по-прости случаи върши чудесна работа.
KeyframeAnimator: Анимации с времева линия
Ако PhaseAnimator работи с дискретни стъпки, KeyframeAnimator ви дава прецизен контрол над времевата линия — нещо като keyframe анимациите в After Effects или CSS. Можете да зададете точни моменти за всяко свойство поотделно:
struct AnimationValues {
var yOffset: Double = 0
var scale: Double = 1.0
var rotation: Double = 0
var opacity: Double = 1.0
}
struct KeyframeAnimatorDemo: View {
@State private var trigger = false
var body: some View {
VStack(spacing: 40) {
KeyframeAnimator(
initialValue: AnimationValues(),
trigger: trigger
) { values in
Image(systemName: "heart.fill")
.font(.system(size: 60))
.foregroundStyle(.red)
.offset(y: values.yOffset)
.scaleEffect(values.scale)
.rotationEffect(.degrees(values.rotation))
.opacity(values.opacity)
} keyframes: { _ in
KeyframeTrack(\.yOffset) {
SpringKeyframe(-80, duration: 0.4, spring: .bouncy)
CubicKeyframe(-40, duration: 0.2)
SpringKeyframe(0, duration: 0.3, spring: .bouncy)
}
KeyframeTrack(\.scale) {
LinearKeyframe(1.0, duration: 0.2)
SpringKeyframe(1.8, duration: 0.3, spring: .bouncy)
SpringKeyframe(1.0, duration: 0.4)
}
KeyframeTrack(\.rotation) {
LinearKeyframe(0, duration: 0.3)
CubicKeyframe(15, duration: 0.15)
CubicKeyframe(-15, duration: 0.15)
CubicKeyframe(0, duration: 0.2)
}
KeyframeTrack(\.opacity) {
LinearKeyframe(1.0, duration: 0.6)
CubicKeyframe(0.5, duration: 0.2)
LinearKeyframe(1.0, duration: 0.1)
}
}
Button("Стартирай") {
trigger.toggle()
}
.buttonStyle(.borderedProminent)
}
}
}
Ето основните концепции:
- KeyframeTrack — контролира едно свойство (идентифицирано чрез key path)
- Типове ключови кадри:
LinearKeyframe(линейна интерполация),SpringKeyframe(пружина),CubicKeyframe(крива на Безие),MoveKeyframe(незабавен скок) - Всеки track работи независимо — различните свойства могат да имат различни продължителности
- Общата продължителност е равна на най-дългия track
PhaseAnimator срещу KeyframeAnimator — кой да изберете?
Кратко и ясно:
- PhaseAnimator: За стъпкови анимации, при които всички свойства се променят едновременно при преход между фази. По-лесен за настройка, по-малко код.
- KeyframeAnimator: За прецизни времеви анимации, при които различните свойства се движат в различни моменти. По-мощен, но и по-сложен за конфигуриране.
Анимиране на текст с TextRenderer (iOS 18+)
С iOS 18 Apple добави протокола TextRenderer, който дава пълен контрол над рендирането на текст. Това означава, че можете да анимирате отделни букви, думи или цели редове:
struct WaveTextRenderer: TextRenderer, Animatable {
var elapsedTime: Double
var animatableData: Double {
get { elapsedTime }
set { elapsedTime = newValue }
}
func draw(layout: Text.Layout, in context: inout GraphicsContext) {
for (lineIndex, line) in layout.enumerated() {
for (runIndex, run) in line.enumerated() {
var copy = context
let yOffset = sin(elapsedTime * 3 + Double(runIndex) * 0.5) * 5
copy.translateBy(x: 0, y: yOffset)
copy.draw(run)
}
}
}
}
struct TextAnimationDemo: View {
@State private var elapsedTime: Double = 0
var body: some View {
Text("Здравей, SwiftUI!")
.font(.largeTitle.bold())
.textRenderer(WaveTextRenderer(elapsedTime: elapsedTime))
.onAppear {
withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) {
elapsedTime = .pi * 2
}
}
}
}
Йерархията на Text.Layout е Lines → Runs → Glyphs, което ви дава контрол чак до ниво символ. Чрез TextAttribute протокола можете да маркирате конкретни части от текста и да прилагате ефекти само към тях — доста мощна възможност.
Управление на анимации с транзакции
За по-фин контрол SwiftUI предоставя системата от транзакции. Звучи сложно, но на практика ги ползвате за неща като „искам да променя стойността, но без анимация":
// Деактивиране на анимация за конкретна промяна
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
position = newPosition
}
// Използване на .transaction модификатор за деактивиране
// на анимации за конкретен изглед
Text("Без анимация")
.transaction { transaction in
transaction.animation = nil
}
// Ограничен обхват на анимация (iOS 17+)
Text("Само този текст ще се анимира")
.animation(.spring) {
$0.opacity(isVisible ? 1.0 : 0.0)
}
Синтаксисът .animation(_:body:) от iOS 17 е страхотно допълнение — позволява прецизно ограничаване на обхвата, без анимацията да „изтича" към дъщерните изгледи.
Най-добри практики за производителност
Анимациите могат да натоварят устройството сериозно, особено на по-стари модели. Ето нещата, които научих по трудния начин:
- Винаги задавайте
value:на.animation()— без него SwiftUI може да задейства анимации при напълно несвързани промени - Внимавайте с
.shadow,.blurи.mask— те натоварват GPU-то значително. Комбинирайте ги в един.overlayкъдето е възможно - Разбивайте на малки подизгледи — по-малки компоненти означават по-малко пренарисувания при промяна на състоянието
- Ползвайте стабилни идентификатори — уникален и стабилен
idза всеки елемент вForEachиListе задължителен - Деактивирайте анимации за чести обновявания — таймери, данни в реално време и подобни неща, които се обновяват многократно в секунда
- Профилирайте с Instruments — Core Animation инструментът и
_printChanges()ще ви покажат точно къде губите кадри
Практически пример: Анимиран бутон за добавяне в кошницата
Нека съберем всичко заедно в един реалистичен пример. Ето бутон, който дава визуална обратна връзка при добавяне на продукт — нещо, което би стояло чудесно в реално приложение:
struct AddToCartButton: View {
@State private var isAdded = false
@State private var animationTrigger = false
var body: some View {
Button {
guard !isAdded else { return }
withAnimation(.spring(duration: 0.4, bounce: 0.3)) {
isAdded = true
}
animationTrigger.toggle()
// Нулиране след 2 секунди
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation(.spring(duration: 0.3)) {
isAdded = false
}
}
} label: {
HStack(spacing: 8) {
Image(systemName: isAdded ? "checkmark" : "cart.badge.plus")
.contentTransition(.symbolEffect(.replace))
Text(isAdded ? "Добавено!" : "Добави в кошницата")
.fontWeight(.semibold)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(isAdded ? .green : .blue)
.foregroundStyle(.white)
.clipShape(Capsule())
}
.phaseAnimator([false, true], trigger: animationTrigger) { content, phase in
content
.scaleEffect(phase ? 1.1 : 1.0)
} animation: { phase in
phase ? .spring(duration: 0.2, bounce: 0.6) : .spring(duration: 0.2)
}
}
}
Този бутон комбинира няколко техники наведнъж: withAnimation за промяна на състоянието, .contentTransition(.symbolEffect) за плавна смяна на иконата, PhaseAnimator за кратък „отскачащ" ефект и цветови преход от синьо към зелено. Крайният резултат изглежда професионално и отнема само няколко реда код.
Често задавани въпроси
Каква е разликата между имплицитни и експлицитни анимации в SwiftUI?
Имплицитните анимации се декларират с .animation(_:value:) и се задействат автоматично при промяна на наблюдаваната стойност. Експлицитните обвивате в withAnimation блок и те анимират всичко вътре. На практика — имплицитните са за „този елемент винаги се анимира така", а експлицитните за „анимирай точно тази промяна сега".
Как да създам безкрайна пулсираща анимация?
Използвайте .repeatForever(autoreverses: true) върху анимацията и стартирайте промяната в .onAppear. Например: .animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: isPulsing). Само не забравяйте да зададете isPulsing = true в .onAppear.
PhaseAnimator или KeyframeAnimator за сложни анимации?
PhaseAnimator е за анимации с ясни стъпки, при които всички свойства се променят заедно. KeyframeAnimator — за прецизни времеви анимации с независим контрол на всяко свойство. Започнете с PhaseAnimator и преминете към KeyframeAnimator само ако имате нужда от по-фин контрол.
Как работи matchedGeometryEffect?
matchedGeometryEffect създава плавен преход между два изгледа, които споделят еднакъв идентификатор и @Namespace. SwiftUI интерполира позицията и размера автоматично. Ползвайте го за hero анимации (от списък към детайлен изглед), а за навигация в NavigationStack — matchedTransitionSource.
Как да подобря производителността на анимациите?
Задавайте value: на .animation(), избягвайте тежки GPU ефекти (.blur, .shadow) върху големи изгледи, разделяйте UI-я на малки подизгледи, ползвайте стабилни идентификатори в ForEach и профилирайте с Instruments. Ако нещо изглежда бавно — _printChanges() е първият ви приятел при дебъгване.