Анимации в SwiftUI: от spring-пружин до hero-переходов

Разбираемся с анимациями SwiftUI: неявные и явные анимации, spring-пружины, PhaseAnimator, KeyframeAnimator, matchedGeometryEffect и hero-переходы NavigationTransition в iOS 18+. Рабочие примеры кода и советы по оптимизации.

Анимации SwiftUI 2026: spring и PhaseAnimator

Зачем нужны анимации в SwiftUI

Анимации — это не просто украшение интерфейса. Они выполняют важную коммуникативную функцию: сообщают пользователю, что его действие принято, показывают связь между элементами и направляют внимание. Если вы когда-нибудь пользовались приложениями Apple, то наверняка замечали, как пружинные переходы создают ощущение живого отклика — от запуска приложений до переключения экранов.

SwiftUI даёт нам декларативную систему анимаций, глубоко интегрированную во фреймворк. Давайте разберёмся со всем арсеналом — от простых неявных анимаций до продвинутых PhaseAnimator, KeyframeAnimator и hero-переходов с NavigationTransition.

Неявные и явные анимации

SwiftUI предлагает два фундаментально разных подхода к анимации. Понимание разницы между ними — первый шаг к тому, чтобы анимации работали предсказуемо, а не «как-то так».

Неявные анимации: .animation(_:value:)

Неявная анимация автоматически отслеживает изменение указанного значения и анимирует все зависимые свойства представления:

struct PulseView: View {
    @State private var isExpanded = false

    var body: some View {
        Circle()
            .fill(.blue)
            .frame(width: isExpanded ? 150 : 100,
                   height: isExpanded ? 150 : 100)
            .animation(.easeInOut(duration: 0.3), value: isExpanded)
            .onTapGesture {
                isExpanded.toggle()
            }
    }
}

Важный момент: всегда передавайте параметр value:. Форма .animation() без него давно устарела — она анимировала вообще всё подряд, включая те изменения, которые вы анимировать не собирались.

Явные анимации: withAnimation

Явные анимации дают точный контроль над тем, что именно будет анимировано:

struct ExplicitAnimationView: View {
    @State private var offset: CGFloat = 0
    @State private var color: Color = .blue

    var body: some View {
        Circle()
            .fill(color)
            .offset(y: offset)
            .onTapGesture {
                withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) {
                    offset = offset == 0 ? -100 : 0
                }
                // Цвет изменится без анимации
                color = color == .blue ? .red : .blue
            }
    }
}

Здесь только offset будет анимирован — изменение color происходит вне блока withAnimation и применяется мгновенно. В этом и есть суть: вы сами решаете, что анимировать, а что нет.

Когда что использовать

Неявные анимации подходят, когда представление должно всегда анимироваться при изменении определённого свойства. Явные — когда анимация привязана к конкретному событию (нажатие, жест) и вы хотите контролировать, какие именно свойства затронуты.

На практике я чаще использую withAnimation — с ним проще предсказать поведение, особенно в сложных иерархиях.

Spring-анимации: физика движения

Spring-анимации моделируют систему масса-пружина-демпфер, создавая ощущение реального физического движения. По сути, это стандарт по умолчанию во всей экосистеме Apple — и не зря.

Параметры пружины

Ключевые параметры .spring(response:dampingFraction:):

  • response — скорость анимации. 0.3 — быстрая и резкая, 0.8 — медленная и плавная.
  • dampingFraction — контроль затухания. Значение 1.0 — без отскока (критическое затухание), 0.5 — заметный отскок, а 0.0 — бесконечные колебания (не делайте так в продакшене).
// Быстрая пружина с лёгким отскоком
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
    scale = 1.2
}

// Медленная пружина с сильным отскоком
withAnimation(.spring(response: 0.6, dampingFraction: 0.4)) {
    rotation = 360
}

Пресеты: .bouncy, .snappy, .smooth

Для типичных сценариев SwiftUI предоставляет удобные пресеты:

  • .smooth — плавная анимация без отскока, подходит для большинства переходов.
  • .snappy — быстрая и чёткая, отлично работает для отклика на пользовательский ввод.
  • .bouncy — энергичная с отскоком, привлекает внимание к действию.
