`@Animatable` Macro: Auto-Synthesized Animation Data for Custom Shapes


If you have ever written a custom Shape that animates between states, you know the ritual: define an AnimatablePair (or a nested chain of them), implement the animatableData computed property, and manually map each component back to its stored properties. For two properties it is tedious. For four it is hostile. iOS 26 eliminates this ceremony with a single annotation.

This post covers the new @Animatable macro, how it synthesizes animatableData for you, how @AnimatableIgnored opts properties out of animation, and where the macro does and does not apply. We will not cover general animation APIs or the Shape protocol from scratch — those are covered in SwiftUI Animations and Custom Shapes in SwiftUI.

Contents

The Problem

Consider a shape that draws a star for a movie-rating overlay. The star needs to animate both its number of points and its inner-radius ratio so the UI can morph smoothly between rating states.

struct StarShape: Shape {
    var points: Double
    var innerRadiusRatio: Double

    // Manual animatableData for TWO properties
    var animatableData: AnimatablePair<Double, Double> {
        get { AnimatablePair(points, innerRadiusRatio) }
        set {
            points = newValue.first
            innerRadiusRatio = newValue.second
        }
    }

    func path(in rect: CGRect) -> Path {
        // ... star-drawing geometry
    }
}

Two properties, eight lines of boilerplate. It gets worse fast. Suppose the shape also animates a rotation angle and a stroke thickness — now you are nesting AnimatablePair<AnimatablePair<Double, Double>, AnimatablePair<Double, Double>>, and the getter/setter turns into a tree traversal. This scaling problem is not theoretical; any non-trivial custom shape hits it the moment a designer asks for one more animatable parameter.

The root cause is that Shape requires a single animatableData value conforming to VectorArithmetic, but most shapes have multiple independent properties to animate. The framework gives you AnimatablePair to compose them, but the composition is manual and error-prone.

Enter @Animatable

Starting with iOS 26, the @Animatable macro auto-synthesizes the animatableData property for any type conforming to Animatable — including Shape, which inherits the conformance.

import SwiftUI

@available(iOS 26.0, *)
@Animatable
struct StarShape: Shape {
    var points: Double
    var innerRadiusRatio: Double

    func path(in rect: CGRect) -> Path {
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let outerRadius = min(rect.width, rect.height) / 2
        let innerRadius = outerRadius * innerRadiusRatio
        let totalPoints = Int(points.rounded(.up)) * 2
        var path = Path()

        for i in 0..<totalPoints {
            let angle = (Double(i) * .pi / points) - .pi / 2
            let radius = i.isMultiple(of: 2) ? outerRadius : innerRadius
            let point = CGPoint(
                x: center.x + CGFloat(cos(angle)) * radius,
                y: center.y + CGFloat(sin(angle)) * radius
            )
            i == 0 ? path.move(to: point) : path.addLine(to: point)
        }
        path.closeSubpath()
        return path
    }
}

That is the entire shape. No animatableData property, no AnimatablePair, no getter/setter juggling. The macro inspects every stored property whose type conforms to VectorArithmetic and synthesizes the correct composite data at compile time. When you use this shape inside a withAnimation block, SwiftUI interpolates points and innerRadiusRatio automatically.

@available(iOS 26.0, *)
struct MovieRatingView: View {
    @State private var isFiveStarRating = false

    var body: some View {
        VStack {
            StarShape(
                points: isFiveStarRating ? 5 : 3,
                innerRadiusRatio: isFiveStarRating ? 0.4 : 0.6
            )
            .fill(.yellow)
            .frame(width: 120, height: 120)

            Button("Toggle Rating") {
                withAnimation(.spring(duration: 0.6)) {
                    isFiveStarRating.toggle()
                }
            }
        }
    }
}

The star smoothly morphs from a three-pointed shape to a five-pointed one, with the inner radius shrinking simultaneously. All the interpolation plumbing is handled by the synthesized code.

Apple Docs: Animatable — SwiftUI

What the Macro Generates

Under the hood, @Animatable expands to roughly the same animatableData computed property you would write by hand. For a type with properties a: Double and b: Double, the expansion is equivalent to:

var animatableData: AnimatablePair<Double, Double> {
    get { AnimatablePair(a, b) }
    set {
        a = newValue.first
        b = newValue.second
    }
}

For three or more properties, the macro nests AnimatablePair values just as you would manually, but without you touching any of it. This is pure compile-time code generation with zero runtime cost beyond what the hand-written version would incur.

Tip: You can inspect the macro expansion in Xcode by right-clicking the @Animatable annotation and choosing Expand Macro. This is useful when debugging unexpected animation behavior.

Excluding Properties with @AnimatableIgnored

Not every stored property on a shape should participate in animation. A color identifier, a label string, or a cached computed value has no meaningful interpolation. Mark these with @AnimatableIgnored and the macro skips them during synthesis.

@available(iOS 26.0, *)
@Animatable
struct CharacterBadgeShape: Shape {
    var cornerRadius: Double
    var insetAmount: Double

    @AnimatableIgnored
    var characterName: String // No interpolation for strings

    @AnimatableIgnored
    var isSidekick: Bool // No interpolation for booleans

    func path(in rect: CGRect) -> Path {
        let insetRect = rect.insetBy(dx: insetAmount, dy: insetAmount)
        return Path(
            roundedRect: insetRect,
            cornerRadius: cornerRadius
        )
    }
}

