Animations & Core Animation
CALayer, implicit animations, UIViewPropertyAnimator, transitions, PhaseAnimator, KeyframeAnimator
- 01
A button slides across the screen for two seconds and taps on it do nothing until it lands — what is actually going on?
MediumUIKit 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 … - 02
Setting
positionon a bare CALayer animates for free, but the same assignment on a view's backing layer just snaps — why the difference?MediumBoth 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, theUIView, which hands backNSNulloutside 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 … - 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?
MediumsetDisableActions(true)stops the animation from ever being created, whilesetAnimationDuration(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() … - 04
Two animations look identical in the simulator, but on device one is free and the other drops frames — what separates them?
MediumThe 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 } … - 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?
MediumScrubbing writes
fractionCompletewhile the animator is paused, and with the defaultscrubsLinearly == truethe 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) { … - 06
A swipe-to-dismiss is cancelled halfway and the screen is left frozen and untappable — what did the transition forget to do?
MediumIt never reported the outcome:
animateTransition(using:)has to end withcompleteTransition(!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 } … - 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?
MediumYou 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 } … - 08
A row appears with a plain fade instead of the
.asymmetrictransition you attached to it — what does SwiftUI need before a transition runs at all?MediumA 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 { … - 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?
HardmatchedGeometryEffectnever 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
The designer wants a badge that pops, tilts and lifts on tap, each with its own timing — phaseAnimator, keyframeAnimator, or a repeatForever animation?
HardKeyframes, because only
keyframeAnimatorgives each property its own timeline: phases move everything to the next state together, andrepeatForeveris one interpolation between two values with no end.struct Pose { var scale = 1.0 var angle = Angle.zero var lift = 0.0 } … - 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?
HardSwiftUI can only animate values it can do arithmetic on, and it reaches them through the
Animatableprotocol'sanimatableData— a shape's initialiser arguments and aTextstring 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
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?
HardA 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
CADisableMinimumFrameDurationOnPhonein Info.plist — after which you express what you need as aCAFrameRateRangeand 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
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?
Harddraw(_:)gives the layer a backing store — a bitmap of width × height × scale² × 4 bytes that your CPU repaints and re-uploads on every change — whileCAShapeLayer,CAGradientLayerandCAEmitterLayerdescribe 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
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?
HardThe 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
durationandbouncerather 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
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?
HardProfile 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
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?
HardA sublayer you position in
layoutSubviewsis 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) …