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

badgag1 pts1 comments

The Ring That Wouldn't Shrink: SwiftUI matchedGeometryEffect in Practice | Musklr Blog

Skip to content

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

By Musklr Team · Published 10 July 2026 · Updated 10 July 2026

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.

is the no-video fallback; it<br>reuses the poster's exact webp URL because fallback elements<br>fetch even when never rendered - a second (jpg) URL would download<br>~130 KB of duplicate posters on every article view. -->

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<br>import PlaygroundSupport

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

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

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

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

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

PlaygroundPage.current.setLiveView(HeroDemo())

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...

ring size view frame timer swiftui

Related Articles