Here only cornerRadius and insetAmount are interpolated during animation. The characterName and isSidekick properties snap to their final values immediately, which is exactly what you want for non-numeric data.

Warning: If you forget @AnimatableIgnored on a property whose type does not conform to VectorArithmetic, the macro will produce a compile-time error. The error message is clear — it points directly to the offending property — but it is worth knowing that the default behavior is opt-in for all stored properties, not opt-out.

Advanced Usage

Animating CGFloat and Double Together

The macro handles any stored property whose type conforms to VectorArithmetic. Both Double and CGFloat conform, and so do AnimatablePair and SIMD types. This means you can mix numeric types freely.

@available(iOS 26.0, *)
@Animatable
struct WaveShape: Shape {
    var amplitude: CGFloat
    var frequency: Double
    var phase: Double

    func path(in rect: CGRect) -> Path {
        var path = Path()
        let width = rect.width
        let height = rect.height
        let midY = height / 2

        path.move(to: CGPoint(x: 0, y: midY))
        for x in stride(from: 0, through: width, by: 1) {
            let relativeX = Double(x / width)
            let sine = sin((relativeX * frequency * .pi * 2) + phase)
            let y = midY + amplitude * CGFloat(sine)
            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()
        return path
    }
}

All three properties — amplitude (CGFloat), frequency (Double), and phase (Double) — are synthesized into the composite animatableData without any manual intervention.

Combining with Existing Animatable Conformances

If your type already conforms to Animatable with a hand-written animatableData, adding @Animatable will cause a conflict. The macro does not override existing conformances; it only synthesizes when animatableData is absent. This is by design — it lets you adopt the macro incrementally across a codebase without breaking shapes that already have custom implementations.

// This will NOT compile -- conflicting animatableData
@available(iOS 26.0, *)
@Animatable
struct ConflictShape: Shape {
    var radius: Double

    var animatableData: Double {
        get { radius }
        set { radius = newValue }
    }

    func path(in rect: CGRect) -> Path { /* ... */ }
}

The fix is straightforward: remove the manual animatableData and let the macro handle it, or remove the macro and keep your hand-written implementation. Pick one.

Using @Animatable on Non-Shape Types

The macro is not limited to Shape. Any type conforming to Animatable can use it. This includes custom ViewModifier implementations and GeometryEffect types, both of which conform to Animatable.

@available(iOS 26.0, *)
@Animatable
struct PixarBounceEffect: GeometryEffect {
    var bounceHeight: CGFloat
    var squashFactor: CGFloat

    func effectValue(size: CGSize) -> ProjectionTransform {
        let translation = CGAffineTransform(
            translationX: 0,
            y: -bounceHeight
        )
        let scale = CGAffineTransform(
            scaleX: 1.0 + squashFactor,
            y: 1.0 - squashFactor
        )
        return ProjectionTransform(translation.concatenating(scale))
    }
}

This GeometryEffect animates two properties simultaneously — the bounce offset and the squash deformation — with zero boilerplate. Attach it to any view and drive the values with withAnimation.

Note: The @Animatable macro requires iOS 26 / macOS 26 / watchOS 12 / tvOS 26 / visionOS 3. If you need to support earlier deployment targets, you must continue using manual animatableData implementations.

Performance Considerations

The @Animatable macro is a compile-time expansion. It generates the same code you would write by hand, which means the runtime performance is identical. There is no reflection, no dynamic dispatch overhead, and no additional allocations beyond what AnimatablePair itself requires.

That said, the number of animatable properties directly affects interpolation cost per frame. Each property is independently interpolated by SwiftUI on every animation frame. For most shapes, two to four properties is perfectly fine. If you find yourself with eight or more animatable properties on a single shape, consider whether some of them can be derived from others or batched into a single parameter.

Profile animation performance using the Core Animation instrument in Instruments. Look for dropped frames during shape animations and check whether path(in:) is the bottleneck or the interpolation itself.

Tip: If path(in:) is expensive, the real optimization is simplifying your path geometry or moving to Canvas for direct draw calls. The macro does not change the number of times path(in:) is called — SwiftUI still invokes it on every interpolated frame.

When to Use (and When Not To)

Use @Animatable when:

  • Your custom Shape has two or more animatable properties. This is the macro’s primary use case and eliminates all boilerplate.
  • Your custom GeometryEffect has multiple parameters. Same benefits apply.
  • Your shape has a single animatable property. Optional, but the macro still saves a few lines.

Avoid @Animatable when:

  • Your shape needs complex animation logic such as custom easing per property. You need manual control over how animatableData maps to properties.
  • Your deployment target is below iOS 26. The macro is not available. Use manual AnimatablePair composition.
  • Your type already has a hand-written animatableData. Remove the manual implementation first, or skip the macro. They cannot coexist.

Summary

  • @Animatable eliminates the manual animatableData boilerplate for custom Shape, GeometryEffect, and other Animatable types in iOS 26.
  • The macro inspects all stored properties conforming to VectorArithmetic and synthesizes the correct AnimatablePair composition at compile time.
  • Use @AnimatableIgnored to exclude properties that should not be interpolated — strings, booleans, or any non-VectorArithmetic type.
  • Runtime performance is identical to a hand-written implementation because the macro is pure compile-time code generation.
  • The macro cannot coexist with a manual animatableData property — use one or the other, not both.

For a deeper look at how Swift macros work under the hood, including writing your own, see Swift Macros. If you want to push custom rendering further with GPU-powered effects, check out Custom Visual Effects with visualEffect and Metal Shaders.