Skip to content

The Ring That Wouldn't Shrink: Building Musklr's Rest Timer Animation in SwiftUI

Musklr's rest timer ring morphing between full screen and the stats bar, built with SwiftUI matchedGeometryEffect.

matchedGeometryEffect animates the frame it proposes, and a view that draws itself at a fixed size ignores that proposal, so only its position moves. Wrap both endpoints in a GeometryReader that reads the proposed size and applies scaleEffect, and the ring genuinely shrinks from 220 points to 50 as it flies.

Finish a set in Musklr and a full screen rest timer takes over. A big countdown ring, a dark scrim, nothing else competing for your attention. Tap anywhere and the timer gets out of your way. Until version 1.180 it faded out. Now the ring shrinks, flies to the corner and lands in the little timer slot in the stats bar, as one continuous motion.

Tap anywhere to minimize. The countdown flies home to the stats bar.

It is a detail most people will never consciously notice. We think that is exactly the point. An interface that moves like a physical thing feels trustworthy in a way you cannot fake with feature lists. This post is the story of building that detail: the SwiftUI hero animation pattern we used, the bug that made our first version secretly fake, and a playground you can paste into Xcode to re-live both.

What makes two SwiftUI views good endpoints for a hero animation?

Musklr already had both endpoints on screen. The rest timer popup draws a 220 point countdown ring, and the stats bar shows a 50 point mini ring when a timer is running. Both are the same TimerRingView under the hood, which makes them perfect endpoints for a hero animation: dismiss the popup and the big ring should physically become the small one. Tap the mini timer and the same flight should run in reverse.

SwiftUI has a built-in tool for exactly this: matchedGeometryEffect. Tag a view in scene A and a view in scene B with the same id and namespace, toggle between them inside withAnimation, and SwiftUI animates the frame from one to the other. That is the theory. Here is a version you can run.

How do you try a SwiftUI hero animation in an Xcode playground?

You do not need a project for this. Open Xcode, File, New, Playground, pick the iOS Blank template, and paste this file. It is a simplified version of our real setup: a fixed size ring, a full screen "popup" state and a corner "mini" state, tagged with matchedGeometryEffect and toggled with a spring.

import SwiftUI
import PlaygroundSupport

/// A countdown ring that draws at ONE fixed size.
/// This fixed frame is exactly what breaks the hero morph below.
struct TimerRing: View {
  let size: CGFloat
  let lineWidth: CGFloat

  var body: some View {
    ZStack {
      Circle()
        .stroke(.gray.opacity(0.25), lineWidth: lineWidth)
      Circle()
        .trim(from: 0, to: 0.62)
        .stroke(.cyan, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
        .rotationEffect(.degrees(-90))
      Text("0:45")
        .font(.system(size: size * 0.24, weight: .semibold, design: .rounded))
        .monospacedDigit()
    }
    .frame(width: size, height: size)
  }
}

struct HeroDemo: View {
  @Namespace private var ns
  @State private var expanded = true

