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.

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 we wanted

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.

Build it in a 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 matchedGeometryEffect would not scale the ring

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.

The fix: scale the content with the proposed frame

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.

In the real 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.

The other lesson: verify size, not position

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.

Small things, done properly

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.

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.