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

Разберём всё по порядку: от самых базовых вещей до инструментов, которые используют в серьёзных приложениях.

Как SwiftUI думает об анимациях

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

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

Неявные анимации: модификатор .animation

Самый простой способ — повесить .animation прямо на вью:

struct SimpleButton: View {
    @State private var isPressed = false

    var body: some View {
        Circle()
            .fill(isPressed ? Color.blue : Color.gray)
            .scaleEffect(isPressed ? 0.9 : 1.0)
            .animation(.easeInOut(duration: 0.2), value: isPressed)
            .onTapGesture {
                isPressed.toggle()
            }
    }
}

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

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

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

Когда нужно анимировать изменение состояния из кода (например, в обработчике кнопки), используют withAnimation:

Button("Показать детали") {
    withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
        showDetails.toggle()
    }
}

Всё изменение состояния внутри блока будет анимировано. Если тебе нужно изменить несколько переменных, но анимировать только одну — меняй её внутри withAnimation, а остальные снаружи.

withAnimation(.easeOut(duration: 0.3)) {
    cardOffset = 0  // анимируется
}
isLoaded = true  // не анимируется

Встроенные кривые и пружины

SwiftUI предлагает несколько стандартных кривых:

  • .linear — равномерная скорость, выглядит механически
  • .easeIn — разгон в начале, резкое завершение
  • .easeOut — плавное торможение в конце, самый природный вариант
  • .easeInOut — плавно начинается и заканчивается
  • .spring — пружинная физика, самое живое ощущение

Пружины заслуживают отдельного внимания. У них два ключевых параметра:

.spring(response: 0.5, dampingFraction: 0.6)

response — насколько быстро пружина реагирует (меньше = быстрее). dampingFraction — насколько пружина «затухает»: 1.0 — без колебаний, 0.5 — заметные отскоки, ниже 0.3 — слишком много вибрации для UI.

Для большинства интерактивных элементов хорошо работает response: 0.35–0.5, dampingFraction: 0.7–0.8. Это даёт живое, но не навязчивое ощущение.

С iOS 17 появились ещё более удобные варианты:

.bouncy          // пружина с небольшим отскоком
.smooth          // без колебаний
.snappy          // быстрая реакция, минимальные колебания

Переходы между вью: Transition

Когда вью появляется или исчезает из иерархии (через if или @ViewBuilder), SwiftUI использует переходы:

if showBanner {
    BannerView()
        .transition(.move(edge: .top).combined(with: .opacity))
}

Стандартные переходы:

  • .opacity — fade in/out
  • .scale — масштаб
  • .move(edge:) — слайд с нужной стороны
  • .slide — горизонтальный слайд
  • .asymmetric(insertion:removal:) — разные переходы для появления и исчезновения

Комбинировать их можно через .combined(with:). Это работает именно так, как и звучит — два перехода происходят одновременно.

Важный момент: переходы работают только внутри withAnimation или при использовании .animation на родительском контейнере. Иначе вью просто мгновенно появится или исчезнет.

Анимация отдельных свойств: animatableData

Если нужно анимировать кастомный Shape или нестандартное свойство, пригодится протокол Animatable. Вот пример анимированного прогресс-индикатора:

struct ArcShape: Shape {
    var progress: Double

    var animatableData: Double {
        get { progress }
        set { progress = newValue }
    }

    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.addArc(
            center: CGPoint(x: rect.midX, y: rect.midY),
            radius: rect.width / 2,
            startAngle: .degrees(-90),
            endAngle: .degrees(-90 + progress * 360),
            clockwise: false
        )
        return path
    }
}

Теперь при изменении progress фреймворк сам интерполирует промежуточные значения и перерисовывает фигуру на каждом кадре.

matchedGeometryEffect — герой анимаций переходов

Это один из самых впечатляющих инструментов в SwiftUI. Позволяет плавно «перетащить» вью из одного места в другое — даже если в реальности это два разных вью в разных частях иерархии.

Классический пример — список и детальный экран:

@Namespace private var heroAnimation

// В списке:
ForEach(items) { item in
    Image(item.imageName)
        .matchedGeometryEffect(id: item.id, in: heroAnimation)
        .onTapGesture {
            withAnimation(.spring()) {
                selectedItem = item
            }
        }
}

// В детальном экране:
if let selected = selectedItem {
    Image(selected.imageName)
        .matchedGeometryEffect(id: selected.id, in: heroAnimation)
}

Фреймворк сам вычислит позиции и размеры обоих вью и создаст плавный переход. Это работает через @Namespace — пространство имён, которое связывает вью между собой.

