r/SwiftUI • u/DismalCockroach1928 • 4d ago
Promotion (must include link to source code) Using SwiftUI timer text as a frame mask in watchOS complications (source + caveats)
I've been adapting the timer/font technique to Apple Watch complications in TV Watch. The idea isn't mine: Bryce Bostwick demonstrated it for iPhone widgets in WidgetAnimation and this walkthrough. My first attempt converted video frames into font glyphs on the iPhone. That approach failed when I tried to load those custom fonts, transferred from iPhone, on the Apple Watch. I then adapted Bryce’s approach: bundle a fixed masking font with the Watch extension and keep the video frames as images, so importing a new clip does not require loading a newly generated font.
The useful distinction is that WidgetKit can update a system-rendered timer without running your extension every second. That does not give you an ordinary SwiftUI animation loop. Instead:
- Prepare opaque image frames ahead of time.
- Draw each frame behind timer text in a custom font. The font's shaping rules turn selected timer values into a filled rectangle and the others into an empty mask.
- Offset each layer's timer so it reveals its frame at the intended point in the loop.
In the 2 fps version, a gate stays open for a second but layers start half a second apart. Two frames can therefore be visible at once: stacking newer opaque frames above older ones is what makes the sequence work. It isn't just one timeline entry per frame.
The core composition looks like this (excerpt, not a standalone example):
Image(decorative: frames[index], scale: 1)
.resizable()
.scaledToFit()
.mask {
Text(anchor.addingTimeInterval(offset), style: .timer)
.font(.custom(gateFontName, fixedSize: fontSize))
.foregroundStyle(.white)
.lineLimit(1)
.environment(\.locale, Locale(identifier: "en_US_POSIX"))
.environment(\.layoutDirection, .leftToRight)
}
The tricky part is the end of the loop: high-index layers otherwise cover the beginning of the next pass. I put the last-second frames in a separately gated group and keep an unmasked frame zero underneath to avoid a blank gap if timer updates arrive late.
Public code: renderer and font generator, under MIT. Disclosure: it's my repository. The whole app still has a private dependency for its legacy renderer, so a fresh clone isn't a self-contained build; the linked renderer and generator are public to inspect.
Limits worth keeping explicit: watchOS controls redraw timing, so 2 fps is a target rather than a guarantee. I use a static frame for Always-On/Reduce Motion in the weather complication. I don't have a controlled battery comparison, and this isn't a promise that future OS releases will preserve the behavior.
Has anyone else worked with staggered system timers on watchOS, particularly late updates around the loop boundary?
1
u/cocoabuilder 3d ago
Clever use of the system timer — using it as a gate rather than trying to drive an animation loop is exactly the trade-off that makes this viable on watchOS. One thing I would test around the loop boundary is a late tick that skips a gate entirely: if the system delivers the next update a second late, do you get a visible jump, or does the unmasked frame underneath carry you through? The second case I would watch is the first frame after the watch wakes — does the timer anchor still line up with the frame index, or does it resume mid-sequence? Your note that 2 fps is a target, not a guarantee, is the right framing; designing the sequence so a missed tick is invisible matters more than hitting the rate exactly.