Выведет 40. Анимация изменила только presentation layer, а fillMode = .forwards вместе с isRemovedOnCompletion = false замораживает эту картинку поверх модели, которая никуда не двигалась, и пока замороженная анимация висит на слое, она перекрывает любое новое значение модели.
Два источника правды: модель говорит 40, экран показывает 300, а layout и следующая анимация читают модель.
Почему 100 не видно: неаддитивная анимация подменяет значение модели в presentation layer всё время, пока она прикреплена, поэтому новое значение остаётся невидимым, пока кто-нибудь не удалит "slide".
Медленная утечка: объект анимации остаётся на слое навсегда, а с ним и любой делегат, если вы его задали, ведь CAAnimation держит делегата сильной ссылкой.
Исправление: берите fromValue из текущей позиции, выставьте модели конечное значение, затем добавьте анимацию и дайте ей удалиться по завершении. Presentation заканчивается ровно на значении модели, поэтому прыжка нет.
**Где fillMode уместен**: при отложенном beginTime режим .backwards показывает первое значение на время ожидания вместо значения модели.
iOSSenior
Анимации и Core Animation
CALayer, неявные анимации, UIViewPropertyAnimator, переходы, PhaseAnimator, KeyframeAnimator
Закрыто вопросов
0 / 20
- 0Повторить
Вопросы
Оба изменения проходят один и тот же поиск action и получают разные ответы: у отдельного слоя нет делегата, и поиск доходит до анимации по умолчанию на 0,25 с, а слой вью спрашивает свой UIView, который возвращает NSNull (без анимации), если изменение сделано не внутри блока анимации.
Поиск action по порядку
delegate?.action(for:forKey:): для слоя вью это сама вью. Вне UIView.animate она возвращает NSNull, а внутри возвращает настоящую анимацию с длительностью и кривой блока.
Словарь actions у слоя.
Словарь style.
defaultAction(forKey:) у класса слоя: неявная анимация на 0,25 с с ease-in-ease-out.
NSNull на любом шаге завершает поиск без анимации; nil передаёт ход следующему шагу.
Ловушка, которая из этого следует
У подслоя, который вы добавили сами (кольца на CAShapeLayer, фона на CAGradientLayer), делегата нет. Задайте ему frame в layoutSubviews, и он будет анимированно въезжать на место при каждом повороте, появлении клавиатуры или ресайзе в split view, отставая от вью на четверть секунды. Это относится только к слою, который уже был закоммичен: слой, добавленный и изменённый в том же проходе run loop, просто появляется сразу в конечном состоянии.
Заглушите это один раз через actions = ["position": NSNull(), "bounds": NSNull()] или задавайте frame в транзакции с отключёнными actions. Если слой должен двигаться вместе с поворотом, анимируйте его явно внутри coordinator.animate(alongsideTransition:) с длительностью перехода.
Проще всего сделать фигуру собственным слоем вью, переопределив layerClass. Тогда он следует за bounds вью без кода для frame; в layoutSubviews по-прежнему нужно пересобирать только path.
CATransaction представляет собой пачку, в которой изменения слоёв уходят в render server: она задаёт им длительность анимации, тайминг-функцию и completion и коммитит их вместе. Чтобы одно изменение не анимировалось, оберните его в транзакцию с setDisableActions(true).
Неявные транзакции: Core Animation открывает такую транзакцию автоматически и коммитит её в конце текущего оборота run loop. Поэтому десять изменений свойств в одном методе отрисовываются одним обновлением.
Явные транзакции: begin() и commit() вкладываются внутрь неявной, и их настройки действуют на все изменения между этими двумя вызовами на этом потоке.
**setDisableActions(true) против нулевой длительности**: оба варианта не добавляют новую анимацию. Отключение пропускает поиск action и прямо выражает намерение; уже идущая по этому ключу анимация при этом остаётся нетронутой, а нулевая длительность её отменяет. «Без анимации» говорите первым способом, а чтобы отменить анимацию, вызывайте removeAnimation(forKey:).
Completion: setCompletionBlock ставьте до изменений. Он срабатывает один раз, когда все анимации, добавленные в транзакции, закончились или были удалены.
Баланс: ранний return между begin() и commit() оставляет транзакцию открытой; defer { CATransaction.commit() } от этого защищает.
Для вью: UIView.performWithoutAnimation делает то же самое для изменения вью внутри внешнего блока анимации.
С a карточка рисуется на 100 pt правее, с центром в (300, 200); с b рисуется на 100 pt ниже, в (200, 300); в обоих случаях она повёрнута на 90°. print оба раза выводит (200, 200), потому что transform меняет только то, как вью рисуется, а не её center или bounds.
Почему важен порядок
translatedBy, rotated(by:) и scaledBy применяют новый шаг до существующего трансформа, в его локальных осях. В b сдвиг происходит в уже повёрнутой системе координат карточки, где ось x смотрит вниз.
t1.concatenating(t2) читается слева направо: сначала t1, потом t2. Предсказуемее всего записывать шаги через concatenating.
Трансформ применяется вокруг layer.anchorPoint, по умолчанию это центр. Если перенести якорь, чтобы вращать вокруг угла, слой тоже сдвинется, если не поправить position.
Правила, которые из этого следуют
При неединичном трансформе frame не определён (возвращает описывающий прямоугольник); пользуйтесь bounds и center.
Auto Layout игнорирует transform, поэтому его анимация не стоит прохода layout и не сбрасывается следующим, в отличие от анимации frame.
CGAffineTransform двумерный. Для глубины и перспективы нужен CATransform3D в layer.transform (или transform3D у вью), а перспективу даёт m34 = -1 / distance.
Специализированные слои (CAShapeLayer, CAGradientLayer, CATextLayer, CAReplicatorLayer, CAEmitterLayer) описывают содержимое, которое render server рисует сам. draw(_:) же даёт вью битмап, который CPU перерисовывает и заново загружает при каждом изменении.
**CAShapeLayer**: векторный путь с анимируемыми path, strokeStart, strokeEnd, lineWidth и цветами. Кольцо прогресса сводится к strokeEnd от 0 до 1: одна анимация и никаких перерисовок.
**CAGradientLayer**: линейные, радиальные или .conic градиенты; colors и locations анимируются. Чёткий при любом размере, в отличие от картинки с градиентом.
**CATextLayer**: лёгкий текст без UILabel. Выставьте contentsScale в масштаб экрана, иначе он будет мыльным.
**CAReplicatorLayer: рисует N копий своих подслоёв с трансформом и сдвигом по времени для каждой копии: спиннеры, эквалайзеры и круги на воде из одного слоя.
CAEmitterLayer**: частицы, которые симулируются в render server. Ячейки копируются при присваивании, поэтому менять их потом нужно через key path вроде emitterCells.spark.birthRate.
Ещё: AVPlayerLayer для видео, CAMetalLayer для рендерера на Metal.
Чего стоит draw(_:)
Backing store занимает ширина × высота × scale² × 4 байта: квадрат 300 pt на 3x занимает около 3,2 МБ на вью, и каждый setNeedsDisplay() перерисовывает его на главном потоке.
Он всё ещё уместен для разовой растровой работы: отрисуйте один раз в картинку через UIGraphicsImageRenderer и переиспользуйте результат.
CADisplayLink представляет собой таймер, синхронизированный с дисплеем: он вызывает вас раз в кадр, чтобы вы сами посчитали значение для этого кадра. Используйте его, только когда Core Animation не может интерполировать нужное за вас (физика, живая осциллограмма, свой рендерер).
**Почему не Timer: таймер дрейфует относительно обновления экрана и срабатывает между кадрами, поэтому одни кадры получают два обновления, а другие ни одного. Display link срабатывает раз в кадр, в такт с дисплеем.
Используйте targetTimestamp: это момент, когда кадр, который вы готовите, будет показан. Считайте позиции от него, а не фиксированным шагом 1/60, иначе анимация замедлится, когда система снизит частоту.
Частота кадров**: задайте preferredFrameRateRange через CAFrameRateRange(minimum:maximum:preferred:). Это просьба: система балансирует все запросы на экране и может опуститься ниже, чтобы сберечь батарею. Просите 120, только если контенту это действительно нужно.
iPhone с ProMotion: без CADisableMinimumFrameDurationOnPhone = YES в Info.plist display link и анимации на iPhone ограничены 60 Гц. iPad этот ключ не нужен.
Режим run loop: добавляйте link в .common, иначе он останавливается, пока scroll view отслеживает палец.
Retain cycle: link сильно держит свой target. Вызывайте invalidate(), когда вью уходит из окна, иначе ни один из объектов не освободится.
iOS 18+: UIUpdateLink служит альтернативой на уровне UIKit: привязан к вью или window scene и умеет запрашивать обновления с низкой задержкой.
UIView.animate работает по принципу «запустил и забыл»; UIViewPropertyAnimator представляет собой объект, который вы храните, поэтому его можно поставить на паузу, скрабить fractionComplete, развернуть, добавлять анимации и completion на ходу и завершить с новым таймингом, а это то, что нужно интерактивному и прерываемому UI.
Управление из жеста
.began: создайте аниматор с конечным состоянием и вызовите pauseAnimation(). Это запускает его сразу на паузе, и только в этом состоянии установка fractionComplete на что-то влияет.
.changed: переводите смещение в fractionComplete. С scrubsLinearly = true по умолчанию вью идёт за пальцем 1:1, а кривая тайминга возвращается, когда аниматор снова запущен.
.ended: выставьте isReversed, если пользователь повёл обратно, затем вызовите continueAnimation(withTimingParameters:durationFactor:). nil сохраняет исходную кривую, UISpringTimingParameters подставляет пружину, а durationFactor: 0 сохраняет оставшееся время.
На чём спотыкаются
Completion получает UIViewAnimatingPosition: .end или .start после разворота. Ветвитесь по нему, а не предполагайте, что действие произошло.
Замыкание с анимациями выполняется один раз, при старте аниматора, чтобы зафиксировать конечное состояние. Значения внутри него читаются в этот момент.
stopAnimation(true) оставляет вью там, где они на экране, записывает эти значения в модель и пропускает completion; stopAnimation(false) и затем finishAnimation(at:) позволяют выбрать конечное состояние самому.
pausesOnCompletion = true держит завершённый аниматор живым, чтобы его можно было потом развернуть, как переключатель.
Изменение constant само по себе ничего не двигает: оно только помечает layout как требующий обновления. Frame меняются при следующем проходе layout, уже после того как блок анимации вернулся, поэтому панель прыгает на новую высоту.
Почему так происходит: UIView.animate анимирует только изменения свойств вью, сделанные, пока выполняется его блок. Констрейнт к ним не относится; к ним относится frame, который Auto Layout из него вычисляет, а этот frame выставляется позже, в layoutSubviews, вне анимации.
Исправление: измените constant, а затем вызовите layoutIfNeeded() внутри блока, чтобы проход layout и все frame, которые он выставляет, пришлись на открытую анимацию. То же правило действует для isActive и смены приоритета.
Вызывайте на нужной вью: на общей superview, здесь self.view, а не на panel: frame панели выставляет layout её superview, а соседние вью, привязанные под панелью, должны сдвинуться вместе с ней.
Сначала сбросьте очередь: layoutIfNeeded() перед изменением применяет уже ожидающий layout без анимации, чтобы несвязанные изменения, запрошенные раньше, не поехали вместе с панелью.
Как поймать: в тесте panel.layer.animationKeys() сразу после вызова равен nil с багом и содержит position и bounds.size с исправлением.
Верните объект, реализующий UIViewControllerAnimatedTransitioning, из делегата навигации или transitioning delegate. UIKit вызывает его animateTransition(using:): вы добавляете входящую вью в containerView, анимируете и вызываете completeTransition(_:); уходящую вью UIKit убирает сам.
Кто отдаёт аниматор: navigationController(_:animationControllerFor:from:to:) для push и pop, UIViewControllerTransitioningDelegate для презентаций. transitioningDelegate объявлен как weak, поэтому держите на него сильную ссылку, иначе молча вернётся стандартная анимация.
Контейнер: вью, которую UIKit создаёт на время перехода, и from-вью уже лежит внутри. Добавляйте to-вью через addSubview или insertSubview(_:belowSubview:), смотря какая должна оказаться сверху.
Фреймы: frame входящей вью берите из context.finalFrame(for:), а не из bounds контейнера: они различаются под барами, в split view и в кастомных презентациях.
**view(forKey:) вместо viewController.view**: с presentation controller это разные вью, а nil означает, что ею владеет UIKit или presentation controller.
Никогда не удаляйте ни одну из вью перехода сами. UIKit убирает from-вью при успехе и to-вью при отмене; если делать это самому, вы гонитесь с UIKit и получаете чёрный экран.
Сбрасывайте трансформы у вью, которая остаётся: контроллер, с которого сделали push, переиспользуется при возврате.
Сначала проверьте встроенное: с iOS 18 preferredTransition = .zoom { _ in sourceView } даёт плавный прерываемый zoom без собственного аниматора.
Transition срабатывает, только когда вью вставляется в иерархию или удаляется из неё (меняется её identity), и это изменение анимировано. Без анимации вью просто появляется; без смены identity переходить не из чего.
Смена identity: ветка if или switch, добавленный или удалённый элемент ForEach или новое значение у .id(value). Изменение opacity или frame у вью, которая остаётся на месте, считается просто изменением свойства, поэтому её .transition никогда не срабатывает.
Куда ставить анимацию: withAnimation { show = true } или .animation(_:value:) на контейнере, который остаётся в дереве. На самой вставляемой вью уже поздно: её не существовало в момент изменения.
Куда ставить transition: на вью внутри ветки. На внешнем VStack он ничего не делает, потому что стек никуда не уходит.
Появление и удаление: .asymmetric(insertion:removal:) задаёт каждому свой эффект, а .combined(with:) складывает два.
Списки: давайте ForEach стабильные id, а не индексы, иначе при удалении анимируется не та строка. Уходящая вью продолжает рендериться во время удаления, поэтому её body не должен лезть обратно в массив по индексу.
Кастомные переходы: с iOS 17 реализуйте протокол Transition и возвращайте вью для каждой TransitionPhase.
С iOS 17 withAnimation(_:completionCriteria:_:completion:) вызывает замыкание, когда анимация завершена, а цепочку строят, запуская следующий withAnimation внутри него. До этого был только вариант с задержкой, равной длительности, который ломается на пружинах и прерываниях.
**.logicallyComplete (по умолчанию): срабатывает, когда анимация достаточно приблизилась к цели, чтобы двигаться дальше. Для пружины это происходит до того, как затухнет последнее мелкое колебание, поэтому следующий шаг стартует без мёртвой паузы.
.removed: срабатывает, только когда анимация полностью закончилась и удалена. Используйте его перед удалением данных или вью, которые анимация ещё рисует.
Задержка внутри цепочки**: Animation.delay(_:) откладывает следующий шаг без таймера.
Тот же хук у транзакции: Transaction.addAnimationCompletion(criteria:_:), для кода, который сам собирает Transaction.
Длинные последовательности: три шага и больше читаются лучше как phaseAnimator или keyframeAnimator, чем как вложенные completion.
UIKit: completion у UIView.animate, addCompletion у property animator или UIView.animateKeyframes для многошаговой последовательности.
phaseAnimator переводит все свойства по списку состояний вместе; keyframeAnimator даёт каждому свойству свою шкалу кейфреймов, и шкалы идут параллельно. repeatForever даёт одну зацикленную анимацию между двумя значениями: годится для пульсации, но не для последовательности.
Фазы
.phaseAnimator(phases, trigger:) проходит по фазам один раз на каждое изменение trigger; без trigger он крутится бесконечно: это обычная пульсация загрузки.
Замыкание animation: выбирает кривую для каждой фазы, но все свойства делят каждый шаг, поэтому scale не может закончиться, пока rotation ещё движется.
Кейфреймы
.keyframeAnimator(initialValue:trigger:content:keyframes:) принимает по одному KeyframeTrack на свойство, собранному из LinearKeyframe, SpringKeyframe, CubicKeyframe и MoveKeyframe. Треки идут одновременно, каждый со своими длительностями, а такую хореографию фазы выразить не могут.
Шкала вычисляется заранее, а замыкание content вызывается каждый кадр с интерполированным значением: держите его лёгким и не ждите, что оно отреагирует на изменение стейта посреди анимации. repeating: true зацикливает её.
repeatForever
.animation(.easeInOut.repeatForever(autoreverses: true), value: isOn) достаточно для одного зацикленного значения.
withAnimation(.linear.repeatForever()) в onAppear заставляет повторяться каждое изменение в этой транзакции, включая изменение layout, случившееся в тот же момент; так спиннер начинает уплывать по экрану. Ограничивайте область через .animation(_:value:).
Оба аниматора требуют iOS 17.
SwiftUI интерполирует только то, что тип отдаёт через Animatable.animatableData. Shape реализует Animatable, но по умолчанию его animatableData имеет тип EmptyAnimatableData, поэтому progress никогда не интерполируется, и кольцо перерисовывается один раз, сразу с конечным значением.
Как работает интерполяция
Во время анимации SwiftUI записывает промежуточные значения в animatableData и перерисовывает: у фигуры каждый кадр вызывается path(in:), а у Animatable-вью или модификатора вызывается body. Держите и то и другое лёгким.
Тип должен реализовывать VectorArithmetic: Double, CGFloat, CGPoint, CGSize или вложенные AnimatablePair для нескольких значений.
В Xcode 26 @Animatable на типе генерирует animatableData из его хранимых свойств, а @AnimatableIgnored исключает отдельное свойство.
Исправления для типичных случаев
Кольцо: отдайте progress через animatableData или обойдитесь без своей фигуры: Circle().trim(from: 0, to: progress) уже анимируется.
Число, которое растёт: Text не умеет интерполировать свою строку. Поместите число в Animatable-вью, которая форматирует интерполированный Double, или используйте .contentTransition(.numericText(value:)), чтобы цифры прокручивались.
Swift 6: View изолирован на main actor, а Animatable не изолирован, поэтому у вью помечайте animatableData как nonisolated, иначе соответствие протоколу не скомпилируется.
Морфинг путей: интерполяция между двумя разными контурами требует, чтобы оба описывались одними и теми же параметрами; два произвольных Path SwiftUI смешивать не будет.
Неявную анимацию Core Animation создаёт сам, когда вы меняете анимируемое свойство слоя; явная представляет собой CAAnimation, который вы собираете и добавляете через layer.add(_:forKey:). Явная анимация меняет только presentation layer, а выставить значение в модели остаётся вашей задачей.
**CABasicAnimation**: один key path от fromValue до toValue (или byValue). Key path может заходить внутрь структур: "transform.scale", "position.x".
**CAKeyframeAnimation**: список values с необязательными keyTimes и calculationMode или path, по которому нужно двигаться; rotationMode = .rotateAuto поворачивает слой вдоль кривой. Shake делается кейфреймами с isAdditive = true.
**CAAnimationGroup**: запускает несколько анимаций с общими duration, beginTime и ключом; дочерние анимации обрезаются по длительности группы.
**CASpringAnimation**: физическая пружина; в iOS 17 появился init(perceptualDuration:bounce:) с той же моделью, что у пружин SwiftUI. **CATransition делает cross-fade или push всего содержимого слоя.
Ключ**: анимация, добавленная под уже занятым ключом, заменяет текущую, а removeAnimation(forKey:) её отменяет. Неявная анимация использует в качестве ключа имя свойства.
Делегат держится сильно: CAAnimation.delegate удерживает свой объект, в отличие от большинства делегатов в UIKit. View controller, ставший делегатом бесконечной анимации, никогда не освободится.
Model layer хранит значения, которые вы установили (во время анимации это уже конечные), а presentation() возвращает копию с тем, что на экране в этом кадре. UIKit делает hit testing по модели и по умолчанию отключает взаимодействие с анимируемыми вью, поэтому тапы по движущимся пикселям не срабатывают.
Три дерева
Model: слои, которыми вы владеете. UIView.animate записывает конечную точку сразу после выхода из блока, поэтому frame, center и hit testing показывают финальное состояние.
Presentation: layer.presentation(), копия только для чтения с интерполированными значениями текущего кадра. Она nil, пока слой ни разу не был закоммичен, а model() ведёт обратно к настоящему слою.
Render: копия в render server, отдельном процессе, который проигрывает анимацию кадр за кадром. Поэтому анимация UIView.animate продолжает двигаться, даже когда главный поток заблокирован.
Как сделать движущуюся вью тапабельной
.allowUserInteraction в опциях не даёт UIKit отключить взаимодействие, но касания по-прежнему проверяются по конечной точке.
Повесьте tap recognizer на неподвижный контейнер и проверяйте в обработчике layer.presentation()?.frame, или так же переопределите hitTest(_:with:).
Чтобы «поймать» вью, скопируйте значения presentation в модель и вызовите removeAllAnimations(). У UIViewPropertyAnimator это за вас делает stopAnimation(true).
Читайте presentation(), когда нужно «где она прямо сейчас» (hit testing, старт новой анимации с текущего места), а модель для всего остального.
matchedGeometryEffect не морфит содержимое: он берёт frame вью, помеченной как источник, и применяет его к другой вью с тем же id и namespace. Анимация получается, только когда обе вью существуют в одной анимированной транзакции, в одном namespace и источник в каждый момент ровно один.
Как сопоставляется пара
Пара определяется ключом (id, namespace). isSource: true (по умолчанию) значит «измеряй меня»; вторую вью ставят и растягивают по frame источника, и она анимируется к своему собственному.
Обычно используют подмену: миниатюра удаляется, а детальная вью вставляется в одном и том же withAnimation, поэтому в течение одной транзакции SwiftUI видит обе и анимирует между их фреймами.
properties: ограничивает, что копируется: .frame (по умолчанию), .position или .size.
Почему гаснет и прыгает
Изменение стейта не было анимировано.
Обе вью остаются вставленными с isSource: true; SwiftUI предупреждает о нескольких источниках и выбирает один. Уберите одну или переключайте isSource.
Источник лежит в ленивом контейнере и не был создан или уже уехал за экран, поэтому сопоставлять не с чем.
@Namespace живёт во вью, которая теряет identity, и каждая пересборка создаёт новый namespace.
Вью находятся на разных экранах. Matched geometry не работает через push в NavigationStack или sheet; с iOS 18 используйте .matchedTransitionSource(id:in:) вместе с .navigationTransition(.zoom(sourceID:in:)).
Через экран летит детальная вью, уже отрисованная с конечным содержимым и временно взявшая frame источника, поэтому её содержимое перекладывается и плавно проявляется, а не морфится.
Передайте скорость пальца как начальную скорость пружины, и тогда движение продолжится, а не начнётся заново из состояния покоя. duration задаёт воспринимаемое время успокоения, bounce определяет величину перелёта, и, в отличие от массы, жёсткости и затухания, их можно настраивать независимо.
Duration и bounce
Spring(duration:bounce:), Animation.spring(duration:bounce:), UIView.animate(springDuration:bounce:) и CASpringAnimation(perceptualDuration:bounce:) используют одну модель (iOS 17+).
bounce 0 означает критическое затухание, без перелёта; значения до 1 дают больший перелёт, а отрицательные дают вялое движение. Для bounce ≥ 0 dampingRatio = 1 - bounce.
.smooth, .snappy и .bouncy в SwiftUI являются пресетами с bounce 0, 0,15 и 0,3. Изменение bounce сохраняет длительность, поэтому дизайнер может описать пружину именно так.
Передача скорости
UISpringTimingParameters(dampingRatio:initialVelocity:) принимает CGVector относительно оставшегося расстояния: velocity / (target - current). Сырые точки в секунду выбрасывают вью за экран, а нулевое расстояние даёт NaN, так что проверяйте его.
В SwiftUI передавайте DragGesture.Value.velocity (iOS 17) в .interpolatingSpring(duration:bounce:initialVelocity:), тоже относительно расстояния.
Идущая пружина SwiftUI при новой цели сохраняет скорость, поэтому смена цели на лету по умолчанию проходит плавно.
Пружина не заканчивается, а сходится; цепляйте следующую работу к её completion или к Spring.settlingDuration, а не к фиксированной задержке.
Верните из делегата interaction controller (обычно UIPercentDrivenInteractiveTransition) и управляйте им из жеста через update(_:), а затем finish() или cancel(). При отмене аниматор должен откатить свои изменения и сообщить completeTransition(false), иначе экран останется замороженным.
Как стыкуются части
Анимацию по-прежнему делает аниматор. UIPercentDrivenInteractiveTransition скрабит её, ставя на паузу часы слоя контейнера (speed, timeOffset), поэтому это должна быть анимация UIKit или Core Animation, а не самописный цикл на display link.
Для перехода, который пользователь может снова схватить на лету, реализуйте interruptibleAnimator(using:) и возвращайте UIViewPropertyAnimator; тогда UIKit управляет этим аниматором напрямую.
Возвращайте interaction controller, только пока переход ведёт жест. Если вернуть его при нажатии кнопки, переход стартует на 0% и ждёт update, который никогда не придёт, и экран замораживается.
Правильная отмена
cancel() проигрывает анимацию назад, но в модели остаются конечные значения. Сбрасывайте transform, alpha и фреймы сами, когда transitionWasCancelled равен true, иначе вью отпрыгнет после окончания обратной анимации.
Вызывайте completeTransition(!context.transitionWasCancelled), а не захардкоженный true. Пока вызова нет, UIKit держит взаимодействие отключённым; с неправильным значением он считает, что экран сменился, хотя этого не произошло.
Ровно один finish() или cancel() на жест, включая состояния .cancelled и .failed.
animationEnded(_:): место, где нужно убрать добавленные вами снапшоты и затемняющие вью.
Анимировать transform, opacity, position или bounds уже отрисованного слоя дёшево: приложение коммитит один раз, а render server интерполирует каждый кадр. Дорого становится, когда каждому кадру нужен offscreen-проход на GPU или новые пиксели от CPU.
Закоммитил один раз, и дальше бесплатно
Render server работает в отдельном процессе и композитит слои с частотой дисплея, поэтому анимация этих свойств продолжается, даже когда главный поток занят.
Поэтому для движения transform лучше frame: ни layout, ни перерисовки, ни новой текстуры.
Оплачивается каждый кадр
Offscreen-проходы: GPU рендерит часть дерева в отдельный буфер, прежде чем скомпозитить её: mask, cornerRadius с masksToBounds поверх подслоёв, тень без shadowPath, групповая прозрачность (alpha меньше 1 у вью с сабвью), размытие UIVisualEffectView.
Новые пиксели: draw(_:), замена contents или размер, который каждый кадр меняет layout: работа CPU плюс загрузка текстуры.
Тесселяция пути: CAShapeLayer.path анимируется в render server, но каждый кадр тесселируется заново; для кольца дёшево, для сложного пути дорого.
Блендинг: стопка полупрозрачных слоёв пересчитывается каждый кадр; непрозрачные вью с непрозрачным фоном этого избегают.
Как найти и исправить
Меню Xcode Debug › View Debugging › Rendering включает Color Offscreen-Rendered и Color Blended Layers прямо на запущенном устройстве.
Давайте теням shadowPath. Обрезка дешёвая, когда у слоя есть только contents: UIImageView в порядке, а для контейнера с сабвью это не так.
shouldRasterize кэширует битмап и помогает только статичному содержимому; на слое, который меняется или масштабируется, кэш промахивается каждый кадр. Выставляйте rasterizationScale в масштаб экрана, иначе кэш будет мыльным.
Профилируйте release-сборку на устройстве шаблоном Animation Hitches в Instruments: он относит каждый hitch либо к фазе commit в вашем процессе, либо к фазе render в render server. Опоздание на commit означает работу на главном потоке, а опоздание на render означает, что дерево слоёв дорого рисовать.
Цикл рендера
У каждого кадра есть бюджет: около 16,7 мс на 60 Гц и 8,3 мс на 120 Гц. Приложение обрабатывает события и коммитит изменения layout и отрисовки, render server готовит дерево, GPU его рендерит, и кадр выводится на экран.
Под hitch понимают кадр, показанный позже ожидаемого. Instruments считает hitch time ratio в миллисекундах задержки на секунду: меньше 5 значит хорошо, 5–10 значит заметно, больше 10 значит критично.
Опоздание на commit
Долгий layoutSubviews, декодирование картинок на главном потоке, draw(_:), пересчёт body в SwiftUI для большого дерева. Смотрите главный поток в той же записи через Time Profiler и оборачивайте свою работу в интервалы OSSignposter.
Исправления: декодируйте картинки вне главного потока (byPreparingForDisplay(), byPreparingThumbnail(ofSize:)), кэшируйте результаты layout и сужайте часть дерева, которая инвалидируется.
Опоздание на render
Offscreen-проходы, блендинг, слишком большие картинки и размытие. Опции Rendering в меню отладки Xcode показывают, где именно; обычно помогают shadowPath и меньше масок.
Привычки
Симулятор использует CPU и GPU Mac, поэтому никогда этого не воспроизводит. Тестируйте на самом старом поддерживаемом устройстве.
Защищайтесь от регрессий performance-тестом XCTest с XCTOSSignpostMetric и следите за hitch rate у реальных пользователей в Xcode Organizer.