Пару важных деталей:

  • В любой момент времени только один вью с данным id должен быть активным в иерархии. Используй if/else или isSource параметр.
  • Переход работает в обоих направлениях автоматически.
  • Лучше всего работает, когда два вью визуально похожи — иначе переход выглядит странно.

Keyframe анимации в iOS 17+

До iOS 17 воспроизвести сложную последовательность движений было неудобно. Приходилось цеплять несколько withAnimation с задержками или использовать таймеры. Теперь есть keyframeAnimator:

struct AnimatedCard: View {
    @State private var animate = false

    var body: some View {
        RoundedRectangle(cornerRadius: 16)
            .fill(.blue)
            .frame(width: 200, height: 120)
            .keyframeAnimator(initialValue: CardAnimation()) { view, value in
                view
                    .scaleEffect(value.scale)
                    .offset(y: value.offsetY)
                    .rotationEffect(.degrees(value.rotation))
            } keyframes: { _ in
                KeyframeTrack(\.scale) {
                    LinearKeyframe(1.0, duration: 0.1)
                    SpringKeyframe(1.1, duration: 0.2)
                    SpringKeyframe(1.0, duration: 0.3)
                }
                KeyframeTrack(\.offsetY) {
                    LinearKeyframe(0, duration: 0.1)
                    SpringKeyframe(-20, duration: 0.3)
                    SpringKeyframe(0, duration: 0.2)
                }
            }
    }
}

struct CardAnimation {
    var scale: Double = 1.0
    var offsetY: Double = 0
    var rotation: Double = 0
}

Каждый KeyframeTrack управляет своим свойством независимо, что даёт полный контроль над хореографией. Можно смешивать LinearKeyframe, SpringKeyframe, CubicKeyframe и CurveKeyframe в одной дорожке.

PhaseAnimator — цикличные анимации

Ещё один инструмент из iOS 17. Полезен, когда нужно зациклить несколько фаз:

PhaseAnimator([false, true]) { phase in
    Circle()
        .fill(phase ? Color.green : Color.red)
        .scaleEffect(phase ? 1.2 : 1.0)
} animation: { phase in
    phase ? .spring(duration: 0.4) : .easeOut(duration: 0.3)
}

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

Типичные проблемы и как их решать

Анимация не запускается. Чаще всего причина — анимируемое свойство не является Animatable. Проверь, что ты не пытаешься анимировать, например, строку или перечисление напрямую.

Анимация срабатывает при первом рендере. Добавь .animation(.none, value: someProperty) для свойств, которые не должны анимироваться при инициализации. Или используй флаг onAppear + Task { try? await Task.sleep(...) }.

Переходы не работают без withAnimation. Это частая ошибка. Если if showView { SomeView() } переключается без withAnimation, переход будет мгновенным.

matchedGeometryEffect зависает или мерцает. Убедись, что в иерархии одновременно не присутствуют два вью с одинаковым id в одном namespace.

Производительность. Анимации drawingGroup() переносит рендеринг в Metal — помогает, если в иерархии много слоёв. Избегай анимации свойств, которые вызывают перерасчёт лейаута (frame, padding с изменяющимися значениями) — лучше использовать offset и scaleEffect.

Когда остановиться

Анимации — инструмент, а не украшение. Несколько правил:

  • Системные компоненты (навигация, шторка, алерты) не трогай — пользователь уже знает, как они работают
  • Анимация должна что-то объяснять: куда делся элемент, что изменилось, что загружается
  • Длительность для большинства UI-переходов: 0.2–0.4 секунды. Дольше — кажется медленным
  • Уважай accessibilityReduceMotion — часть пользователей отключает анимации по медицинским причинам:
@Environment(\.accessibilityReduceMotion) var reduceMotion

var animation: Animation {
    reduceMotion ? .none : .spring(response: 0.4)
}

Это две строки кода, которые делают приложение доступным для людей с вестибулярными нарушениями.

Что дальше

SwiftUI-анимации сейчас — это полноценная система для создания живых интерфейсов без UIKit. keyframeAnimator и PhaseAnimator появились относительно недавно и закрыли большинство сценариев, для которых раньше нужны были сторонние библиотеки.

Если работаешь над мобильным приложением и хочешь, чтобы интерфейс не просто работал, а ощущался — анимации это не опция, а базовое ожидание пользователей iOS.

А если нужен не только мобильный, но и веб-интерфейс рядом с приложением — у REEXY (r3xy.ru) есть опыт создания связки «сайт + Telegram-бот + бэкенд» для продуктов, которые работают на всех платформах сразу.