CtrlK
BlogDocsLog inGet started
Tessl Logo

dpearson2699/swift-ios-skills

Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.

71

Quality

89%

Does it follow best practices?

Impact

No eval scenarios have been run

SecuritybySnyk

Advisory

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

core-animation-bridge.mdskills/swiftui-animation/references/

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, unsupported animation curves, or direct CALayer manipulation. Overflow reference for the swiftui-animation skill.

Contents

When to Drop Below SwiftUI Animations

SwiftUI's animation system covers most use cases. Drop to Core Animation only when:

ScenarioWhy CA Is Needed
Custom timing functions beyond spring/easeCAMediaTimingFunction supports arbitrary cubic Bezier curves
Layer-specific properties (shadowPath, borderWidth, etc.)SwiftUI does not expose all CALayer animatable properties
Additive animationsCA supports additive blending of multiple concurrent animations on the same property
Frame-synchronized drawingCADisplayLink provides precise frame timing for custom rendering
Performance-critical particle/effectsDirect layer manipulation avoids SwiftUI's diffing overhead
Animation along a pathCAKeyframeAnimation supports CGPath-based animation paths

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 value

Custom Bezier Timing

// Custom cubic Bezier curve -- not available in SwiftUI
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

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 = CATransform3DIdentity

Perceptual 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 -= 100

The 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 PresetCA Equivalent
.smoothCASpringAnimation(perceptualDuration: 0.5, bounce: 0.0)
.snappyCASpringAnimation(perceptualDuration: 0.4, bounce: 0.15)
.bouncyCASpringAnimation(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.0

Docs: 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 Control

On ProMotion displays (120 Hz), use preferredFrameRateRange to balance smoothness and power:

displayLink?.preferredFrameRateRange = CAFrameRateRange(
    minimum: 30,
    maximum: 120,
    preferred: 60
)
RangeUse Case
preferred: 120Smooth scrolling, gesture tracking
preferred: 60Standard animations
preferred: 30Ambient/slow animations, power saving

Important: Always call invalidate() when done. A running CADisplayLink prevents the CPU from idling and drains battery.

Docs: CADisplayLink | Optimizing ProMotion refresh rates

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

  1. Create layers in makeUIView or the UIView subclass initializer, not in updateUIView.
  2. Stop display links in dismantleUIView to prevent leaks and background CPU usage.
  3. Guard against redundant animation starts in updateUIView -- it runs on every SwiftUI state change.
  4. 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 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) {}

    func animateIn(_ uiView: UIView) {
        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 = uiView.next as? CAAnimationDelegate

        badge.add(spring, forKey: "appear")
        badge.transform = CATransform3DIdentity
    }

    final class Coordinator: NSObject, CAAnimationDelegate {
        var parent: AnimatedBadge

        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

AspectSwiftUI AnimationCore Animation
RenderingView diffing + render treeDirect layer manipulation
ThreadMain thread for state, render server for compositingSame -- render server composites
OverheadSwiftUI body re-evaluation per frame (for animatable)No body re-evaluation
Best forStandard UI transitionsParticle 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 CADisplayLink sparingly. A running display link prevents the CPU from sleeping. Always invalidate when not needed.
  • Prefer CAShapeLayer for path-based animations over redrawing in draw(_:). Shape layers are GPU-accelerated.
  • Set shouldRasterize = true on 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.

skills

CHANGELOG.md

README.md

tile.json