UIKitMiddleCoding

Что такое Core Animation и как создавать базовые анимации в UIKit?

Core Animation работает через CALayer (model/presentation layer), render server и CATransaction. UIView.animate — удобная обёртка; CABasicAnimation даёт контроль над timing и fill mode; CADisplayLink синхронизирует покадровую логику с дисплеем.

Что такое Core Animation

Core Animation (CA) — графический движок Apple, работающий в отдельном процессе rendererd (iOS) или WindowServer (macOS). Он оперирует не UIView, а CALayer — backing layer каждого вью. Слои хранят два дерева состояния: model layer (то, что вы присваиваете в коде) и presentation layer (интерполированное значение в данный кадр). Это различие — источник большинства багов с hit-testing во время анимации.

UIKit предоставляет три уровня абстракции над CA:

  • UIView.animate(withDuration:…) — самый высокий, анимирует animatable-свойства вью через implicit transaction;
  • CABasicAnimation / CAKeyframeAnimation — явные анимации на уровне слоя, дают полный контроль над timing, fill mode и делегатом;
  • CADisplayLink — покадровый callback, синхронизированный с частотой дисплея (ProMotion: до 120 Hz).

Пример: UIView.animate и CABasicAnimation

import UIKit

final class AnimationDemoViewController: UIViewController {

    private let box = UIView()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground

        box.frame = CGRect(x: 40, y: 200, width: 80, height: 80)
        box.backgroundColor = .systemBlue
        box.layer.cornerRadius = 12
        view.addSubview(box)

        let tapGR = UITapGestureRecognizer(target: self, action: #selector(runAnimations))
        view.addGestureRecognizer(tapGR)
    }

    // MARK: – UIView convenience API (implicit CATransaction)
    @objc private func runAnimations() {
        // 1. Простая анимация позиции и прозрачности
        UIView.animate(
            withDuration: 0.5,
            delay: 0,
            usingSpringWithDamping: 0.7,
            initialSpringVelocity: 0.5,
            options: [.curveEaseInOut]
        ) {
            self.box.center.x += 120
            self.box.alpha = 0.4
        } completion: { _ in
            // completion вызывается на главном потоке
            self.runCABasicAnimation()
        }
    }

    // MARK: – Явная CABasicAnimation
    private func runCABasicAnimation() {
        let anim = CABasicAnimation(keyPath: "transform.rotation.z")
        anim.fromValue = 0
        anim.toValue   = Double.pi * 2      // полный оборот
        anim.duration  = 0.8
        anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)

        // fillMode + isRemovedOnCompletion — частая ловушка
        anim.fillMode = .forwards
        anim.isRemovedOnCompletion = false  // ⚠️ слой живёт вечно, если забыть убрать

        anim.delegate = self                // AnimationDelegate
        box.layer.add(anim, forKey: "rotation")
    }

    // MARK: – CAKeyframeAnimation для нелинейного пути
    private func runKeyframe() {
        let pathAnim = CAKeyframeAnimation(keyPath: "position")
        pathAnim.path = makeBouncePath()
        pathAnim.duration = 1.2
        pathAnim.calculationMode = .cubicPaced
        box.layer.add(pathAnim, forKey: "pathMove")
    }

    private func makeBouncePath() -> CGPath {
        let path = UIBezierPath()
        path.move(to: box.center)
        path.addQuadCurve(
            to: CGPoint(x: box.center.x, y: 500),
            controlPoint: CGPoint(x: box.center.x + 80, y: 350)
        )
        return path.cgPath
    }
}

// MARK: – CAAnimationDelegate
extension AnimationDemoViewController: CAAnimationDelegate {
    func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        guard flag else { return }          // отменена — не трогаем UI
        // Синхронизируем model layer с presentation layer
        box.layer.removeAnimation(forKey: "rotation")
        // Сбрасываем реальное значение, иначе при следующем изменении будет прыжок
        box.layer.transform = CATransform3DIdentity
    }
}

CADisplayLink для покадровой анимации

