RSS Feed ↗
VOL. 1 • NO. 14 • IOSBUFFET.COM OCT 25, 2026
Inside Page — A12 Continued from Front
The Daily Recipe
Oct 7, 2026 • Page A12

Custom Animated Shapes with @Animatable

Animating a custom shape used to mean hand-writing nested AnimatablePair boilerplate. With the @Animatable macro in Xcode 26 you list the properties and the macro writes the rest.

By M. HOSSAIN — Staff Writer — Oct 7, 2026 2040 words
Recipe Card — No. 6
Serves
Xcode 26 · the gauge runs on iOS 17+, the glass buttons need iOS 26
Prep time
20 minutes
Kitchen-tested
Compiled with Xcode 26.6 · iOS 26.5 simulator · Oct 26, 2026
Difficulty
★★☆
Ingredients
  • A custom Shape
  • The @Animatable macro
  • @AnimatableIgnored
  • PhaseAnimator(_:trigger:content:animation:)
  • TimelineView(.animation)
  • @Environment(\.accessibilityReduceMotion)

SwiftUI animates a shape by interpolating one value, animatableData, between the old shape and the new one. For a shape with one changing number that’s easy. For a shape with three, you had to pack them into nested AnimatablePairs and unpack them again in a setter, and every new property meant editing that tower of generics. Xcode 26 adds the @Animatable macro, which reads your shape’s stored properties and writes animatableData for you.

This recipe makes a batter gauge for a baking app: a round bowl whose batter level animates when you add or pour, sloshes on every change, and keeps a gentle ripple moving the rest of the time.

Method

1. Draw the wave

The gauge is built from a wave shape: a sine curve at a given fill level, closed off along the bottom. The drawing code is shared by both versions of the shape below, so it lives in a function. It’s marked nonisolated because Shape.path(in:) is a nonisolated requirement and calls it; more on that in step 3.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// A sine-wave water line at `level` (0 = empty, 1 = full), filled to the bottom.
nonisolated func wavePath(
    in rect: CGRect, level: CGFloat, amplitude: CGFloat, phase: CGFloat, crests: Int
) -> Path {
    var path = Path()
    let baseline = rect.maxY - rect.height * level
    let wavelength = rect.width / CGFloat(crests)

    path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
    for x in stride(from: rect.minX, through: rect.maxX, by: 2) {
        let angle = (x - rect.minX) / wavelength * 2 * .pi + phase
        path.addLine(to: CGPoint(x: x, y: baseline + sin(angle) * amplitude))
    }
    path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
    path.closeSubpath()
    return path
}

2. The old way, for comparison

Before the macro, animating both the level and the wave height meant writing animatableData by hand. Two properties need one AnimatablePair; three would need a pair inside a pair.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
nonisolated struct WaveWithoutMacro: Shape {
    var level: CGFloat
    var amplitude: CGFloat
    var phase: CGFloat = 0
    var crests = 2

    var animatableData: AnimatablePair<CGFloat, CGFloat> {
        get { AnimatablePair(level, amplitude) }
        set {
            level = newValue.first
            amplitude = newValue.second
        }
    }

    func path(in rect: CGRect) -> Path {
        wavePath(in: rect, level: level, amplitude: amplitude, phase: phase, crests: crests)
    }
}

3. The new way

With @Animatable, the getter and setter disappear. The macro builds animatableData from the stored properties, and @AnimatableIgnored leaves a property out. Here two properties are left out, for different reasons. crests is an Int, which can’t be interpolated smoothly. phase could be, but it’s driven by a clock (step 4), and if it were animatable, every level change would also try to tween the ripple from its old position to its new one.

1
2
3
4
5
6
7
8
9
10
11
@Animatable
nonisolated struct Wave: Shape {
    var level: CGFloat
    var amplitude: CGFloat
    @AnimatableIgnored var phase: CGFloat
    @AnimatableIgnored var crests = 2

    func path(in rect: CGRect) -> Path {
        wavePath(in: rect, level: level, amplitude: amplitude, phase: phase, crests: crests)
    }
}

About that nonisolated. SwiftUI declares Shape as a nonisolated protocol that refines Sendable, with a nonisolated path(in:): shapes are plain values that SwiftUI can ask for a path wherever it’s rendering. A new Xcode 26 project isolates code to the main actor by default, so mark custom shapes nonisolated to match the protocol.