// Пресеты можно настроить
withAnimation(.bouncy(duration: 0.5, extraBounce: 0.2)) {
    showDetails.toggle()
}

Честно говоря, в большинстве случаев хватает .snappy или .smooth — и не нужно тратить время на подбор числовых параметров.

Интерактивная пружина для жестов

Для анимаций, управляемых жестами, есть .interactiveSpring(). Она оптимизирована для ситуаций, когда цель анимации может измениться на полпути — например, при перетаскивании:

struct DraggableCard: View {
    @State private var offset = CGSize.zero

    var body: some View {
        RoundedRectangle(cornerRadius: 20)
            .fill(.blue.gradient)
            .frame(width: 200, height: 300)
            .offset(offset)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        offset = value.translation
                    }
                    .onEnded { _ in
                        withAnimation(.interactiveSpring(
                            response: 0.3,
                            dampingFraction: 0.7
                        )) {
                            offset = .zero
                        }
                    }
            )
    }
}

Встроенные переходы: .transition()

Переходы анимируют появление и исчезновение представлений. Важный нюанс: они работают только с представлениями внутри условных конструкций (if, switch).

struct TransitionDemo: View {
    @State private var showCard = false

    var body: some View {
        VStack {
            Button("Показать") {
                withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
                    showCard.toggle()
                }
            }

            if showCard {
                RoundedRectangle(cornerRadius: 16)
                    .fill(.purple.gradient)
                    .frame(height: 200)
                    .transition(.move(edge: .bottom)
                        .combined(with: .opacity))
            }
        }
    }
}

Основные типы переходов

  • .opacity — плавное появление и исчезновение.
  • .scale — масштабирование от нуля или к нулю.
  • .move(edge:) — сдвиг с указанной стороны.
  • .slide — сдвиг слева направо.
  • .push(from:) — вытеснение старого вида новым (iOS 16+).

Асимметричные переходы

Если хотите, чтобы появление и исчезновение выглядели по-разному, используйте .asymmetric(insertion:removal:):

.transition(.asymmetric(
    insertion: .scale.combined(with: .opacity),
    removal: .move(edge: .trailing).combined(with: .opacity)
))

Пользовательские переходы через ViewModifier

Встроенных переходов не хватает? Можно создать свой — через протокол ViewModifier и статический метод .modifier:

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)
    }
}

extension AnyTransition {
    static var rotateAndFade: AnyTransition {
        .modifier(
            active: RotateAndFadeModifier(isActive: true),
            identity: RotateAndFadeModifier(isActive: false)
        )
    }
}

// Использование
if showElement {
    CardView()
        .transition(.rotateAndFade)
}

Модификатор определяет два состояния: active (начальное или конечное для перехода) и identity (нормальное состояние). SwiftUI интерполирует между ними автоматически — вам остаётся только описать крайние точки.

matchedGeometryEffect: hero-анимации

matchedGeometryEffect — пожалуй, один из самых крутых инструментов в арсенале SwiftUI. Он позволяет создавать плавные переходы между представлениями, которые находятся в разных частях иерархии, синхронизируя их позицию, размер и геометрию по общему идентификатору.

Базовый пример

struct HeroAnimationView: View {
    @Namespace private var animation
    @State private var isExpanded = false

    var body: some View {
        VStack {
            if isExpanded {
                // Развёрнутый вид
                RoundedRectangle(cornerRadius: 20)
                    .fill(.orange.gradient)
                    .matchedGeometryEffect(id: "card", in: animation)
                    .frame(height: 400)
                    .onTapGesture {
                        withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
                            isExpanded = false
                        }
                    }
            } else {
                // Компактный вид
                RoundedRectangle(cornerRadius: 12)
                    .fill(.orange.gradient)
                    .matchedGeometryEffect(id: "card", in: animation)
                    .frame(width: 100, height: 100)
                    .onTapGesture {
                        withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
                            isExpanded = true
                        }
                    }
            }
        }
    }
}

