It prints 40. The animation changed only the presentation layer, and fillMode = .forwards with isRemovedOnCompletion = false freezes that picture over a model that never moved, and while the frozen animation stays attached, it overrides every new model value.
Two sources of truth: the model says 40, the screen shows 300, and layout and the next animation read the model.
Why 100 never shows: a non-additive animation replaces the model value in the presentation layer for as long as it is attached, so the new value stays invisible until someone removes "slide".
A slow leak: the animation object stays on the layer forever, and so would any delegate you gave it, since CAAnimation holds its delegate strongly.
The fix: take fromValue from the current position, set the model to the end value, then add the animation and let it be removed on completion. The presentation ends exactly on the model value, so there is no jump.
**Where fillMode is right**: with a delayed beginTime, .backwards shows the first value during the wait instead of the model value.
iOSSenior
Animations & Core Animation
CALayer, implicit animations, UIViewPropertyAnimator, transitions, PhaseAnimator, KeyframeAnimator
Questions closed
0 / 20
- 0Review
Questions
Both changes go through the same action search and get different answers: a standalone layer has no delegate and falls through to the default 0.25 s animation, while a backing layer asks its UIView, which returns NSNull (no animation) unless the change is inside an animation block.
The action search, in order
delegate?.action(for:forKey:): for a backing layer, the view. Outside UIView.animate it returns NSNull; inside, a real animation with the block's duration and curve.
The layer's actions dictionary.
The style dictionary.
defaultAction(forKey:) on the layer class: the implicit 0.25 s ease-in-ease-out.
NSNull at any step ends the search with no animation; nil moves on to the next step.
The trap this creates
A sublayer you add yourself (a CAShapeLayer ring, a CAGradientLayer background) has no delegate. Set its frame in layoutSubviews and it animates into place on every rotation, keyboard or split-view resize, trailing the view by a quarter of a second. This applies only to a layer that has already been committed; a layer added and changed in the same turn just appears in its final state.
Silence it once with actions = ["position": NSNull(), "bounds": NSNull()], or set the frame in a transaction with actions disabled. When the layer should move with a rotation, animate it explicitly inside coordinator.animate(alongsideTransition:) with the transition's duration.
Simplest of all: make the shape the view's own layer by overriding layerClass. It then follows the view's bounds with no frame code; only path still needs rebuilding in layoutSubviews.
CATransaction is the batch that carries layer changes to the render server: it sets their animation duration, timing function and completion, and commits them together. To keep one change from animating, wrap it in a transaction with setDisableActions(true).
Implicit transactions: Core Animation opens one automatically and commits it at the end of the current run loop turn. That is why ten property changes in one method render as a single update.
Explicit transactions: begin() and commit() nest inside it, and their settings apply to every change made between the two calls on that thread.
**setDisableActions(true) vs a zero duration**: both add no new animation. Disabling skips the action search and says what you mean; it also leaves an animation already running on that key untouched, while a zero duration cancels it. Say "no animation" with the first, and call removeAnimation(forKey:) when you need to cancel.
Completion: set setCompletionBlock before the changes. It fires once, when every animation added in the transaction has finished or been removed.
Balance: an early return between begin() and commit() leaves the transaction open; defer { CATransaction.commit() } prevents it.
For views: UIView.performWithoutAnimation does the same for a view change made inside an outer animation block.
With a the card is drawn 100 pt to the right, centred at (300, 200); with b it is 100 pt lower, at (200, 300); both are rotated 90°. print shows (200, 200) both times, because transform changes only how the view is drawn, not its center or bounds.
Why the order matters
translatedBy, rotated(by:) and scaledBy apply the new step before the existing transform, in its local axes. In b the translation happens in the card's already rotated coordinate space, where the x axis points down.
t1.concatenating(t2) reads left to right: t1 first, then t2. Spelling the steps with concatenating is the least surprising way.
The transform is applied around layer.anchorPoint, the centre by default. Moving the anchor to rotate around a corner also moves the layer unless you correct position.
Rules that follow
With a non-identity transform, frame is undefined (it returns the bounding box); use bounds and center.
Auto Layout ignores transform, so animating it costs no layout pass and is never undone by the next one, unlike animating frame.
CGAffineTransform is 2D. Depth and perspective need CATransform3D on layer.transform (or transform3D on the view), with m34 = -1 / distance for perspective.
Specialised layers (CAShapeLayer, CAGradientLayer, CATextLayer, CAReplicatorLayer, CAEmitterLayer) describe content the render server draws itself. draw(_:) instead gives the view a bitmap that the CPU repaints and uploads again on every change.
**CAShapeLayer**: a vector path with animatable path, strokeStart, strokeEnd, lineWidth and colours. A progress ring is strokeEnd going from 0 to 1: one animation, no redraws.
**CAGradientLayer**: axial, radial or .conic gradients; colors and locations animate. Sharp at any size, unlike a gradient image.
**CATextLayer**: light text without a UILabel. Set contentsScale to the screen scale, or it renders blurry.
**CAReplicatorLayer: draws N copies of its sublayers with a transform and a time offset per copy: spinners, equalisers and ripples from one layer.
CAEmitterLayer**: particles simulated on the render server. Cells are copied on assignment, so change them later through key paths such as emitterCells.spark.birthRate.
Also: AVPlayerLayer for video, CAMetalLayer for a Metal renderer.
What draw(_:) costs
The backing store is width × height × scale² × 4 bytes: a 300 pt square at 3x is about 3.2 MB per view, and every setNeedsDisplay() repaints it on the main thread.
It still earns its place for one-off raster work: render once into a UIGraphicsImageRenderer image and reuse the result.
CADisplayLink is a timer synchronised with the display: it calls you once per frame so you can compute that frame's value yourself. Use it only when Core Animation cannot interpolate the thing for you (physics, a live waveform, a custom renderer).
**Why not Timer: a timer drifts against the screen refresh and fires between frames, so some frames get two updates and some none. A display link fires once per frame, in step with the display.
Use targetTimestamp: it is when the frame you are preparing will be shown. Compute positions from it, not from a fixed 1/60 step, or the animation slows down when the system lowers the rate.
Frame rate**: set preferredFrameRateRange with CAFrameRateRange(minimum:maximum:preferred:). It is a request: the system balances every request on screen and may go lower to save power. Ask for 120 only when the content needs it.
ProMotion iPhones: without CADisableMinimumFrameDurationOnPhone = YES in Info.plist, display links and animations on iPhone are capped at 60 Hz. iPad does not need the key.
Run loop mode: add the link to .common, or it stops while a scroll view is tracking a finger.
Retain cycle: the link retains its target. Call invalidate() when the view leaves the window, or neither object is ever freed.
iOS 18+: UIUpdateLink is the UIKit-level alternative, tied to a view or window scene and able to request low-latency updates.
UIView.animate is fire-and-forget; UIViewPropertyAnimator is an object you keep, so you can pause it, scrub fractionComplete, reverse it, add animations or completions while it runs and finish it with new timing, which is what interactive, interruptible UI needs.
Driving it from a gesture
.began: create the animator with the end state and call pauseAnimation(). That starts it paused, the only state in which setting fractionComplete does anything.
.changed: map the translation to fractionComplete. With the default scrubsLinearly = true the view follows the finger 1:1, and the timing curve returns when the animator runs again.
.ended: set isReversed if the user went back, then call continueAnimation(withTimingParameters:durationFactor:). nil keeps the original curve, UISpringTimingParameters swaps in a spring, and durationFactor: 0 keeps the remaining time.
What catches people
The completion receives a UIViewAnimatingPosition: .end, or .start after a reversal. Branch on it instead of assuming the action happened.
The animation closure runs once, when the animator starts, to capture the end state. Values read inside it are read at that moment.
stopAnimation(true) leaves the views where they are on screen, writes those values into the model and skips the completions; stopAnimation(false) followed by finishAnimation(at:) lets you choose the final state.
pausesOnCompletion = true keeps a finished animator alive so it can be reversed later, like a toggle.
Changing constant moves nothing by itself: it only marks the layout as needing an update. The frames change in the next layout pass, after the animation block has returned, so the panel snaps to its new height.
Why it happens: UIView.animate animates only the view property changes made while its block runs. A constraint is not one of them; the frame Auto Layout computes from it is, and that frame is set later, in layoutSubviews, outside the animation.
The fix: change constant and then call layoutIfNeeded() inside the block, so the layout pass and every frame it sets happen while the animation is open. The same rule applies to isActive and priority changes.
Call it on the right view: on the common superview, here self.view, not on panel: the panel's frame is set by its superview's layout, and siblings pinned below the panel must move with it.
Flush first: a layoutIfNeeded() before the change applies any layout already pending without animation, so unrelated changes queued earlier do not slide along with the panel.
Catching it: in a test, panel.layer.animationKeys() right after the call is nil with the bug and lists position and bounds.size with the fix.
Return an object conforming to UIViewControllerAnimatedTransitioning from the navigation or transitioning delegate. UIKit calls its animateTransition(using:): you add the incoming view to containerView, animate, and call completeTransition(_:); UIKit removes the outgoing view.
Who supplies the animator: navigationController(_:animationControllerFor:from:to:) for push and pop, UIViewControllerTransitioningDelegate for presentations. transitioningDelegate is weak, so keep a strong reference to it or the default animation silently comes back.
The container: a view UIKit creates for the transition, with the from-view already inside. Add the to-view with addSubview or insertSubview(_:belowSubview:), depending on which one should end up on top.
Frames: take the incoming view's frame from context.finalFrame(for:), not from the container's bounds; they differ under bars, in split views and in custom presentations.
**view(forKey:) over viewController.view**: with a presentation controller they are not the same view, and nil means UIKit or the presentation controller owns it.
Never remove either transition view yourself. UIKit removes the from-view on success and the to-view on cancel; doing it yourself races with that and leaves a black screen.
Reset transforms on the view that stays: a pushed-from controller is reused on the way back.
Check the built-ins first: since iOS 18, preferredTransition = .zoom { _ in sourceView } gives a fluid, interruptible zoom without writing an animator.
A transition runs only when a view is inserted into or removed from the hierarchy (its identity changes) and that change is animated. Without an animation the view simply appears; without an identity change there is nothing to transition.
Identity changes: an if or switch branch, a ForEach element added or removed, or .id(value) getting a new value. Changing the opacity or frame of a view that stays is only a property change, so its .transition never fires.
Where the animation goes: withAnimation { show = true }, or .animation(_:value:) on a container that stays in the tree. On the inserted view itself it is too late: that view did not exist when the change happened.
Where the transition goes: on the view inside the branch. On the enclosing VStack it does nothing, because the stack never leaves.
Insertion and removal: .asymmetric(insertion:removal:) gives each its own effect, and .combined(with:) stacks two.
Lists: give ForEach stable ids, not indices, or a deletion animates the wrong row. A leaving view still renders during its removal, so its body must not index back into the array.
Custom transitions: since iOS 17, conform to Transition and return a view for each TransitionPhase.
Since iOS 17, withAnimation(_:completionCriteria:_:completion:) calls a closure when the animation is done, and you chain by starting the next withAnimation inside it. Before that the only option was a delay equal to the duration, which breaks with springs and interruptions.
**.logicallyComplete (the default): fires when the animation has reached its target well enough to move on. For a spring that is before the last tiny oscillation dies out, so a chained step starts without a dead pause.
.removed: fires only when the animation has fully finished and been removed. Use it before deleting data or views the animation still renders.
A delay inside the chain**: Animation.delay(_:) postpones the next step without a timer.
The same hook on a transaction: Transaction.addAnimationCompletion(criteria:_:), for code that builds a Transaction itself.
Longer sequences: three or more steps read better as a phaseAnimator or keyframeAnimator than as nested completions.
UIKit: the completion of UIView.animate, addCompletion on a property animator, or UIView.animateKeyframes for a multi-step sequence.
phaseAnimator steps through a list of states, moving every property to the next state together; keyframeAnimator gives each property its own timeline of keyframes, running in parallel. repeatForever is one animation between two values, looped: fine for a pulse, not for a sequence.
Phases
.phaseAnimator(phases, trigger:) runs through the phases once per trigger change; without a trigger it cycles forever, which is the usual loading pulse.
The animation: closure picks a curve per phase, but all properties share each step, so scale cannot finish while rotation is still moving.
Keyframes
.keyframeAnimator(initialValue:trigger:content:keyframes:) takes one KeyframeTrack per property, built from LinearKeyframe, SpringKeyframe, CubicKeyframe and MoveKeyframe. Tracks run at the same time with their own durations: the choreography phases cannot express.
The timeline is precomputed and the content closure runs every frame with the interpolated value: keep it cheap, and do not expect it to react to state changes mid-flight. repeating: true loops it.
repeatForever
.animation(.easeInOut.repeatForever(autoreverses: true), value: isOn) is enough for one looping value.
withAnimation(.linear.repeatForever()) in onAppear makes every change in that transaction repeat, including a layout change that happens at the same moment; that is how a spinner ends up drifting across the screen. Scope it with .animation(_:value:).
Both animators need iOS 17.
SwiftUI interpolates only what a type exposes through Animatable.animatableData. Shape is Animatable, but its default animatableData is EmptyAnimatableData, so progress is never interpolated and the ring is redrawn once, at the final value.
How interpolation works
During an animation SwiftUI writes intermediate values into animatableData and redraws: path(in:) runs every frame for a shape, body for an Animatable view or modifier; keep both cheap.
The type must conform to VectorArithmetic: Double, CGFloat, CGPoint, CGSize, or nested AnimatablePairs for several values.
With Xcode 26, @Animatable on the type generates animatableData from its stored properties, and @AnimatableIgnored leaves one out.
Fixes for the usual cases
The ring: expose progress through animatableData, or skip the custom shape: Circle().trim(from: 0, to: progress) is already animatable.
A number counting up: Text cannot interpolate its string. Put the number in an Animatable view that formats the interpolated Double, or use .contentTransition(.numericText(value:)) to roll the digits instead.
Swift 6: View is main-actor-isolated and Animatable is not, so on a view mark animatableData as nonisolated, or the conformance does not compile.
Morphing paths: interpolating between two different outlines needs both described by the same parameters; SwiftUI will not blend two arbitrary Path values.
An implicit animation is one Core Animation creates for you when you change an animatable layer property; an explicit one is a CAAnimation you build and attach with layer.add(_:forKey:). Explicit animations change only the presentation layer; setting the model value stays your job.
**CABasicAnimation**: one key path from fromValue to toValue (or byValue). Key paths reach into structs: "transform.scale", "position.x".
**CAKeyframeAnimation**: a list of values with optional keyTimes and calculationMode, or a path to move along; rotationMode = .rotateAuto turns the layer with the curve. A shake is keyframes with isAdditive = true.
**CAAnimationGroup**: runs several animations under one duration, beginTime and key; children are clipped to the group's duration.
**CASpringAnimation**: a physical spring; iOS 17 added init(perceptualDuration:bounce:), the same model as SwiftUI springs. **CATransition cross-fades or pushes a layer's whole content.
The key**: adding an animation under a key already in use replaces the running one, and removeAnimation(forKey:) cancels it. An implicit animation uses the property name as its key.
The delegate is strong: CAAnimation.delegate retains its object, unlike almost every other delegate in UIKit. A view controller that is the delegate of an endless animation is never freed.
The model layer holds the values you set (during an animation, already the end values), while presentation() returns a copy with what is on screen this frame. UIKit hit-tests the model and by default disables interaction on animating views, so taps on the moving pixels go nowhere.
Three trees
Model: the layers you own. UIView.animate writes the destination the moment the block returns, so frame, center and hit testing all report the end state.
Presentation: layer.presentation(), a read-only copy with the interpolated values of the current frame. It is nil until the layer has been committed once, and model() leads back to the real layer.
Render: the copy in the render server, a separate process that plays the animation frame by frame. That is why a UIView.animate animation keeps moving while your main thread is blocked.
Making a moving view tappable
.allowUserInteraction in the options stops UIKit from disabling interaction, but touches are still tested against the destination.
Put a tap recognizer on a container that does not move and test layer.presentation()?.frame in the handler, or override hitTest(_:with:) the same way.
To "catch" the view, copy the presentation values into the model and call removeAllAnimations(). With a UIViewPropertyAnimator, stopAnimation(true) does that for you.
Read presentation() for "where is it right now" (hit testing, starting a new animation from the current spot) and the model for everything else.
matchedGeometryEffect does not morph content: it takes the frame of the view marked as source and applies it to the other view with the same id and namespace. It animates only when both views exist in the same animated transaction, in one namespace, with exactly one source at a time.
How the pair resolves
The pair is keyed by (id, namespace). isSource: true, the default, means "measure me"; the other view is placed and sized to the source's frame and animates to its own.
The usual pattern is a swap: the thumbnail is removed and the detail inserted in the same withAnimation, so for one transaction SwiftUI sees both and animates between their frames.
properties: limits what is copied: .frame (the default), .position or .size.
Why it fades and snaps
The state change was not animated.
Both views stay inserted with isSource: true; SwiftUI warns about multiple sources and picks one. Remove one, or flip isSource.
The source sits in a lazy container and was never created or has scrolled away, so there is no frame to match.
The @Namespace lives in a view that loses identity, so every rebuild creates a new namespace.
The two views are on different screens. Matched geometry does not cross a NavigationStack push or a sheet; since iOS 18, use .matchedTransitionSource(id:in:) with .navigationTransition(.zoom(sourceID:in:)).
What travels across the screen is the destination view, already drawn with its final content and borrowing the source's frame on the way, so its content re-lays out and cross-fades rather than morphing.
Pass the finger's velocity as the spring's initial velocity, so the motion continues instead of restarting from rest. duration is the perceived settling time and bounce the amount of overshoot, and unlike mass, stiffness and damping the two can be tuned independently.
Duration and bounce
Spring(duration:bounce:), Animation.spring(duration:bounce:), UIView.animate(springDuration:bounce:) and CASpringAnimation(perceptualDuration:bounce:) share one model (iOS 17+).
bounce 0 is critically damped (no overshoot); values up to 1 overshoot more, negative values are sluggish. For bounce ≥ 0, dampingRatio = 1 - bounce.
SwiftUI's .smooth, .snappy and .bouncy are presets with bounce 0, 0.15 and 0.3. Changing the bounce keeps the duration, which is why a designer can specify a spring this way.
Handing over velocity
UISpringTimingParameters(dampingRatio:initialVelocity:) takes a CGVector relative to the distance left: velocity / (target - current). Raw points per second launch the view off screen, and a zero distance gives NaN, so guard it.
In SwiftUI, pass DragGesture.Value.velocity (iOS 17) to .interpolatingSpring(duration:bounce:initialVelocity:), also relative to the distance.
A running SwiftUI spring that gets a new target keeps its velocity, so retargeting mid-flight is smooth by default.
A spring converges rather than ends; chain work on its completion or Spring.settlingDuration, not on a fixed delay.
Return an interaction controller (usually UIPercentDrivenInteractiveTransition) from the delegate and drive it from the gesture with update(_:), then finish() or cancel(). On cancel the animator must undo its changes and report completeTransition(false), or the screen is left frozen.
How the pieces fit
The animator still does the animation. UIPercentDrivenInteractiveTransition scrubs it by pausing the container layer's clock (speed, timeOffset), so it must be a UIKit or Core Animation animation, not a hand-rolled display link loop.
For a transition the user can grab again mid-flight, implement interruptibleAnimator(using:) and return a UIViewPropertyAnimator; UIKit then drives that animator directly.
Return the interaction controller only while a gesture is driving. Returning it for a button tap starts the transition at 0% and waits for an update that never comes: a frozen screen.
Cancelling correctly
cancel() plays the animation backwards, but the model values are still the end values. Reset transform, alpha and frames yourself when transitionWasCancelled is true, or the view jumps away once the reverse animation ends.
Call completeTransition(!context.transitionWasCancelled), never a hard-coded true. Until it is called UIKit keeps interaction disabled; with the wrong value it believes the screen changed when it did not.
Exactly one finish() or cancel() per gesture, including for .cancelled and .failed.
animationEnded(_:) is the place to remove snapshots and dimming views you added.
Animating transform, opacity, position or bounds of an already drawn layer is cheap: the app commits once and the render server interpolates every frame. It gets expensive when each frame needs an offscreen pass on the GPU or new pixels from the CPU.
Committed once, then free
The render server runs in its own process and composites layers at the display rate, so an animation of these properties keeps going while your main thread is busy.
This is why transform beats frame for motion: no layout, no redraw, no new texture.
Paid every frame
Offscreen passes: the GPU renders part of the tree into a separate buffer before compositing it: a mask, cornerRadius with masksToBounds over sublayers, a shadow with no shadowPath, group opacity (alpha below 1 on a view with subviews), a UIVisualEffectView blur.
New pixels: draw(_:), swapping contents, or a size that changes layout every frame: CPU work plus a texture upload.
Path tessellation: CAShapeLayer.path animates on the render server but is re-tessellated every frame; cheap for a ring, costly for a complex path.
Blending: stacked translucent layers are recomposited every frame; opaque views with opaque backgrounds avoid it.
Finding and fixing
Xcode's Debug › View Debugging › Rendering menu turns on Color Offscreen-Rendered and Color Blended Layers on the running device.
Give shadows a shadowPath. Clipping is cheap when the layer has only contents: a UIImageView is fine; a container with subviews is not.
shouldRasterize caches a bitmap and helps only static content; on a layer that changes or scales, the cache misses every frame. Set rasterizationScale to the screen scale, or the cache is blurry.
Profile a release build on the device with the Animation Hitches template in Instruments: it attributes each hitch to the commit phase in your process or to the render phase in the render server. Commit-side means main-thread work; render-side means the layer tree is expensive to draw.
The render loop
Each frame has a budget: about 16.7 ms at 60 Hz and 8.3 ms at 120 Hz. The app handles events and commits its layout and drawing changes, the render server prepares the tree, the GPU renders it, and the frame is displayed.
A hitch is a frame shown later than expected. Instruments reports the hitch time ratio in milliseconds of hitch per second: under 5 is good, 5–10 is noticeable, over 10 is critical.
Commit-side
Long layoutSubviews, image decoding on the main thread, draw(_:), SwiftUI bodies re-evaluated for a large tree. Look at the main thread in the same trace with Time Profiler, and wrap your own work in OSSignposter intervals.
Fixes: decode images off the main thread (byPreparingForDisplay(), byPreparingThumbnail(ofSize:)), cache layout results, and shrink the part of the tree that gets invalidated.
Render-side
Offscreen passes, blending, oversized images and blur. The Rendering options in Xcode's debug menu show where; a shadowPath and fewer masks usually fix it.
Habits
The simulator uses the Mac's CPU and GPU, so it never reproduces this. Test on the oldest supported device.
Guard against regressions with an XCTest performance test using XCTOSSignpostMetric, and watch the hitch rate for real users in the Xcode Organizer.