4. Make it slosh and ripple

Three kinds of motion meet in the gauge, and each comes from a different place:

  • The ripple runs constantly. A TimelineView(.animation) redraws every frame and computes phase from the clock, which loops seamlessly because the wave repeats every 2π. It pauses when Reduce Motion is on.
  • The slosh happens on every level change. PhaseAnimator steps a slosh value through 0, 1, 0.4 and back to 0 whenever level changes, and the wave’s amplitude follows it. That only animates smoothly because amplitude is part of the macro-generated animatableData.
  • The level itself springs to its new value through an ordinary .animation(_:value:).

The views keep level as a Double, which Swift converts to the shape’s CGFloat automatically, so the percentage label can use the standard .percent format.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
struct BatterGauge: View {
    var level: Double
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    var body: some View {
        PhaseAnimator([0, 1, 0.4, 0] as [CGFloat], trigger: level) { slosh in
            TimelineView(.animation(paused: reduceMotion)) { context in
                let seconds = context.date.timeIntervalSinceReferenceDate
                let phase = CGFloat(seconds.truncatingRemainder(dividingBy: 2) * .pi)

                ZStack {
                    Wave(level: level, amplitude: 4 + 12 * slosh, phase: phase)
                        .fill(.orange.opacity(0.4))
                    Wave(level: level, amplitude: 3 + 9 * slosh, phase: phase + .pi, crests: 3)
                        .fill(.orange)
                }
            }
        } animation: { _ in
            .spring(duration: 0.4)
        }
        .animation(.spring(duration: 0.8), value: level)
        .background(.orange.opacity(0.08))
        .clipShape(.circle)
        .overlay {
            Circle().stroke(.orange, lineWidth: 4)
        }
        .overlay {
            Text(level, format: .percent.precision(.fractionLength(0)))
                .font(.system(size: 44, weight: .bold, design: .rounded))
                .monospacedDigit()
                .contentTransition(.numericText(value: level))
                .shadow(color: .white.opacity(0.8), radius: 6)
        }
    }
}

5. Put it on a screen

Two glass buttons change the level in steps of 20 percent:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct MixingBowlScreen: View {
    @State private var level = 0.35

    var body: some View {
        VStack(spacing: 32) {
            Text("Batter")
                .font(.title2.bold())

            BatterGauge(level: level)
                .frame(width: 240, height: 240)

            HStack(spacing: 16) {
                Button("Pour Out", systemImage: "minus") {
                    level = max(level - 0.2, 0)
                }
                Button("Add Batter", systemImage: "plus") {
                    level = min(level + 0.2, 1)
                }
            }
            .buttonStyle(.glass)
        }
    }
}

Plating

The bowl opens about a third full, with two layers of orange batter rippling slowly against each other. Tap Add Batter and the level springs up while the surface sloshes higher, then settles back to its ripple; the percentage counts up with the numeric text transition. Pour Out does the same in reverse. Turn on Reduce Motion and the ripple stops, while the level changes still animate.

35 percent
35 percent
75 percent
75 percent
FIG. 1 — The gauge at 35 percent, and after two taps of Add Batter.

Kitchen notes

  • The macro works with stored properties only. Computed properties are skipped, so derive values inside path(in:) rather than storing them twice.
  • Use @AnimatableIgnored for anything that shouldn’t be interpolated: counts, flags, enum cases, and values that come from somewhere else, like the clock-driven phase here.
  • If a type already conforms to Animatable, the macro doesn’t add the conformance again, but it still writes animatableData. Shape already conforms, and Apple’s own example of the macro is a shape.
  • Apple lists the macro as available back to iOS 13. It runs at compile time, so it needs Xcode 26, not iOS 26 on the device. The gauge here needs iOS 17 for PhaseAnimator, and the glass buttons need iOS 26.
  • For motion that never stops, a TimelineView that computes values from the date is easier to reason about than a repeatForever animation. A repeating animation and a one-off spring on the same shape would both be animating the same animatableData, which makes the result hard to predict.

— 30 —

— END OF EDITION 2026 — SET IN GARAMOND & COURIER ON NEWSPRINT STOCK — iOS BUFFET PRESS — RSS —