Ключевые правила

  • Создавайте пространство имён через @Namespace.
  • Каждый идентификатор должен быть уникальным внутри пространства имён.
  • Представления с одним id не должны отображаться одновременно — если это неизбежно, используйте параметр isSource.
  • Оборачивайте переключение состояния в withAnimation, иначе перехода просто не увидите.

Использование isSource для одновременных представлений

Бывает, что оба представления видны одновременно — например, анимированный индикатор в сегментированном контроле. В таком случае пригодится параметр isSource:

struct SegmentedControl: View {
    @Namespace private var ns
    @State private var selected = 0
    let items = ["Все", "Активные", "Завершённые"]

    var body: some View {
        HStack(spacing: 0) {
            ForEach(items.indices, id: \.self) { index in
                Text(items[index])
                    .padding(.vertical, 8)
                    .padding(.horizontal, 16)
                    .background {
                        if selected == index {
                            Capsule()
                                .fill(.blue)
                                .matchedGeometryEffect(id: "tab", in: ns)
                        }
                    }
                    .foregroundStyle(selected == index ? .white : .primary)
                    .onTapGesture {
                        withAnimation(.snappy) {
                            selected = index
                        }
                    }
            }
        }
        .background(Capsule().fill(.gray.opacity(0.2)))
    }
}

Этот паттерн отлично подходит для tab bar, сегментированных контролов и любых переключателей с плавающим индикатором выбора.

PhaseAnimator: многоступенчатые анимации

PhaseAnimator появился в iOS 17 и позволяет создавать анимации, которые последовательно проходят через набор дискретных состояний. Переходы между фазами SwiftUI анимирует автоматически.

Непрерывная анимация

struct PulsingDot: View {
    var body: some View {
        Circle()
            .fill(.green)
            .frame(width: 40, height: 40)
            .phaseAnimator([false, true]) { content, phase in
                content
                    .scaleEffect(phase ? 1.3 : 1.0)
                    .opacity(phase ? 0.6 : 1.0)
                    .shadow(color: .green.opacity(phase ? 0.5 : 0),
                            radius: phase ? 15 : 0)
            } animation: { phase in
                phase
                    ? .easeInOut(duration: 0.8)
                    : .easeInOut(duration: 0.8)
            }
    }
}

Без параметра trigger анимация зацикливается бесконечно. Идеальный вариант для индикаторов загрузки и пульсирующих элементов.

Анимация по триггеру

Чтобы запустить анимацию по событию, передайте параметр trigger:

enum BouncePhase: CaseIterable {
    case initial, compress, stretch, settle

    var scale: CGSize {
        switch self {
        case .initial: CGSize(width: 1.0, height: 1.0)
        case .compress: CGSize(width: 1.2, height: 0.8)
        case .stretch: CGSize(width: 0.9, height: 1.15)
        case .settle: CGSize(width: 1.0, height: 1.0)
        }
    }

    var verticalOffset: Double {
        switch self {
        case .initial: 0
        case .compress: 10
        case .stretch: -30
        case .settle: 0
        }
    }
}

struct BounceButton: View {
    @State private var trigger = 0

    var body: some View {
        Image(systemName: "bell.fill")
            .font(.system(size: 50))
            .foregroundStyle(.yellow)
            .phaseAnimator(
                BouncePhase.allCases,
                trigger: trigger
            ) { content, phase in
                content
                    .scaleEffect(
                        x: phase.scale.width,
                        y: phase.scale.height
                    )
                    .offset(y: phase.verticalOffset)
            } animation: { phase in
                switch phase {
                case .initial: .spring(duration: 0.2)
                case .compress: .spring(duration: 0.15)
                case .stretch: .spring(duration: 0.2, bounce: 0.4)
                case .settle: .spring(duration: 0.3)
                }
            }
            .onTapGesture { trigger += 1 }
    }
}

Обратите внимание: каждая фаза может иметь свою кривую анимации. Это даёт полный контроль над динамикой — сжатие может быть резким, а возврат — мягким и пружинистым.

KeyframeAnimator: независимая анимация свойств