  var body: some View {
    ZStack(alignment: .topTrailing) {
      Color(.systemGroupedBackground).ignoresSafeArea()

      if expanded {
        ZStack {
          Color.black.opacity(0.4).ignoresSafeArea()
          TimerRing(size: 220, lineWidth: 12)
            .matchedGeometryEffect(id: "ring", in: ns)
        }
        .transition(.opacity)
        .onTapGesture {
          withAnimation(.spring(duration: 0.6)) { expanded = false }
        }
      } else {
        TimerRing(size: 50, lineWidth: 4)
          .matchedGeometryEffect(id: "ring", in: ns)
          .padding(24)
          .onTapGesture {
            withAnimation(.spring(duration: 0.6)) { expanded = true }
          }
      }
    }
    .frame(width: 390, height: 700)
  }
}

PlaygroundPage.current.setLiveView(HeroDemo())
The tutorial code in an Xcode playground beside the live view, showing the full screen countdown ring on the dimmed overlay.

Run it and tap the dark overlay. The ring travels to the corner, the small ring appears, everything lands in the right place. Ship it?

We nearly did. Look closer. Slow the spring down, or record the screen and step through frames. The big ring never shrinks. It slides toward the corner at a constant 220 points and fades, while a separate 50 point ring pops in and fades up. Two rings, sliding past each other, dressed up as one. On a simulator at full speed it fools you. On a real phone in your hand it reads as cheap, and we shipped exactly this to our own test devices before catching it.

The naive version running. Watch the ring: it never shrinks, it just slides and fades.

Why does matchedGeometryEffect not scale a view?

matchedGeometryEffect interpolates the frame it proposes to your view. Mid-flight, your view is offered a frame that is 180 points, then 120, then 60. But a frame proposal is only an offer. A view that draws itself at a fixed size ignores it. Our TimerRing does exactly that: Circle().frame(width: size, height: size) is 220 points no matter what the parent proposes. So the only thing that visibly animates is position. The size interpolation happens, and our view discards it every frame.

Nothing warns you about it. There is no console message, and both endpoints look pixel perfect whenever nothing is moving, because at rest the proposed frame and the fixed frame agree. The bug only exists mid-flight.

How do you make matchedGeometryEffect scale the content?

The ring needs to accept whatever frame the morph proposes and scale its fixed size drawing to fit. That is a small, reusable wrapper: a GeometryReader that reads the proposed size and applies scaleEffect.

/// Scales fixed-design-size content to whatever frame matchedGeometryEffect
/// proposes mid-flight. Without this, fixed-size content ignores the
/// interpolated frame and only its POSITION animates.
struct HeroScalableRing<Content: View>: View {
  let designSize: CGFloat
  @ViewBuilder let content: Content

  var body: some View {
    GeometryReader { geo in
      content
        .frame(width: designSize, height: designSize)
        .scaleEffect(min(geo.size.width, geo.size.height) / designSize)
        .position(x: geo.size.width / 2, y: geo.size.height / 2)
    }
  }
}

Two details are load bearing. First, the wrapper goes on both endpoints, tagged with matchedGeometryEffect from outside, so both rings scale along the same interpolated frame. Second, the .frame(width: 220, height: 220) AFTER the tag matters more than it looks: GeometryReader greedily takes all the space it is offered, so without pinning the at-rest size the layout blows up. With it, the scale factor is exactly 1.0 whenever the ring is not in flight, which means the fixed version is pixel identical to the naive one at rest.

if expanded {
  ZStack {
    Color.black.opacity(0.4).ignoresSafeArea()
    HeroScalableRing(designSize: 220) {
      TimerRing(size: 220, lineWidth: 12)
    }
    .matchedGeometryEffect(id: "ring", in: ns)
    // Load-bearing: GeometryReader is greedy. This outer frame pins the
    // at-rest size so scale == 1 when the ring is not in flight.
    .frame(width: 220, height: 220)
  }
  .transition(.opacity)
  .onTapGesture {
    withAnimation(.spring(duration: 0.6)) { expanded = false }
  }
} else {
  HeroScalableRing(designSize: 50) {
    TimerRing(size: 50, lineWidth: 4)
  }
  .matchedGeometryEffect(id: "ring", in: ns)
  .frame(width: 50, height: 50)
  .padding(24)
  .onTapGesture {
    withAnimation(.spring(duration: 0.6)) { expanded = true }
  }
}

Run the fixed file and tap. The ring now genuinely shrinks as it flies, stroke, digits and all, from 220 points down to 50. One object, one motion.

What does a hero animation need in a production app?

The playground shows the pattern. The production version has a few extra moves that are worth stealing if you build one of these:

