Skip to main content
3Nsofts logo3Nsofts
SwiftUI

SwiftUI Animation in 2026: Phase Animators, Keyframes, and Core Animation

Choose the right animation layer for a production SwiftUI app: ordinary state animation, PhaseAnimator, KeyframeAnimator, or a focused Core Animation bridge.

By Ehsan Azish · 3NSOFTS·August 2026·10 min read

SwiftUI now covers most interface animation without requiring direct layer code. The production decision is no longer simply "SwiftUI or Core Animation." It is a ladder: start with an ordinary state-driven animation, move to discrete phases when the interaction has named steps, use keyframes when properties need an explicit timeline, and bridge to Core Animation only for capabilities the SwiftUI layer does not expose.

That order matters. Starting too low creates imperative state that fights SwiftUI updates. Staying too high can produce fragile workarounds, repeated layout, or animation that cannot synchronize with the system it represents.

The Four Layers to Consider

1. State-driven animation

Use withAnimation or .animation(_:value:) when one state change should interpolate to another:

struct DisclosureCard: View {
    @State private var isExpanded = false

    var body: some View {
        CardContent(isExpanded: isExpanded)
            .scaleEffect(isExpanded ? 1 : 0.96)
            .opacity(isExpanded ? 1 : 0.7)
            .onTapGesture {
                withAnimation(.snappy) {
                    isExpanded.toggle()
                }
            }
    }
}

This is the best default for selection, disclosure, insertion, and other transitions with two meaningful states. Do not introduce a timeline API merely because the motion uses more than one modifier.

2. PhaseAnimator

A phase animator models a finite sequence of discrete states. SwiftUI advances through the phases and applies the animation selected for each transition.

private enum SavePhase: CaseIterable {
    case idle, compress, settle

    var scale: Double {
        switch self {
        case .idle: 1
        case .compress: 0.88
        case .settle: 1.05
        }
    }
}

struct SaveConfirmation: View {
    @State private var saveCount = 0

    var body: some View {
        Image(systemName: "checkmark.circle.fill")
            .phaseAnimator(SavePhase.allCases, trigger: saveCount) { content, phase in
                content.scaleEffect(phase.scale)
            } animation: { phase in
                switch phase {
                case .idle: .smooth
                case .compress: .easeIn(duration: 0.12)
                case .settle: .spring(duration: 0.35, bounce: 0.35)
                }
            }
            .onTapGesture { saveCount += 1 }
    }
}

Use phases for a pulse, a status transition, a short onboarding beat, or feedback that has a small set of named visual states. Prefer an enum over raw numbers: it makes the sequence readable and prevents animation values from becoming unexplained constants.

Phase animation is not a substitute for a precise timeline. It tells SwiftUI which states to visit, not the exact value of every property at an arbitrary timestamp.

3. KeyframeAnimator

A keyframe animator is appropriate when multiple properties need independent tracks over a coordinated timeline. SwiftUI evaluates the content closure on every animation frame, so keep that closure limited to inexpensive visual modifiers.

private struct FeedbackValues {
    var scale = 1.0
    var rotation = Angle.zero
    var verticalOffset = 0.0
}

struct KeyframedFeedback: View {
    @State private var trigger = 0

    var body: some View {
        Image(systemName: "star.fill")
            .keyframeAnimator(
                initialValue: FeedbackValues(),
                trigger: trigger
            ) { content, value in
                content
                    .scaleEffect(value.scale)
                    .rotationEffect(value.rotation)
                    .offset(y: value.verticalOffset)
            } keyframes: { _ in
                KeyframeTrack(\.scale) {
                    SpringKeyframe(1.25, duration: 0.28, spring: .bouncy)
                    CubicKeyframe(1, duration: 0.2)
                }
                KeyframeTrack(\.rotation) {
                    CubicKeyframe(.degrees(10), duration: 0.18)
                    CubicKeyframe(.zero, duration: 0.3)
                }
                KeyframeTrack(\.verticalOffset) {
                    CubicKeyframe(-12, duration: 0.2)
                    SpringKeyframe(0, duration: 0.28, spring: .bouncy)
                }
            }
            .onTapGesture { trigger += 1 }
    }
}

Apple provides four useful keyframe types:

  • LinearKeyframe for constant interpolation to the next value
  • CubicKeyframe for a smooth cubic curve
  • SpringKeyframe for spring-based interpolation
  • MoveKeyframe for an immediate value change without interpolation

Use keyframes for coordinated feedback, a composed icon transition, or a short celebratory sequence. Avoid disk access, decoding, model work, object allocation, or complex geometry calculation inside the per-frame content closure.

4. Core Animation through a narrow bridge

Core Animation remains useful, but the reason should be concrete. Good candidates include:

  • animating a CAShapeLayer path or strokeEnd directly
  • inspecting a presentation-layer value during an interruption
  • coordinating layer time with an external media clock
  • maintaining an existing UIKit or AppKit component whose layer animation is already correct
  • using a specialized rendering view that SwiftUI does not expose