Если PhaseAnimator анимирует все свойства синхронно при переходе между фазами, то KeyframeAnimator — это другой уровень. Он позволяет каждому свойству жить по своей собственной временной шкале через отдельные треки.

Определение анимируемых значений

struct NotificationValues {
    var scale: Double = 1.0
    var verticalOffset: Double = 0.0
    var rotation: Double = 0.0
    var opacity: Double = 1.0
}

struct NotificationBadge: View {
    @State private var trigger = 0

    var body: some View {
        Image(systemName: "envelope.badge.fill")
            .font(.system(size: 60))
            .foregroundStyle(.blue)
            .keyframeAnimator(
                initialValue: NotificationValues(),
                trigger: trigger
            ) { content, value in
                content
                    .scaleEffect(value.scale)
                    .offset(y: value.verticalOffset)
                    .rotationEffect(.degrees(value.rotation))
                    .opacity(value.opacity)
            } keyframes: { _ in
                KeyframeTrack(\.scale) {
                    SpringKeyframe(1.3, duration: 0.15)
                    SpringKeyframe(0.9, duration: 0.1)
                    SpringKeyframe(1.05, duration: 0.15)
                    SpringKeyframe(1.0, duration: 0.1)
                }

                KeyframeTrack(\.verticalOffset) {
                    LinearKeyframe(-20, duration: 0.15)
                    SpringKeyframe(5, duration: 0.15)
                    SpringKeyframe(0, duration: 0.2)
                }

                KeyframeTrack(\.rotation) {
                    LinearKeyframe(0, duration: 0.1)
                    CubicKeyframe(15, duration: 0.1)
                    CubicKeyframe(-15, duration: 0.1)
                    CubicKeyframe(8, duration: 0.1)
                    CubicKeyframe(0, duration: 0.15)
                }
            }
            .onTapGesture { trigger += 1 }
    }
}

Типы ключевых кадров

  • LinearKeyframe — линейная интерполяция, равномерное движение.
  • SpringKeyframe — пружинная динамика, физически реалистичное движение.
  • CubicKeyframe — кубическая кривая Безье, плавные переходы с контролем ускорения.
  • MoveKeyframe — мгновенный переход к значению, без анимации.

Когда выбирать PhaseAnimator, а когда KeyframeAnimator

Вот простое правило:

  • PhaseAnimator — все свойства синхронно переходят между дискретными состояниями. Проще в использовании, быстрее в написании.
  • KeyframeAnimator — каждому свойству нужна собственная временная шкала. Мощнее, но кода заметно больше.

Начинайте с PhaseAnimator и переходите к ключевым кадрам, только когда синхронной анимации действительно не хватает.

NavigationTransition: hero-переходы в iOS 18+

iOS 18 принёс протокол NavigationTransition, который позволяет создавать hero-анимации при навигации буквально в несколько строк. И это, пожалуй, одна из самых приятных новинок для разработчиков.

Zoom Transition

Самое впечатляющее нововведение — .zoom переход, который создаёт эффект масштабирования при переходе к детальному представлению:

struct PhotoGrid: View {
    let photos = (1...20).map { "photo_\($0)" }

    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: [
                    GridItem(.adaptive(minimum: 100))
                ], spacing: 4) {
                    ForEach(photos, id: \.self) { photo in
                        NavigationLink(value: photo) {
                            Image(photo)
                                .resizable()
                                .aspectRatio(1, contentMode: .fill)
                                .clipped()
                        }
                        .matchedTransitionSource(id: photo, in: namespace)
                    }
                }
            }
            .navigationDestination(for: String.self) { photo in
                Image(photo)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .navigationTransition(.zoom(
                        sourceID: photo,
                        in: namespace
                    ))
            }
        }
    }

    @Namespace private var namespace
}

Важный момент: передавайте стабильный идентификатор (например, модельный объект), а не захватывайте представление напрямую. Это критично для коллекций с переиспользованием ячеек — иначе анимация может «прыгнуть» не к тому элементу.

Совместимость с sheet и fullScreenCover

Zoom transition работает не только с навигацией, но и с модальными представлениями — что приятно:

