Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.
71
89%
Does it follow best practices?
Impact
—
No eval scenarios have been run
Advisory
Suggest reviewing before use
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.
SwiftUI's animation system covers most use cases. Drop to Core Animation only when:
| Scenario | Why CA Is Needed |
|---|---|
| Custom timing functions beyond spring/ease | CAMediaTimingFunction supports arbitrary cubic Bezier curves |
| 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 |
If SwiftUI's withAnimation, PhaseAnimator, or KeyframeAnimator can achieve the effect, prefer them. Core Animation bridging adds complexity and requires explicit UIViewRepresentable wrappers.
CABasicAnimation interpolates a single layer property between two values.
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 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")// 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.
CAKeyframeAnimation animates a property through a sequence of values or along a path.
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")// 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)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 applies spring physics to a layer property. It extends CABasicAnimation with physical spring attributes.
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 = CATransform3DIdentitylet 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.
| 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 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 is a timer synchronized to the display's refresh rate. Use it for frame-accurate custom drawing, particle systems, or manual animation loops.
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
}
}On ProMotion displays (120 Hz), use preferredFrameRateRange to balance smoothness and power:
displayLink?.preferredFrameRateRange = CAFrameRateRange(
minimum: 30,
maximum: 120,
preferred: 60
)| Range | Use Case |
|---|---|
preferred: 120 | Smooth scrolling, gesture tracking |
preferred: 60 | Standard 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.
To use Core Animation layers inside SwiftUI, wrap them in a UIViewRepresentable.
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()
}
}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
}
}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)
}
}makeUIView or the UIView subclass initializer, not in updateUIView.dismantleUIView to prevent leaks and background CPU usage.updateUIView -- it runs on every SwiftUI state change.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")
}
}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
}
}
}
}| 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 |
CADisplayLink sparingly. A running display link prevents the CPU from sleeping. Always invalidate when not needed.CAShapeLayer for path-based animations over redrawing in draw(_:). Shape layers are GPU-accelerated.shouldRasterize = true on complex static sublayer trees to cache them as bitmaps, but disable it during animation (rasterization prevents smooth per-frame updates).perceptualDuration:bounce: initializer so animations feel consistent across the bridge boundary.skills
accessorysetupkit
references
activitykit
references
adattributionkit
references
alarmkit
references
app-clips
app-intents
references
app-store-optimization
app-store-review
apple-on-device-ai
appmigrationkit
references
audioaccessorykit
references
authentication
references
avkit
references
background-processing
references
browserenginekit
references
callkit
references
carplay
references
cloudkit
references
contacts-framework
references
core-bluetooth
references
core-data
core-motion
references
core-nfc
references
coreml
references
cryptokit
references
cryptotokenkit
references
debugging-instruments
device-integrity
references
dockkit
references
energykit
references
eventkit
references
financekit
references
focus-engine
gamekit
references
healthkit
references
homekit
references
ios-accessibility
ios-localization
ios-networking
ios-simulator
references
mapkit
metrickit
references
musickit
references
natural-language
references
paperkit
references
passkit
references
pdfkit
references
pencilkit
references
permissionkit
references
photokit
push-notifications
realitykit
references
relevancekit
references
scenekit
references
sensorkit
references
speech-recognition
spritekit
references
storekit
swift-api-design-guidelines
swift-architecture
swift-charts
references
swift-codable
swift-concurrency
swift-formatstyle
swift-language
swift-security
references
swift-testing
swiftdata
swiftlint
swiftui-animation
swiftui-gestures
references
swiftui-layout-components
swiftui-liquid-glass
references
swiftui-patterns
swiftui-performance
swiftui-uikit-interop
swiftui-webkit
tabletopkit
references
tipkit
references
vision-framework
weatherkit
references
widgetkit
references