Core Animation Bridge
Patterns for bridging Core Animation (QuartzCore) with SwiftUI. Use when SwiftUI's built-in animation system is insufficient -- typically for performance-critical layer animations, layer-only properties, additive animations, or direct CALayer manipulation. Overflow reference for the swiftui-animation skill.
Contents
- When to Drop Below SwiftUI Animations
- CABasicAnimation
- CAKeyframeAnimation
- CASpringAnimation
- CAAnimationGroup
- CADisplayLink
- UIViewRepresentable Wrapper for CA Layers
- Bridging CA Animations with SwiftUI State
- Performance Considerations
When to Drop Below SwiftUI Animations
SwiftUI's animation system covers most use cases. Drop to Core Animation only when:
| Scenario | Why CA Is Needed |
|---|---|
| CALayer-specific timing | CAMediaTimingFunction keeps timing attached to direct layer animations |
| Layer-specific properties (shadowPath, borderWidth, etc.) | SwiftUI does not expose all CALayer animatable properties |
| Additive animations | CA supports additive blending of multiple concurrent animations on the same property |
| Frame-synchronized drawing | CADisplayLink provides precise frame timing for custom rendering |
| Performance-critical particle/effects | Direct layer manipulation avoids SwiftUI's diffing overhead |
| Animation along a path | CAKeyframeAnimation supports CGPath-based animation paths |
Do not drop to Core Animation just for a cubic Bezier timing curve: SwiftUI has
UnitCurve.bezier(startControlPoint:endControlPoint:) and
Animation.timingCurve(_:duration:). If SwiftUI's withAnimation,
PhaseAnimator, or KeyframeAnimator can achieve the effect, prefer them.
Core Animation bridging adds complexity and requires explicit
UIViewRepresentable wrappers.
CABasicAnimation
CABasicAnimation interpolates a single layer property between two values.
Basic Usage
import QuartzCore
let animation = CABasicAnimation(keyPath: "opacity")
animation.fromValue = 0.0
animation.toValue = 1.0
animation.duration = 0.3
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
// Apply to a layer
layer.add(animation, forKey: "fadeIn")
layer.opacity = 1.0 // Set the final model valueCustom Bezier Timing
For SwiftUI view animations, prefer
Animation.timingCurve(.bezier(startControlPoint:endControlPoint:), duration:).
Use CAMediaTimingFunction when you are already animating a CALayer
property directly.
let timingFunction = CAMediaTimingFunction(controlPoints: 0.2, 0.8, 0.2, 1.0)
let animation = CABasicAnimation(keyPath: "position.y")
animation.fromValue = layer.position.y
animation.toValue = layer.position.y - 100
animation.duration = 0.5
animation.timingFunction = timingFunction
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
layer.add(animation, forKey: "customBezier")Shadow Path Animation
// Animate shadowPath -- not possible in pure SwiftUI
let animation = CABasicAnimation(keyPath: "shadowPath")
animation.fromValue = layer.shadowPath
animation.toValue = UIBezierPath(roundedRect: newBounds, cornerRadius: 16).cgPath
animation.duration = 0.3
animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
layer.shadowPath = UIBezierPath(roundedRect: newBounds, cornerRadius: 16).cgPath
layer.add(animation, forKey: "shadowPath")Important: Always set the model value (the property on the layer itself) to the final state. Core Animation operates on a separate presentation layer -- without setting the model value, the layer snaps back when the animation completes.
Docs: CABasicAnimation | CAMediaTimingFunction | UnitCurve.bezier | Animation.timingCurve(_:duration:)
CAKeyframeAnimation
CAKeyframeAnimation animates a property through a sequence of values or along a path.
Value-Based Keyframes
let animation = CAKeyframeAnimation(keyPath: "transform.scale")
animation.values = [1.0, 1.3, 0.9, 1.05, 1.0]
animation.keyTimes = [0, 0.25, 0.5, 0.75, 1.0] // Normalized [0..1]
animation.duration = 0.6
animation.timingFunctions = [
CAMediaTimingFunction(name: .easeOut),
CAMediaTimingFunction(name: .easeIn),
CAMediaTimingFunction(name: .easeOut),
CAMediaTimingFunction(name: .easeInEaseOut)
]
layer.add(animation, forKey: "bounceScale")Path-Based Animation
// Animate position along a CGPath -- unique to CAKeyframeAnimation
let path = CGMutablePath()
path.move(to: CGPoint(x: 50, y: 300))
path.addCurve(
to: CGPoint(x: 300, y: 50),
control1: CGPoint(x: 100, y: 50),
control2: CGPoint(x: 250, y: 300)
)
let animation = CAKeyframeAnimation(keyPath: "position")
animation.path = path
animation.duration = 1.5
animation.rotationMode = .rotateAuto // Rotate along the tangent
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
layer.add(animation, forKey: "pathAnimation")
layer.position = CGPoint(x: 300, y: 50)Shake Animation (Discrete Keyframes)
func shakeAnimation() -> CAKeyframeAnimation {
let animation = CAKeyframeAnimation(keyPath: "transform.translation.x")
animation.values = [0, -10, 10, -8, 8, -5, 5, 0]
animation.keyTimes = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 1.0]
animation.duration = 0.5
animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
return animation
}Docs: CAKeyframeAnimation
CASpringAnimation
CASpringAnimation applies spring physics to a layer property. It extends CABasicAnimation with physical spring attributes.
Physical Spring Parameters
let spring = CASpringAnimation(keyPath: "transform.scale")
spring.fromValue = 0.0
spring.toValue = 1.0
spring.mass = 1.0
spring.stiffness = 200.0
spring.damping = 10.0
spring.initialVelocity = 0.0
spring.duration = spring.settlingDuration // Use the physics-calculated duration
layer.add(spring, forKey: "springScale")
layer.transform = CATransform3DIdentityPerceptual Spring (iOS 17+)
let spring = CASpringAnimation(perceptualDuration: 0.5, bounce: 0.3)
spring.keyPath = "position.y"
spring.fromValue = layer.position.y
spring.toValue = layer.position.y - 100
layer.add(spring, forKey: "perceptualSpring")
layer.position.y -= 100The perceptualDuration and bounce initializer matches SwiftUI's Spring(duration:bounce:), making it easier to keep CA and SwiftUI spring behaviors consistent.
Matching SwiftUI Spring Presets
| SwiftUI Preset | CA Equivalent |
|---|---|
.smooth |
CASpringAnimation(perceptualDuration: 0.5, bounce: 0.0) |
.snappy |
CASpringAnimation(perceptualDuration: 0.4, bounce: 0.15) |
.bouncy |
CASpringAnimation(perceptualDuration: 0.5, bounce: 0.3) |
Docs: CASpringAnimation
CAAnimationGroup
CAAnimationGroup runs multiple animations concurrently on the same layer.
let scaleAnim = CABasicAnimation(keyPath: "transform.scale")
scaleAnim.fromValue = 0.5
scaleAnim.toValue = 1.0
let opacityAnim = CABasicAnimation(keyPath: "opacity")
opacityAnim.fromValue = 0.0
opacityAnim.toValue = 1.0
let group = CAAnimationGroup()
group.animations = [scaleAnim, opacityAnim]
group.duration = 0.4
group.timingFunction = CAMediaTimingFunction(name: .easeOut)
layer.add(group, forKey: "appearGroup")
layer.transform = CATransform3DIdentity
layer.opacity = 1.0Docs: CAAnimationGroup
CADisplayLink
CADisplayLink is a timer synchronized to the display's refresh rate. Use it for frame-accurate custom drawing, particle systems, or manual animation loops.
Basic Display Link
import QuartzCore
final class FrameAnimator {
private var displayLink: CADisplayLink?
private var startTime: CFTimeInterval = 0
func start() {
displayLink = CADisplayLink(target: self, selector: #selector(onFrame))
displayLink?.add(to: .main, forMode: .common)
startTime = CACurrentMediaTime()
}
func stop() {
displayLink?.invalidate()
displayLink = nil
}
@objc private func onFrame(_ link: CADisplayLink) {
let elapsed = link.timestamp - startTime
let progress = min(elapsed / 2.0, 1.0) // 2-second animation
// Update rendering based on progress
updateAnimation(progress: progress)
if progress >= 1.0 {
stop()
}
}
private func updateAnimation(progress: Double) {
// Custom per-frame rendering logic
}
}ProMotion Frame Rate Hints
On ProMotion displays, use preferredFrameRateRange to request a sustainable refresh-rate range that balances smoothness and power. Treat the values as hints: the system can choose different rates based on hardware, power, thermal state, and other onscreen animation.
displayLink?.preferredFrameRateRange = CAFrameRateRange(
minimum: 30,
maximum: 120,
preferred: 60
)| Hint | Use Case |
|---|---|
preferred: 120 |
Short, high-impact motion when the app can sustain it |
preferred: 60 |
Standard interactive animations |
preferred: 30 |
Ambient/slow animations, power saving |
Important: Always call invalidate() when done. A running CADisplayLink prevents the CPU from idling and drains battery. Drive custom animation from targetTimestamp, and scale rendering detail or work per frame to the refresh rate the system actually selects.
UIViewRepresentable Wrapper for CA Layers
To use Core Animation layers inside SwiftUI, wrap them in a UIViewRepresentable.
Animated Layer View
import SwiftUI
import QuartzCore
struct AnimatedLayerView: UIViewRepresentable {
var isAnimating: Bool
var color: Color
func makeUIView(context: Context) -> AnimatedLayerUIView {
let view = AnimatedLayerUIView()
return view
}
func updateUIView(_ uiView: AnimatedLayerUIView, context: Context) {
uiView.updateColor(UIColor(color))
if isAnimating {
uiView.startAnimation()
} else {
uiView.stopAnimation()
}
}
static func dismantleUIView(_ uiView: AnimatedLayerUIView, coordinator: ()) {
uiView.stopAnimation()
}
}The Backing UIView
final class AnimatedLayerUIView: UIView {
private let animationLayer = CAShapeLayer()
private var displayLink: CADisplayLink?
private var phase: CGFloat = 0
override init(frame: CGRect) {
super.init(frame: frame)
setupLayer()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupLayer()
}
private func setupLayer() {
animationLayer.fillColor = UIColor.systemBlue.cgColor
animationLayer.strokeColor = nil
layer.addSublayer(animationLayer)
}
override func layoutSubviews() {
super.layoutSubviews()
animationLayer.frame = bounds
updatePath()
}
func updateColor(_ color: UIColor) {
// Animate color change at the CA layer level
let animation = CABasicAnimation(keyPath: "fillColor")
animation.fromValue = animationLayer.fillColor
animation.toValue = color.cgColor
animation.duration = 0.3
animationLayer.fillColor = color.cgColor
animationLayer.add(animation, forKey: "colorChange")
}
func startAnimation() {
guard displayLink == nil else { return }
displayLink = CADisplayLink(target: self, selector: #selector(tick))
displayLink?.preferredFrameRateRange = CAFrameRateRange(
minimum: 30, maximum: 60, preferred: 60
)
displayLink?.add(to: .main, forMode: .common)
}
func stopAnimation() {
displayLink?.invalidate()
displayLink = nil
}
@objc private func tick(_ link: CADisplayLink) {
phase += 0.05
updatePath()
}
private func updatePath() {
let path = CGMutablePath()
let width = bounds.width
let height = bounds.height
let midY = height / 2
path.move(to: CGPoint(x: 0, y: midY))
for x in stride(from: 0, to: width, by: 2) {
let relativeX = x / width
let y = midY + sin((relativeX * .pi * 4) + phase) * (height * 0.3)
path.addLine(to: CGPoint(x: x, y: y))
}
path.addLine(to: CGPoint(x: width, y: height))
path.addLine(to: CGPoint(x: 0, y: height))
path.closeSubpath()
animationLayer.path = path
}
}SwiftUI Usage
struct WaveView: View {
@State private var isAnimating = true
var body: some View {
Button { isAnimating.toggle() } label: {
AnimatedLayerView(isAnimating: isAnimating, color: .blue)
.frame(height: 200)
.clipShape(.rect(cornerRadius: 16))
}
.buttonStyle(.plain)
}
}Key Rules for CA-in-SwiftUI Wrappers
- Create layers in
makeUIViewor the UIView subclass initializer, not inupdateUIView. - Stop display links in
dismantleUIViewto prevent leaks and background CPU usage. - Guard against redundant animation starts in
updateUIView-- it runs on every SwiftUI state change. - Set model values alongside CA animations so the layer state is correct after animations complete.
Bridging CA Animations with SwiftUI State
Triggering CA Animations from SwiftUI State Changes
struct PulseButton: UIViewRepresentable {
var pulseCount: Int // Increment to trigger a pulse
func makeUIView(context: Context) -> PulseUIView {
PulseUIView()
}
func updateUIView(_ uiView: PulseUIView, context: Context) {
// Only animate when pulseCount changes, not on every update
if context.coordinator.lastPulseCount != pulseCount {
context.coordinator.lastPulseCount = pulseCount
uiView.pulse()
}
}
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator {
var lastPulseCount = 0
}
}
final class PulseUIView: UIView {
private let pulseLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
pulseLayer.fillColor = UIColor.systemBlue.withAlphaComponent(0.3).cgColor
layer.addSublayer(pulseLayer)
}
required init?(coder: NSCoder) { fatalError() }
override func layoutSubviews() {
super.layoutSubviews()
let size = min(bounds.width, bounds.height)
let rect = CGRect(
x: (bounds.width - size) / 2,
y: (bounds.height - size) / 2,
width: size,
height: size
)
pulseLayer.path = UIBezierPath(ovalIn: rect).cgPath
}
func pulse() {
let scaleAnim = CABasicAnimation(keyPath: "transform.scale")
scaleAnim.fromValue = 1.0
scaleAnim.toValue = 1.5
let opacityAnim = CABasicAnimation(keyPath: "opacity")
opacityAnim.fromValue = 1.0
opacityAnim.toValue = 0.0
let group = CAAnimationGroup()
group.animations = [scaleAnim, opacityAnim]
group.duration = 0.6
group.timingFunction = CAMediaTimingFunction(name: .easeOut)
pulseLayer.add(group, forKey: "pulse")
}
}Reading CA Animation Completion in SwiftUI
Use CAAnimationDelegate on the Coordinator to report animation completion back to SwiftUI:
struct AnimatedBadge: UIViewRepresentable {
@Binding var isPresented: Bool
@Binding var isAnimationComplete: Bool
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> UIView {
let view = UIView()
let badge = CAShapeLayer()
badge.path = UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 40, height: 40)).cgPath
badge.fillColor = UIColor.systemRed.cgColor
badge.name = "badge"
view.layer.addSublayer(badge)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
context.coordinator.parent = self
if isPresented && !context.coordinator.didAnimateIn {
context.coordinator.didAnimateIn = true
animateIn(uiView, delegate: context.coordinator)
} else if !isPresented {
context.coordinator.didAnimateIn = false
}
}
private func animateIn(_ uiView: UIView, delegate: CAAnimationDelegate) {
guard let badge = uiView.layer.sublayers?.first(where: { $0.name == "badge" }) else { return }
let spring = CASpringAnimation(perceptualDuration: 0.5, bounce: 0.3)
spring.keyPath = "transform.scale"
spring.fromValue = 0.0
spring.toValue = 1.0
spring.delegate = delegate
badge.add(spring, forKey: "appear")
badge.transform = CATransform3DIdentity
}
final class Coordinator: NSObject, CAAnimationDelegate {
var parent: AnimatedBadge
var didAnimateIn = false
init(_ parent: AnimatedBadge) { self.parent = parent }
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
if flag {
parent.isAnimationComplete = true
}
}
}
}Performance Considerations
CA vs. SwiftUI Animation Performance
| Aspect | SwiftUI Animation | Core Animation |
|---|---|---|
| Rendering | View diffing + render tree | Direct layer manipulation |
| Thread | Main thread for state, render server for compositing | Same -- render server composites |
| Overhead | SwiftUI body re-evaluation per frame (for animatable) | No body re-evaluation |
| Best for | Standard UI transitions | Particle effects, wave animations, complex paths |
Guidelines
- Avoid mixing CA animations and SwiftUI animations on the same property. They use separate animation systems and will conflict.
- Use
CADisplayLinksparingly. A running display link prevents the CPU from sleeping. Always invalidate when not needed. - Prefer
CAShapeLayerfor path-based animations over redrawing indraw(_:). Shape layers are GPU-accelerated. - Set
shouldRasterize = trueon complex static sublayer trees to cache them as bitmaps, but disable it during animation (rasterization prevents smooth per-frame updates). - Match CA spring parameters to SwiftUI springs using the
perceptualDuration:bounce:initializer so animations feel consistent across the bridge boundary.