Core Animation does not make all setup work independent of the main thread. Your app still creates layers, changes model-layer values, and commits transactions through its UI pipeline. After commit, the render server can composite eligible properties efficiently. That distinction is why a simple transform may remain smooth while layout, drawing, or per-frame application work still stutters.

A Safe Bridging Pattern

Keep the imperative animation inside a representable and make SwiftUI state the input:

struct RingProgressView: UIViewRepresentable {
    let progress: CGFloat

    func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        let ring = CAShapeLayer()
        ring.fillColor = UIColor.clear.cgColor
        ring.strokeColor = UIColor.systemBlue.cgColor
        ring.lineWidth = 6
        ring.strokeEnd = 0
        view.layer.addSublayer(ring)
        context.coordinator.ring = ring
        return view
    }

    func updateUIView(_ view: UIView, context: Context) {
        guard let ring = context.coordinator.ring else { return }
        let previous = ring.presentation()?.strokeEnd ?? ring.strokeEnd
        ring.strokeEnd = progress

        let animation = CABasicAnimation(keyPath: "strokeEnd")
        animation.fromValue = previous
        animation.toValue = progress
        animation.duration = 0.3
        animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
        ring.add(animation, forKey: "progress")
    }

    final class Coordinator {
        var ring: CAShapeLayer?
    }
}

A production component also updates the layer path in layoutSubviews, avoids replaying an identical animation during unrelated SwiftUI updates, and handles initial state without animating from a stale value. The important boundary is architectural: SwiftUI owns the desired progress, while the bridge owns layer mechanics.

Performance: Animate Rendering, Not Work

Animation performance problems usually come from the update chain around the animation rather than the curve itself.

Prefer properties that can be represented as transforms, opacity, clipping, or other inexpensive visual changes. Be cautious when animation changes intrinsic size, text layout, alignment guides, or geometry that invalidates a large hierarchy.

For keyframes, remember that the content closure runs every frame. Keep the animated value small and composed of animatable value types. Resolve images, strings, paths, and business state before the timeline begins.

For continuously changing data such as an audio meter, separate sampling from display. Throttle or aggregate high-rate input to the visual rate the interface actually needs. If the requirement is a dense waveform or particle field, a drawing or rendering API may fit better than hundreds of independently updating SwiftUI views.

Profile the release build on a physical device with Instruments. Check CPU work, hangs, and animation hitches together. A simulator is useful for layout iteration but does not reproduce device thermal behavior, refresh rate, or graphics scheduling reliably.

Interruption and State Ownership

An animation can be visually correct and still be architecturally wrong. The source of truth should describe product state, not animation progress. A save operation owns states such as idle, saving, succeeded, and failed; the view derives motion from those states.

Do not use delayed callbacks as a hidden state machine. Cancellation, rapid repeated input, navigation, and scene changes will expose the mismatch. Trigger-based phase and keyframe APIs are useful because a new value creates an explicit replay boundary.

When precise mid-flight continuity is essential, Core Animation's presentation layer can provide the currently displayed layer value. Treat that as a rendering detail, then update the model layer to the final value so the visual state does not snap back when the animation is removed.

Accessibility Is Part of the Animation Contract

Motion should communicate state without becoming the only way state is communicated. Respect Reduce Motion:

@Environment(\.accessibilityReduceMotion) private var reduceMotion

private var feedbackAnimation: Animation? {
    reduceMotion ? .easeOut(duration: 0.12) : .spring(duration: 0.4, bounce: 0.3)
}

For large spatial movement, repeated pulses, parallax, or zoom, provide a restrained alternative. Opacity, color, symbol changes, and immediate state transitions can preserve meaning with less motion. Also test Reduce Transparency, Increase Contrast, Dynamic Type, and VoiceOver focus; an animation should not move the active control away from the user's focus unexpectedly.

Decision Checklist

Use ordinary state animation when:

  • one product-state transition drives the effect
  • one curve can describe the movement
  • interruption should naturally follow state changes

Use PhaseAnimator when:

  • the effect has a small, named sequence of discrete states
  • each transition benefits from its own animation
  • the sequence loops or replays from a trigger

Use KeyframeAnimator when:

  • several properties need independent tracks
  • timing relationships matter more than named phases
  • the per-frame content closure can remain inexpensive

Bridge to Core Animation when:

  • the requirement depends on a layer capability SwiftUI does not expose
  • you need presentation-layer inspection or explicit layer timing
  • an existing UIKit or AppKit rendering component is the correct owner

Production Architecture

Keep reusable motion in small modifiers or dedicated views, but do not build an animation framework before the product has repeated patterns. A named SaveConfirmation, LoadingPulseModifier, or RingProgressView is easier to understand and test than a generic timeline abstraction with configuration dictionaries.

Animation triggers should come from observable feature state. Heavy work belongs in services or actors, not in view bodies or animation callbacks. The production SwiftUI architecture guide covers state ownership and observation boundaries; the iOS performance optimization guide covers the profiling workflow when frames still drop.

References

Authoritative References