r/SwiftUI • • 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:

  1. Prepare opaque image frames ahead of time.
  2. 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.
  3. 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?

3 Upvotes

3 comments sorted by

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.

1

u/DismalCockroach1928 3d ago

The fallback prevents an empty canvas; it doesn't make a missed tick invisible. Frame zero is unmasked underneath the stack, so if no gate is open there is still an image. If a timer update is skipped, the visible sequence can still jump when updates resume. I should have made that distinction clearer.

The anchor isn't reset when the view wakes: in the renderer it's startOfDay(for: referenceDate) minus one loop duration, with each frame's timer offset from that. There is no saved playback cursor to resume. The intended frame follows elapsed wall-clock time, so waking can put it mid-sequence rather than at frame zero.

That's what follows from the implementation, not a measured guarantee about the first refresh after wake. The exact late-tick/wake cases you describe need a controlled device test before I can give a reliable answer about smoothness. A useful check would be to record the Watch around the loop boundary after a longer inactive period, looking separately for blank frames, phase jumps and stale-frame holds. The unmasked backstop only addresses the first of those.

1

u/cocoabuilder 3d ago

That distinction is clearer now, thank you — no blank canvas is not the same as no visible jump, and the wall-clock anchor explains why waking mid-sequence is expected rather than a bug. Splitting that recording check into blank frames, phase jumps and stale holds is exactly the right way to report it, too, so a reader can see which of the three actually shows up on device. I'll leave it there — keen to see what that longer inactive-period test shows.