Искусство бесшовных переходов в iOS от 60 FPS до идеального UX
Вы когда-нибудь открывали приложение, где переход между экранами выглядит так, будто интерфейс собрали на коленке за час до дедлайна? Экран мигает, элементы прыгают, анимация тормозит и вы инстинктивно хотите закрыть это безобразие. Проблема не в том, что разработчик не умеет делать анимации. Проблема в том, что он не понимает, как работает восприятие пользователя.
Давайте же разберем как делать переходы в iOS-приложениях так, чтобы они не раздражали, не ломали флоу и не заставляли пользователя думать "что-то пошло не так". Выясним почему 60 FPS - это не всегда гладко, как избежать типичных багов вроде джанка и мигания, правильно использовать matchedGeometryEffect, писать кастомные переходы без костылей и отлаживать всё это дело до идеального состояния. В конце разберём реальный кейс - переход от списка к детальной странице без единого моргания экрана.
Если вы делаете приложения на SwiftUI или UIKit и хотите, чтобы ваши анимации выглядели как у Apple - а не как у стартапа, который нанял первого попавшегося джуна - читайте дальше.
Что на самом деле означает «Плавность» (ощущаемая против фактической производительности)
Когда я спрашиваю коллег, что такое "плавная анимация", большинство отвечает: "60 FPS". И это правда. Но только наполовину.
Дело в том, что человеческий мозг - это не профайлер. Он не считает кадры в секунду. Он оценивает, насколько логично и предсказуемо ведёт себя интерфейс. Вы можете отрисовывать переход на стабильных 60 FPS, но если элемент появляется не там, где ожидалось, или анимация начинается с задержкой в 100 миллисекунд - пользователь почувствует дискомфорт. Он не скажет "о, тут просел фреймрейт". Он скажет "что-то тормозит".
Perceived performance - это то, как быстро приложение показывается пользователю. И вот что интересно: вы можете сделать приложение быстрее, вообще не трогая код. Добавьте skeleton screen вместо спиннера - и переход покажется мгновенным, даже если данные грузятся секунду. Запустите анимацию раскрытия карточки до того, как подгрузится контент - и пользователь не заметит задержки.
С другой стороны, actual performance - это реальная производительность. 60 FPS на iPhone 15 Pro и 60 FPS на iPhone XR - это разные вещи. На старых устройствах анимация может проседать из-за сложных теней, блюра или тяжёлых трансформаций. И тут уже не поможет никакой UX-трюк - нужно оптимизировать рендеринг.
Я заметил, что самые гладкие приложения (Apple Music, Things, Overcast) делают две вещи одновременно:
Минимизируют задержку между действием пользователя и началом анимации.
Используют максимально простые трансформации: scale, translate, opacity. Никаких сложных 3D-поворотов или кастомных шейпов в середине перехода.
Простой тест: запишите видео экрана в slow-mo и посмотрите, когда именно начинается анимация после тапа. Если между тапом и движением больше 2-3 кадров - у вас проблема с latency. Пользователь не увидит этого в реальном времени, но почувствует, что "что-то не так".
Общие ошибки переходов: фризы, мерцание, поехавшая верстка
Давайте честно: большинство багов в анимациях не из-за сложности кода. Они из-за того, что мы забываем про пограничные случаи.
Jank (микрофризы)
Джанк - это когда анимация идёт плавно, потом на мгновение застывает, потом продолжается. Обычно это происходит из-за:
Тяжёлых вычислений в основном потоке во время анимации.
Перестройки layout'а в середине перехода.
Подгрузки изображений, которые не были закешированы.
Я как-то делал экран с галереей, где при переходе к fullscreen-просмотру картинка увеличивалась с плавной анимацией. Всё работало отлично, пока я не потестил на реальном девайсе с медленным интернетом. Оказалось, что анимация начиналась, а высококачественная версия изображения грузилась прямо во время перехода - и анимация тормозила на полсекунды.
Решение было простым: запускать prefetch высококачественного изображения заранее, когда пользователь делает long press на превью. К моменту, когда он отпустит палец, картинка уже в кеше.
// Плохо: грузим во время анимации .onTapGesture { withAnimation { isFullscreen = true } loadHighResImage() // <- это убьёт анимацию} // Хорошо: грузим на long press .onLongPressGesture(minimumDuration: 0.3, pressing: { isPressing in if isPressing { loadHighResImage() // prefetch } }, perform: {})
Flashing (моргание)
Самая раздражающая проблема. Экран мигает белым (или чёрным) между переходами. Обычно это происходит, когда:
Вы используете
.sheet()или.fullScreenCover()с кастомным фоном, но забываете про.background()на самом.sheet().SwiftUI пересоздаёт view hierarchy во время анимации.
У вас разные
colorSchemeна двух экранах, и система переключает тему в середине перехода.
Классический пример:
.sheet(isPresented: $showDetails) { DetailView() // Забыли добавить фон - будет белый блик } // Правильно: .sheet(isPresented: $showDetails) { DetailView() .background(Color(.systemBackground)) }
Ещё один источник миганий - это когда вы меняете id view во время анимации. SwiftUI воспринимает это как удаление старого view и создание нового. Никакого плавного перехода не будет - только резкая замена.
Layout Shifts (прыгающие элементы)
Вы открываете карточку товара, и заголовок сначала появляется вверху, потом прыгает на 20 пикселей вниз. Или кнопка "Купить" сначала рисуется где-то в углу, потом телепортируется в нужное место.
Это происходит, потому что SwiftUI (или UIKit) рассчитывает layout уже после того, как началась анимация. Система не знает финальные размеры элементов и вынуждена пересчитывать их на лету.
Решение: явно задавайте frame или использовать .layoutPriority(), чтобы SwiftUI понимал, какие элементы важнее.
// Плохо: размер вычисляется динамически Text(product.title) .font(.largeTitle) // Хорошо: фиксируем высоту Text(product.title) .font(.largeTitle) .frame(height: 40, alignment: .leading)
Я стараюсь избегать ситуаций, где контент определяет размер контейнера во время анимации. Лучше зафиксировать размеры заранее - даже если это означает чуть больше кода.
Правильное использование matchedGeometryEffect
matchedGeometryEffect - это, наверное, самая крутая фича SwiftUI для переходов. Но и самая переоценённая. Да, она позволяет анимировать один и тот же элемент между разными экранами, и да, выглядит это магически. Но она не решает всех проблем.
Как это работает
Вы помечаете два view одним и тем же id в рамках одного @Namespace, и SwiftUI автоматически интерполирует позицию, размер и форму между ними.
@Namespace private var animation var body: some View { if showDetail { DetailView(item: selectedItem) .matchedGeometryEffect(id: selectedItem.id, in: animation) } else { ListView() .matchedGeometryEffect(id: item.id, in: animation) } }
Звучит просто. На практике - куча подводных камней.
Проблема №1: ID должны быть уникальными
Если у вас список из 100 элементов, и вы используете matchedGeometryEffect на каждом, убедитесь, что id реально уникальны. Я один раз по невнимательности передал в id не item.id, а просто строку "card". Результат: все карточки анимировались одновременно в одну точку. Выглядело как баг в Matrix.
Проблема №2: Namespace должен жить выше по иерархии
Если вы создаёте @Namespace внутри view, который исчезает во время анимации, SwiftUI потеряет связь между элементами.
// Плохо: namespace умирает вместе с ListView struct ListView: View { @Namespace private var animation // <- Не доживёт до конца анимации ... } // Хорошо: namespace живёт на уровне контейнера struct ContentView: View { @Namespace private var animation ... }
Проблема №3: Не всё можно анимировать
matchedGeometryEffect работает только с геометрией: position, size, shape. Он не анимирует содержимое view. Если у вас в карточке текст, и его размер меняется - SwiftUI не будет плавно интерполировать каждую букву. Он просто переключит один текст на другой.
Для таких случаев нужно комбинировать matchedGeometryEffect с обычными .opacity() и .scaleEffect().
Когда НЕ использовать matchedGeometryEffect
Если элементы на двух экранах визуально похожи, но семантически разные - не пытайтесь связывать их через matchedGeometryEffect. Например, если у вас на списке превью фотографии, а на детальной странице - та же фотография, но в другом аспекте (crop), анимация будет выглядеть странно: картинка растянется, потом обрежется, потом вернётся в форму.
Лучше использовать обычный fade + scale:
.transition(.asymmetric( insertion: .scale.combined(with: .opacity), removal: .opacity ))
Я заметил, что лучшие приложения используют matchedGeometryEffect очень дозированно: только для элементов, которые дей��твительно "перемещаются" между экранами. Всё остальное - fade in/out.
Пользовательские переходы с AnimatablePair и GeometryReader
Иногда встроенных переходов SwiftUI недостаточно. Вы хотите, чтобы карточка не просто выезжала снизу, а раскрывалась из точки тапа. Или чтобы элементы списка появлялись по одному с каскадной задержкой.
Для этого есть кастомные Transition и протокол AnimatableModifier.
Простой пример: ScaleAndFade
Допустим, вы хотите, чтобы элемент появлялся с одновременным увеличением и fade-in. Встроенный .scale работает, но не даёт контроля над pivot point (точкой, относительно которой происходит масштабирование).
struct ScaleAndFade: ViewModifier { var progress: Double // 0 = невидимо, 1 = полностью видно func body(content: Content) -> some View { content .scaleEffect(0.8 + progress * 0.2) // От 0.8 до 1.0 .opacity(progress) } } extension AnyTransition { static var scaleAndFade: AnyTransition { .modifier( active: ScaleAndFade(progress: 0), identity: ScaleAndFade(progress: 1) ) } }
Использование:
if showCard { CardView() .transition(.scaleAndFade) }
AnimatablePair: когда нужно анимировать несколько значений
Допустим, вы хотите анимировать и масштаб, и смещение одновременно. AnimatablePair позволяет объединить два Animatable значения в одно.
struct ScaleAndOffset: ViewModifier, Animatable { var scale: CGFloat var offsetY: CGFloat var animatableData: AnimatablePair<CGFloat, CGFloat> { get { AnimatablePair(scale, offsetY) } set { scale = newValue.first offsetY = newValue.second } } func body(content: Content) -> some View { content .scaleEffect(scale) .offset(y: offsetY) } }
Теперь можно плавно анимировать переход от scale: 0.5, offsetY: 100 к scale: 1.0, offsetY: 0.
GeometryReader: адаптивные переходы
Самые интересные анимации - те, что реагируют на контекст. Например, если пользователь тапнул на карточку в верхнем левом углу экрана, она должна раскрываться из этой точки. Если тапнул внизу справа - оттуда.
Для этого нужен GeometryReader:
struct ExpandFromTap: ViewModifier { var tapLocation: CGPoint var progress: Double func body(content: Content) -> some View { GeometryReader { geometry in let centerX = geometry.size.width / 2 let centerY = geometry.size.height / 2 let offsetX = (tapLocation.x - centerX) * (1 - progress) let offsetY = (tapLocation.y - centerY) * (1 - progress) content .scaleEffect(0.1 + progress * 0.9, anchor: .center) .offset(x: offsetX, y: offsetY) .opacity(progress) } } }
Идея в том, что в начале анимации (progress = 0) элемент находится в точке тапа и имеет масштаб 0.1. По мере роста progress он движется к центру и увеличивается до полного размера.
Проблема с GeometryReader - он требует дополнительного рендера и может замедлить анимацию, если вы используете его внутри List или ScrollView. Я стараюсь применять его только там, где он действительно нужен.
Избегайте избыточной анимации
Это тот самый случай, когда "можем" не значит "нужно".
Я видел проекты, где каждый чих сопровождался анимацией. Нажал кнопку - она подпрыгнула. Открыл меню - оно вылетело с тремя разными easing'ами. Переключил таб - иконки закружились. И знаете, через 30 секунд это начинает бесить.
Правило простое: анимация должна объяснять, что произошло. Если она не несёт информации - она лишняя.
Когда анимация обязательна
Смена контекста: переход между экранами, открытие модального окна, раскрытие карточки. Без анимации пользователь не понимает, куда делся предыдущий контент.
Обратная связь: кнопка нажата, переключатель включён, элемент удалён. Анимация подтверждает действие.
Загрузка: skeleton screen, спиннер, прогресс-бар. Показывает, что приложение работает, а не зависло.
Когда анимация избыточна
Микроэффекты: кнопка слегка меняет оттенок при нажатии. Это пользователь даже не заметит. Анимации должны быть заметны, но не назойливы.
Декоративные движения: текст плавно выезжает слева, хотя мог бы просто появиться. Это красиво первые 2 раза, потом раздражает.
Каскадные задержки: 10 элементов появляются по одному с интервалом 0.1 секунды. Итого 1 секунда до полной загрузки экрана. Слишком медленно.
Я придерживаюсь правила: если анимация длится дольше 0.3 секунды - она должна быть интерактивной (т.е. пользователь может её прервать жестом). Если дольше 0.5 секунды - она точно лишняя.
Easing: не всё должно быть spring'ом
SwiftUI по умолчанию использует spring анимацию для всего подряд. Это хорошо для интерактивных элементов (drag-and-drop, pull-to-refresh), но плохо для простых переходов.
// Хорошо для жестов .animation(.spring(response: 0.3, dampingFraction: 0.7), value: offset) // Плохо для fade-in .animation(.spring(), value: isVisible) // Зачем тут пружина? // Лучше: .animation(.easeOut(duration: 0.2), value: isVisible)
Spring красиво смотрится в маркетинговых видео, но в реальном использовании он часто делает анимацию дольше и менее предсказуемой.
Инструменты отладки: замедленные анимации, логи Core Animation
Когда анимация не работает, первое желание - добавить print() и смотреть, что происходит. Но print() не покажет вам просадки FPS, задержки рендера или конфликты слоёв.
«Слоу-мо» для интерфейса
Самый простой способ понять, что идёт не так - замедлить анимацию.
В Simulator: Debug → Slow Animations (или Cmd + T).
Всё будет идти в 10 раз медленнее. Это позволяет увидеть:
Моменты, когда анимация дёргается (jank).
Места, где элементы появляются не оттуда, откуда ожидалось.
Layout shifts, которые на нормальной скорости незаметны.
Я всегда включаю slow animations перед тем, как отдать фичу на QA. Если в замедленном режиме что-то выглядит странно - на реальной скорости это будет ещё хуже.
Профилирование с помощью Core Animation Instrument
Когда вам нужно найти реальную причину просадки FPS - используйте Instruments.
Запустите приложение через Product → Profile (
Cmd + I).Выберите Core Animation.
Включите Debug Options → Color Blended Layers.
Теперь экран раскрасится в разные цвета:
Зелёный: слой непрозрачный, рендерится быстро.
Красный: слой с alpha-blending, рендерится медленно.
Если весь экран красный - у вас проблема. SwiftUI приходится смешивать слои на каждом кадре, и это убивает производительность.
Типичные причины:
.background(Color.white.opacity(0.5))- полупрозрачные фоны..shadow()- тени требуют дополнительного композитинга.Вложенные
ZStackс.opacity().
Решение: если вам не нужна прозрачность - не используйте .opacity(). Если нужна тень - попробуйте использовать готовое изображение с тенью вместо .shadow().
Ловим дробные пиксели с Color Misaligned Layers
Ещё одна полезная опция в Core Animation Instrument. Показывает, где слои отрисовываются не на pixel boundaries.
Когда элемент позиционируется на дробных координатах (например, x: 12.5, y: 34.3), система вынуждена использовать antialiasing. Это не баг, но на старых устройствах это замедляет рендер.
Если видите много жёлтых областей - проверьте, не используете ли вы .offset() или .position() с нецелыми значениями.
Разбор слоев во View Hierarchy Debugger
Когда не понятно, почему элемент не анимируется или анимируется не так, откройте Debug → View Debugging → Capture View Hierarchy.
Вы увидите 3D-развёртку всех слоёв. Иногда оказывается, что ваш элемент вообще скрыт за другим слоем, или у него неправильный zIndex, или он находится в совершенно другом месте иерархии, чем вы думали.
Я один раз потратил час на отладку анимации, которая "не работала". Оказалось, что SwiftUI создавал два экземпляра одного и того же view, и я анимировал не тот.
Навигация между списком и детальной страницей без мерцания
Теперь соберём всё вместе на реальном примере.
Задача: У нас есть список товаров (grid из карточек). При тапе на карточку она раскрывается в fullscreen-детальную страницу. Требования:
Никакого мигания.
Никаких прыгающих элементов.
Анимация должна чувствоваться естественной.
Работать на iPhone SE (2020) без тормозов.
Шаг 1: Структура данных
struct Product: Identifiable { let id: UUID let title: String let price: String let imageName: String }
Шаг 2: Список (Grid)
struct ProductGrid: View { let products: [Product] @Binding var selectedProduct: Product? @Namespace private var animation var body: some View { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 16) { ForEach(products) { product in ProductCard(product: product) .matchedGeometryEffect(id: product.id, in: animation) .onTapGesture { withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { selectedProduct = product } } } } .padding() } .overlay { if let product = selectedProduct { ProductDetail(product: product, namespace: animation) { withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { selectedProduct = nil } } .transition(.opacity) } } } }
Шаг 3: Карточка товара
struct ProductCard: View { let product: Product var body: some View { VStack(alignment: .leading, spacing: 8) { Image(product.imageName) .resizable() .aspectRatio(contentMode: .fill) .frame(height: 200) .clipped() .cornerRadius(12) Text(product.title) .font(.headline) .lineLimit(2) Text(product.price) .font(.subheadline) .foregroundColor(.secondary) } .background(Color(.systemBackground)) } }
Шаг 4: Детальная страница
struct ProductDetail: View { let product: Product let namespace: Namespace.ID let onClose: () -> Void var body: some View { ZStack(alignment: .topTrailing) { ScrollView { VStack(alignment: .leading, spacing: 16) { Image(product.imageName) .resizable() .aspectRatio(contentMode: .fill) .frame(maxWidth: .infinity) .frame(height: 400) .clipped() .matchedGeometryEffect(id: product.id, in: namespace) VStack(alignment: .leading, spacing: 12) { Text(product.title) .font(.largeTitle) .fontWeight(.bold) Text(product.price) .font(.title2) .foregroundColor(.secondary) Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.") .font(.body) .foregroundColor(.primary) } .padding() } } .background(Color(.systemBackground)) .edgesIgnoringSafeArea(.all) Button(action: onClose) { Image(systemName: "xmark.circle.fill") .font(.title) .foregroundColor(.secondary) .padding() } } } }
Проблемы, которые я решил по ходу
Проблема 1: Изображение мигало при переходе.
Причина: SwiftUI перезагружал изображение при создании ProductDetail.
Решение: Использовал кеширование изображений. Добавил prefetch на long press.
Проблема 2: Текст на детальной странице появлялся резко.
Причина: matchedGeometryEffect анимирует только геометрию, не контент.
Решение: Добавил .transition(.opacity) на текстовые блоки.
Text(product.title) .transition(.opacity.animation(.easeIn(duration: 0.3).delay(0.2)))
Проблема 3: На iPhone SE анимация тормозила.
Причина: Слишком сложный shadow на карточках + полупрозрачный фон.
Решение: Убрал .shadow(), добавил тонкую рамку. Заменил полупрозрачный фон на сплошной.
// Было: .background(Color.white.opacity(0.95)) .shadow(radius: 10) // Стало: .background(Color(.systemBackground)) .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.gray.opacity(0.2), lineWidth: 1))
Проблема 4: Scroll position сбрасывался при возврате на список.
Причина: SwiftUI пересоздавал ScrollView.
Решение: Использовал .id() стабильный для ScrollView, чтобы он не пересоздавался.
ScrollView { // ... } .id("product_grid") // Фиксированный ID
Итоговый результат
Анимация занимает 0.4 секунды. Карточка плавно увеличивается от своей позиции в grid до fullscreen. Изображение остаётся на месте (благодаря matchedGeometryEffect), текст появляется с лёгкой задержкой. Никаких миганий, никаких скачков. На iPhone SE работает на стабильных 60 FPS.
Ключевые моменты:
matchedGeometryEffectтолько на изображении, не на всём view.Простые easing'и:
springсdampingFraction: 0.8.Минимум визуальных эффектов: без теней, без блюра, без полупрозрачности.
Prefetch изображений до начала анимации.
Выводы
Плавные переходы - это не про то, чтобы впихнуть анимацию везде, где можно. Это про то, чтобы пользователь не замечал переходов вообще. Интерфейс должен течь, а не прыгать.
Три главных правила, которые я усвоил за годы работы с iOS:
Perceived performance важнее actual performance. Запустите анимацию мгновенно, даже если данные ещё грузятся. Пользователь не заметит задержки в 100 мс после начала движения, но заметит задержку в 100 мс перед началом.
Меньше - лучше. Одна хорошо продуманная анимация лучше, чем десять посредственных. Если не уверены, нужна ли анимация - скорее всего, не нужна.
Тестируйте на старых устройствах. Если анимация тормозит на iPhone XR - упрощайте. Никакие красивые эффекты не стоят джанка.
И помните: в конечном счёте лучшая анимация - та, которую пользователь не замечает. Потому что она просто работает.