  • The mini timer's slot swaps to Color.clear while the popup is up. The slot keeps its 50 point footprint, so the stats row never reflows mid-flight and the hero always has a stable landing pad.
  • Only the ring is tagged. The scrim, the title and the Skip button fade through the container's opacity transition. A hero animation reads best when exactly one element flies.
  • The corner nudge is layout, not offset. We wanted the mini timer tucked a few points toward the corner. Doing that with .offset would have broken the landing, because offset moves pixels after layout and matchedGeometryEffect measures layout frames. An oversized frame with bottomTrailing alignment moves the real frame.
  • Reduce Motion is one property, not four checks. Every animation the feature triggers routes through a single computed property that returns nil when the user has Reduce Motion on, so the whole feature degrades to the old crossfade with one line.
The reverse direction: tap the mini timer and the ring flies back to center.

Why does a test that tracks position pass a broken hero animation?

One more thing bit us, and it is not a SwiftUI thing. Our first automated check recorded the simulator, tracked the ring's center across frames and confirmed a smooth flight to the corner. It passed. It was also measuring the one property that animates even when the morph is broken. The center of a constant size ring that slides to the corner moves exactly like the center of a ring that shrinks on the way.

After the fix, the check measures the ring's bounding box per frame instead: one blob, diameter marching monotonically from 231 points down to 52. If you verify geometry animations with screen recordings, measure the thing that can fail. Position rarely lies about position, but it says nothing about size.

Why do small animation details make an app feel built rather than assembled?

A rest timer that flies to its slot will not show up in an App Store feature list. But it is the difference between an app that feels assembled and an app that feels built. Musklr is full of details like this, like two-tap set logging and the live muscle heatmap, and they all get the same attention, because lifting is repetitive and the app you see four times a week should feel great every time.

Frequently asked questions

Why is there no warning when matchedGeometryEffect only animates position?

Because nothing about the code is invalid. There is no console message, and both endpoints look pixel perfect whenever nothing is moving, because at rest the proposed frame and the fixed frame agree. The bug exists only mid-flight, and at full speed on a simulator it fools you. Slow the spring down, or step through a screen recording, and the ring is plainly sliding at a constant size rather than shrinking.

Why does a GeometryReader wrapper need a fixed frame after the matchedGeometryEffect tag?

Because GeometryReader greedily takes all the space it is offered, so without pinning the at-rest size the layout blows up. Put the frame after the matchedGeometryEffect tag and the scale factor is exactly 1.0 whenever the ring is not in flight, which makes the fixed version pixel identical to the naive one at rest. The wrapper goes on both endpoints, so both scale along the same interpolated frame.

Should more than one view be tagged in a hero animation?

Usually not. Only the ring is tagged here. The scrim, the title and the Skip button fade through the container's opacity transition instead, because a hero animation reads best when exactly one element flies. The two ends of this flight are the same TimerRingView at 220 points and at 50, which is what makes them a clean pair to tag in the first place.

Why must a hero animation's landing slot keep its space while the popup is up?

So the hero always has a stable landing pad. While the popup is up the slot swaps to Color.clear rather than disappearing, which keeps its 50 point footprint and stops the stats row reflowing mid-flight. That matters because matchedGeometryEffect measures layout frames: the ring is interpolating toward wherever the layout puts that slot, so the slot has to stay put.

Why does .offset break a matchedGeometryEffect landing when an oversized frame does not?

Because offset moves pixels after layout, and matchedGeometryEffect measures layout frames. Nudging the mini timer toward the corner with .offset moves what you see without moving what the morph is aiming at, which is what breaks the landing. An oversized frame with bottomTrailing alignment moves the real frame instead, so the target moves with it and the ring still lands in the slot.

What happens to a SwiftUI hero animation when Reduce Motion is on?

The whole feature falls back to the old crossfade. Every animation it triggers routes through one computed property that returns nil when the user has Reduce Motion on, so it is a single line rather than four separate checks and there is no second code path to keep in step. Until version 1.180 the timer faded out for everyone, so the fallback is behaviour the app had already shipped.

Free on iPhone and Apple Watch. Log a set in two taps.

See what Musklr does

Curious how Musklr stacks up against Strong, Hevy, Jefit and Fitbod? Read our comparison.

Share this post