Animations & Core Animation
Irbisa · cheatsheetSeptember 13, 2026

Animations & Core Animation

CALayer, implicit animations, UIViewPropertyAnimator, transitions, PhaseAnimator, KeyframeAnimator

Senior Developer16 itemscompressed for a skim
  1. 01

    A button slides across the screen for two seconds and taps on it do nothing until it lands — what is actually going on?

    Medium

    UIKit hit-tests the model layer, and the model layer holds the animation's final value from the instant the block returns — so the tap is tested against a frame that is already at the destination while the user's finger is over pixels drawn by the presentation layer.

    // The model layer jumps to the destination the moment the block returns
    UIView.animate(withDuration: 2, delay: 0, options: [.allowUserInteraction]) {
      self.ball.center.x = 320
    }
    print(ball.layer.position.x)                       // 320 — not what anyone can see
    print(ball.layer.presentation()?.position.x ?? 0)  // 41.7 — what is on screen this frame
    …
  2. 02

    Setting position on a bare CALayer animates for free, but the same assignment on a view's backing layer just snaps — why the difference?

    Medium

    Both assignments run the same lookup — Core Animation asks the layer for an action for that key — and the two layers get different answers: a bare layer falls through to defaultAction(forKey:) and gets a quarter-second animation, while a view-backed layer asks its delegate, the UIView, which hands back NSNull outside an animation block.

    // Bare layer: no delegate, so the default action wins — 0.25s, ease-in-ease-out, free
    let badge = CALayer()
    view.layer.addSublayer(badge)
    badge.opacity = 0                 // fades out on its own
    
    // View-backed layer: the view returns NSNull outside an animation block
    …
  3. 03

    You need one layer property change not to animate — do you wrap it in a zero-duration CATransaction or in setDisableActions, and does it matter?

    Medium

    setDisableActions(true) stops the animation from ever being created, while setAnimationDuration(0) still builds one and runs it in no time — identical on a still screen, and different the moment something else is already animating that property.

    // One property, no animation: stop the lookup before it starts
    CATransaction.begin()
    CATransaction.setDisableActions(true)
    gradient.frame = bounds              // committed as a plain value change
    CATransaction.commit()
    …
  4. 04

    Two animations look identical in the simulator, but on device one is free and the other drops frames — what separates them?

    Medium

    The cheap one only touches properties the render server can interpolate and composite by itself — transform, opacity, position, bounds, backgroundColor — so your process commits once and then does nothing, while the expensive one makes the GPU render into an offscreen buffer, or makes your CPU produce new pixels, on every single frame.

    // Free: one commit, then the render server interpolates without waking your process
    UIView.animate(withDuration: 0.4) {
      card.transform = CGAffineTransform(scaleX: 1.1, y: 1.1).rotated(by: .pi / 30)
      card.alpha = 0.6
    }
    …
  5. 05

    A sheet driven by a pan feels right going down and wrong the moment the user drags it back — what does UIViewPropertyAnimator do to the timing curve?

    Medium

    Scrubbing writes fractionComplete while the animator is paused, and with the default scrubsLinearly == true the finger maps 1:1 to progress — but the curve comes back the moment you reverse or let go, applied from wherever the fraction currently is, and an ease-out run backwards reads as an ease-in.

    final class SheetInteraction {
      private var animator: UIViewPropertyAnimator?
      private let sheet: UIView
      private var travel: CGFloat { sheet.bounds.height }
    
      @objc func handle(_ pan: UIPanGestureRecognizer) {
    …
  6. 06

    A swipe-to-dismiss is cancelled halfway and the screen is left frozen and untappable — what did the transition forget to do?

    Medium

    It never reported the outcome: animateTransition(using:) has to end with completeTransition(!context.transitionWasCancelled), and until that call UIKit keeps the transition in flight, with user interaction off and the view controller hierarchy unchanged.

    final class DismissAnimator: NSObject, UIViewControllerAnimatedTransitioning {
      private weak var fromView: UIView?
      private let dimming = UIView()
    
      func transitionDuration(using c: UIViewControllerContextTransitioning?) -> TimeInterval { 0.35 }
    …
  7. 07

    In a custom push animation, who puts the incoming view into the container view, and who is responsible for taking the outgoing one out?

    Medium

    You add the incoming view yourself; you remove neither — UIKit hands you a stage with the outgoing view already on it and takes the loser off once you call completeTransition.

    final class SlideAnimator: NSObject, UIViewControllerAnimatedTransitioning {
      private let operation: UINavigationController.Operation
      init(operation: UINavigationController.Operation) { self.operation = operation }
    
      func transitionDuration(using c: UIViewControllerContextTransitioning?) -> TimeInterval { 0.32 }
    …
  8. 08

    A row appears with a plain fade instead of the .asymmetric transition you attached to it — what does SwiftUI need before a transition runs at all?

    Medium

    A transition only runs when a view's identity changes — SwiftUI has to watch the view enter or leave the tree — and the change that causes it has to be animated, otherwise there is nothing to interpolate and the view simply appears.

    struct Inbox: View {
      @State private var showDetail = false
      @State private var items: [Item] = Item.samples
    
      var body: some View {
        VStack {
    …
  9. 09

    A card is supposed to grow into a detail view with matchedGeometryEffect, but it just fades and snaps into place — what is SwiftUI missing?

    Hard

    matchedGeometryEffect never morphs content — it reads the frame of whichever view is marked as the source and applies an offset and a resize to the other view carrying the same id, so it only works when both views are in the hierarchy, in the same namespace, in the same transaction, with exactly one source.

    struct CardGallery: View {
      @Namespace private var cards          // must outlive both sides of the pair
      @State private var expanded: String?
      private let ids = ["a", "b", "c", "d"]
    
      var body: some View {
    …
  10. 10

    The designer wants a badge that pops, tilts and lifts on tap, each with its own timing — phaseAnimator, keyframeAnimator, or a repeatForever animation?

    Hard

    Keyframes, because only keyframeAnimator gives each property its own timeline: phases move everything to the next state together, and repeatForever is one interpolation between two values with no end.

    struct Pose {
      var scale = 1.0
      var angle = Angle.zero
      var lift = 0.0
    }
    …
  11. 11

    An animation on your custom shape's parameter does nothing, and an animated counter jumps straight from 0 to 1,240 — what does SwiftUI need in order to interpolate?

    Hard

    SwiftUI can only animate values it can do arithmetic on, and it reaches them through the Animatable protocol's animatableData — a shape's initialiser arguments and a Text string are not on that path, so the view is simply rebuilt at the final value.

    // Snaps: `end` is only an init argument, so there is nothing to interpolate.
    struct WedgeThatSnaps: Shape {
      var end: Double
      func path(in rect: CGRect) -> Path { Wedge(end: end).path(in: rect) }
    }
    …
  12. 12

    Your CADisplayLink-driven waveform runs at 60 fps on a 120 Hz iPhone, and when you force it to 120 the battery melts — what do you actually set?

    Hard

    A display link fires once per frame at whatever rate the system has chosen, and on a ProMotion iPhone that is capped at 60 Hz until your app opts in with CADisableMinimumFrameDurationOnPhone in Info.plist — after which you express what you need as a CAFrameRateRange and let the system arbitrate instead of demanding 120.

    final class WaveformView: UIView {
      private var link: CADisplayLink?
      private var phase: CFTimeInterval = 0
    
      override func didMoveToWindow() {
        super.didMoveToWindow()
    …
  13. 13

    The Memory Graph shows every one of your custom-drawn progress rings holding a multi-megabyte bitmap — what do you move to, and what does that cost instead?

    Hard

    draw(_:) gives the layer a backing store — a bitmap of width × height × scale² × 4 bytes that your CPU repaints and re-uploads on every change — while CAShapeLayer, CAGradientLayer and CAEmitterLayer describe their content as parameters the render server rasterises, so they hold no bitmap and their properties animate for free.

    // Backing store: 320x320 points at 3x is ~3.7 MB per view, repainted on change.
    final class RingViewCG: UIView {
      var progress: CGFloat = 0 { didSet { setNeedsDisplay() } }   // full CPU redraw
    
      override func draw(_ rect: CGRect) {
        let path = UIBezierPath(arcCenter: CGPoint(x: rect.midX, y: rect.midY),
    …
  14. 14

    A sheet flicked hard follows the finger and then restarts from a dead stop at the release point — what is wrong with the spring you handed it?

    Hard

    The spring was created without the gesture's exit velocity, so it starts from rest and the motion visibly kinks — a spring is defined by where it is going, how fast it is already moving, and, in the modern parameterisation, by duration and bounce rather than stiffness and damping.

    // Wrong: the flick's velocity is discarded, so the spring starts from rest.
    func endDragBad() {
      UIView.animate(withDuration: 0.4) { self.sheet.frame.origin.y = self.restingY }
    }
    
    // Right: normalise the gesture velocity against the distance still to travel.
    …
  15. 15

    The transition is perfectly smooth in the simulator and stutters on a real iPhone — how do you tell a slow commit from a slow GPU?

    Hard

    Profile the render loop rather than the frame rate: the Animation Hitches instrument splits every late frame into your app's commit phase and the render server's prepare and execute phases, and the Metal HUD on device tells you in seconds whether the GPU is the one missing the deadline.

    import os
    
    private let log = OSLog(subsystem: "com.example.feed", category: .pointsOfInterest)
    private let signposter = OSSignposter(logHandle: log)
    
    // Commit-side hitch: the decode happens on the main thread as the frame commits.
    …
  16. 16

    On rotation the shaped header behind your navigation bar flies in from the old corner a beat after everything else — why, and how do you make it move with the rotation?

    Hard

    A sublayer you position in layoutSubviews is not part of what UIKit animates during the rotation: your assignment fires the layer's own implicit action on its own clock, so it travels separately from the views.

    final class HeaderView: UIView {
      private let curve = CAShapeLayer()
    
      static func path(for rect: CGRect) -> CGPath {
        let path = UIBezierPath()
        path.move(to: .zero)
    …