SwiftUI анимации: Ръководство за PhaseAnimator, KeyframeAnimator и още

Пълно ръководство за SwiftUI анимации — имплицитни, експлицитни, пружинни, PhaseAnimator, KeyframeAnimator, hero анимации и TextRenderer с работещи примери.

Въведение: Защо анимациите са толкова важни в 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)
        }
    }
}

Как работи на практика:

  1. Дефинирате фазите чрез CaseIterable enum
  2. Всяка фаза определя стойностите на свойствата за анимиране
  3. SwiftUI автоматично преминава от фаза на фаза
  4. Можете да зададете различна анимация за всеки преход
  5. Без 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 анимации (от списък към детайлен изглед), а за навигация в NavigationStackmatchedTransitionSource.

Как да подобря производителността на анимациите?

Задавайте value: на .animation(), избягвайте тежки GPU ефекти (.blur, .shadow) върху големи изгледи, разделяйте UI-я на малки подизгледи, ползвайте стабилни идентификатори в ForEach и профилирайте с Instruments. Ако нещо изглежда бавно — _printChanges() е първият ви приятел при дебъгване.

За Автора Editorial Team

Our team of expert writers and editors.