.fullScreenCover(item: $selectedPhoto) { photo in
    PhotoDetailView(photo: photo)
        .navigationTransition(.zoom(
            sourceID: photo.id,
            in: namespace
        ))
}

Оптимизация производительности анимаций

Когда иерархия представлений разрастается, производительность анимаций может просесть. Вот несколько проверенных приёмов, которые помогут этого избежать.

Предпочитайте трансформации изменениям макета

Изменение frame или padding заставляет SwiftUI пересчитывать макет всей иерархии. Это дорого. Вместо этого используйте scaleEffect и offset — они применяются на уровне рендеринга, не трогая макет:

// ❌ Вызывает перерасчёт макета
.frame(width: isExpanded ? 200 : 100)

// ✅ Только визуальная трансформация
.scaleEffect(isExpanded ? 2.0 : 1.0)

Используйте drawingGroup() для сложных представлений

Модификатор .drawingGroup() растеризует представление в текстуру перед анимацией. Для сложных вложенных иерархий это может дать заметный прирост:

ComplexChartView()
    .drawingGroup()
    .animation(.smooth, value: chartData)

Доступность: уважайте настройки системы

Не забывайте проверять системную настройку «Уменьшение движения». Некоторые пользователи сознательно отключают анимации — и это нужно уважать:

struct AccessibleAnimationView: View {
    @Environment(\.accessibilityReduceMotion) var reduceMotion
    @State private var isVisible = false

    var body: some View {
        ContentView()
            .opacity(isVisible ? 1 : 0)
            .animation(reduceMotion ? nil : .smooth, value: isVisible)
    }
}

Это мелочь, но она показывает внимание к деталям — и к пользователям.

Часто задаваемые вопросы

Чем отличается .animation() от withAnimation() в SwiftUI?

.animation(_:value:) — это неявная анимация, которая автоматически срабатывает при изменении указанного значения и прикрепляется к конкретному представлению. withAnimation — явная анимация, где вы точно контролируете, какие изменения состояния будут анимированы. Неявная — для постоянного отслеживания свойства, явная — для анимации по событию.

Когда использовать PhaseAnimator, а когда KeyframeAnimator?

PhaseAnimator — для последовательного переключения между состояниями, когда все свойства анимируются синхронно. KeyframeAnimator — когда каждому свойству нужна своя временная шкала. Например, масштаб меняется с пружиной, а поворот — линейно и с другой длительностью.

Как создать hero-анимацию между экранами в SwiftUI?

Два основных подхода. Для iOS 18+ — протокол NavigationTransition с .zoom переходом (буквально пара строк кода). Для поддержки более старых версий — matchedGeometryEffect с @Namespace и оборачивание переключения состояния в withAnimation.

Как оптимизировать производительность анимаций в SwiftUI?

Используйте трансформации (scaleEffect, offset, rotationEffect) вместо изменений макета (frame, padding). Применяйте .drawingGroup() для растеризации сложных представлений. И обязательно проверяйте accessibilityReduceMotion — пользователи это ценят.

Работает ли matchedGeometryEffect между представлениями, видимыми одновременно?

Да, но для этого нужен параметр isSource. Одно представление помечается как isSource: true — оно задаёт целевую геометрию, а другое (isSource: false) анимирует свою геометрию к параметрам источника. Классический пример — плавающий индикатор выбора в tab bar или сегментированном контроле.

Об авторе Mei-Lin Chen

Mei-Lin joined Robinhood in 2020 as an iOS engineer on the Crypto team and stayed through the SwiftUI rewrite of the order-entry flow before leaving in 2025. She also did a two-year stint at Asana earlier in her career working on the iPad app and the Mac Catalyst port. She writes about the parts of Apple's frameworks that the WWDC talks gloss over - what Observable actually does to your view-update graph, why @Bindable bindings tear in some animation contexts, and the surprisingly deep rabbit hole of Swift macros for boilerplate elimination. She has shipped two indie apps to the App Store, one of which hit #4 in the Health & Fitness category for a week in 2023. Mei-Lin is based in Seattle and has been writing Swift for 8 years.