final class ParticleViewController: UIViewController {
    private var displayLink: CADisplayLink?
    private var phase: CGFloat = 0
    private let waveLayer = CAShapeLayer()

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        view.layer.addSublayer(waveLayer)
        waveLayer.strokeColor = UIColor.systemIndigo.cgColor
        waveLayer.fillColor   = UIColor.clear.cgColor
        waveLayer.lineWidth   = 2

        displayLink = CADisplayLink(target: self, selector: #selector(tick))
        // preferredFrameRateRange — ProMotion API (iOS 15+)
        displayLink?.preferredFrameRateRange = CAFrameRateRange(
            minimum: 60, maximum: 120, preferred: 120
        )
        displayLink?.add(to: .main, forMode: .common)
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        displayLink?.invalidate()   // ⚠️ без invalidate — retain cycle и утечка
        displayLink = nil
    }

    @objc private func tick(link: CADisplayLink) {
        // link.targetTimestamp — время следующего кадра (точнее, чем Date())
        phase += CGFloat(link.targetTimestamp - link.timestamp) * 3
        waveLayer.path = buildWavePath(phase: phase)
    }

    private func buildWavePath(phase: CGFloat) -> CGPath {
        let path = UIBezierPath()
        let w = view.bounds.width
        path.move(to: CGPoint(x: 0, y: view.bounds.midY))
        stride(from: CGFloat(0), through: w, by: 2).forEach { x in
            let y = view.bounds.midY + sin(x / 30 + phase) * 40
            path.addLine(to: CGPoint(x: x, y: y))
        }
        return path.cgPath
    }
}

Как работает render pipeline

При изменении свойства слоя CA создаёт неявный CATransaction. В конце run loop он фиксирует дерево, отправляет скомпилированные команды в render server по XPC. Render server работает в отдельном процессе — именно поэтому анимации не прерываются при занятом главном потоке короткое время. Но длинный main-thread jank (~16 мс) всё равно вызывает dropped frames, потому что CA не получает следующий commit.

Подводные камни

  • fillMode + isRemovedOnCompletion = false без cleanup. Presentation layer замирает в конечной точке, model layer — в исходной. При следующем изменении происходит видимый прыжок. Всегда явно задавайте финальное значение model layer в animationDidStop и затем вызывайте removeAnimation(forKey:).
  • Hit-testing бьёт по model layer. Во время анимации UIView.animate нажатие в визуальную позицию кнопки ничего не даёт — responder chain ищет view по frame из model layer (исходная позиция). Используйте layer.presentation()?.hitTest(point) или перемещайте view до начала анимации.
  • CADisplayLink и retain cycle. CADisplayLink удерживает target сильной ссылкой. Если target — UIViewController, и вы забыли invalidate() в viewDidDisappear, контроллер никогда не освободится. Альтернатива — weak proxy через NSProxy.
  • Анимация после layoutSubviews. Если изменить frame вью внутри блока анимации, а затем вызвать setNeedsLayout — AutoLayout пересчитает frame синхронно и отменит анимацию. Анимируйте constraint constant, а не frame напрямую.
  • preferredFramesPerSecond устарел. С iOS 15 используйте CAFrameRateRange. Старое API игнорируется на ProMotion-устройствах с адаптивной частотой.
  • Подводные камни
    • Проверяйте корректность данных после каждой операции.

Common mistakes

  • Сводить «Core Animation и как создавать базовые анимации в UIKit» к синтаксису и не объяснять responder chain.
  • Игнорировать жизненный цикл, основной поток или момент освобождения ресурсов в сценарии uikit-18.
  • Выбирать API по привычке, не проверяя состояние, ошибки, доступность и платформенные ограничения.

What the interviewer is testing

  • Формулирует точную модель для «Core Animation и как создавать базовые анимации в UIKit» и подтверждает ее корректным примером.
  • Умеет связать ответ с reuse pool, тестированием и отладкой на устройстве.
  • Называет ограничения подхода uikit-18, включая производительность, память и сопровождение.

Sources

Related topics