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-бот + бэкенд» для продуктов, которые работают на всех платформах сразу.