Искусство бесшовных переходов в 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) делают две вещи одновременно:

  1. Минимизируют задержку между действием пользователя и началом анимации.

  2. Используют максимально простые трансформации: 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.

  1. Запустите приложение через Product → Profile (Cmd + I).

  2. Выберите Core Animation.

  3. Включите 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:

  1. Perceived performance важнее actual performance. Запустите анимацию мгновенно, даже если данные ещё грузятся. Пользователь не заметит задержки в 100 мс после начала движения, но заметит задержку в 100 мс перед началом.

  2. Меньше - лучше. Одна хорошо продуманная анимация лучше, чем десять посредственных. Если не уверены, нужна ли анимация - скорее всего, не нужна.

  3. Тестируйте на старых устройствах. Если анимация тормозит на iPhone XR - упрощайте. Никакие красивые эффекты не стоят джанка.

И помните: в конечном счёте лучшая анимация - та, которую пользователь не замечает. Потому что она просто работает.