Back to Devoured - September 03, 2026
DEVOURED
On Animating What You Can't See

On Animating What You Can't See

Design Karl Koch
Karl Koch explains how debugging complex iOS UI animations requires capturing snapshots of drawn view geometry rather than just the object bounds.
What: While working on the DuckDuckGo for iOS browser, Karl Koch identified that UI 'ghosting' bugs and incorrect scaling in animations were caused by snapshotting view bounds instead of the actual rendered content.
Why it matters: Debugging high-fidelity transitions often requires stepping through frames via ffmpeg rather than relying on standard code-level timing adjustments, as issues often stem from view ownership or incorrect geometric assumptions.
Takeaway: When an animation looks wrong, use `ffmpeg` to step through a 60fps recording of the transition frame-by-frame; check if your logic is snapshotting bounds or the actual drawing extent.
Deep dive
  • Animations often feel broken due to underlying view ownership conflicts, not just timing curves.
  • snapshotView(afterScreenUpdates:) fails to capture content extending outside a view's standard bounds (e.g., shadows or floating elements).
  • When scaling UI snapshots, avoid non-uniform resizing; use min-scaling to maintain aspect ratios.
  • Ghosting bugs often indicate that a view is being modified by an asynchronous process before the current transition finishes.
  • Debugging requires slowing down the simulation 10x and capturing individual frames to identify the exact point of divergence.
Decoder
  • View bounds: The rectangular area a view occupies in its parent coordinate system, which may exclude content like shadows or borders drawn outside that rectangle.
  • Run-loop: The event processing mechanism that schedules work and handles input on iOS threads.
  • ffmpeg: A command-line tool used to process, cut, and analyze video files frame-by-frame.
Original article

I’ve been polishing DuckDuckGo for iOS’s floating UI, a glass-capsule toolbar that morphs and collapses much like Safari’s. The transition between the tab switcher and a web page took four attempts to fix because the first three were trying to fix the wrong layer.

The wider pass covered every state around that toolbar, including how it collapses during a scroll and how it makes room for a focused address field. Each change is small in isolation, but together they decide whether the browser chrome feels like one coherent object or a stack of views catching up with each other.

The raw problem

Switch from the tab switcher back to a tab and, in slow motion, you’d see a faint double image of the bottom toolbar: two overlapping copies of the back and forward arrows, tab count and menu button.

Everything else about the transition looked right, which made it easy to assume this was a sequencing problem: retime the fade, adjust the crossfade, tighten the easing. Three rounds of that changed what was visible, which felt like progress, but the ghost kept coming back in a different form.

If the ghost keeps changing shape, you’re tuning parameters on the wrong mechanism.

Stop guessing, start measuring

I finally fixed it by recording the transition at 60fps with the simulator’s slow-animations mode on, a 10× slowdown, then stepping through it frame by frame with ffmpeg. That’s what it took to see that the real toolbar’s tab-count badge was fully visible 24 frames before the snapshot animation even started. No amount of retiming a snapshot fixes a bug that happens before its code runs.

It turned out that the duration and curve weren’t the problem, view ownership was. Dismissing the tab switcher triggered an async completion handler that snapped the toolbar to full opacity on the next run-loop turn, with no awareness that a custom transition was mid-flight and already owned that view. Three other code paths could do the same thing.

I added an ownership check:

var isTabSwitcherTransitionOwningToolbar = false

if !isTabSwitcherTransitionOwningToolbar {
    viewCoordinator.toolbar.alpha = toolbarAlpha(for: percent)
}

One flag, respected by every path that writes that property, set for exactly as long as the transition owns the view.

Bounds aren’t always what’s drawn

With the ghosting gone, a second bug surfaced: the toolbar’s rounded bottom corners stayed flat for the whole transition, then snapped into roundness the instant the real toolbar took over.

The capsule is deliberately drawn a little outside its own view bounds. That’s how it floats near the home indicator without disturbing the rest of the layout. snapshotView(afterScreenUpdates:) only captures a view’s bounds, so every snapshot we animated had the bottom of the capsule sliced off: corners, shadow and all.

The lesson generalises: a view’s bounds and what it draws become two different rectangles the moment a transform pushes content outside itself. Snapshot the wrong one and you get a silently cropped copy that looks fine until it moves.

var visibleCapsuleRect: CGRect {
    let shadowSpill = materialBackgroundView.layer.shadowRadius
        + materialBackgroundView.layer.shadowOffset.height

    return bounds.union(
        materialBackgroundView.frame.insetBy(
            dx: -shadowSpill,
            dy: -shadowSpill
        )
    )
}

Ask the view what it draws, not what its bounds say. Snapshot that instead.

The squish

A separate bug, flagged in review, flattened the Dax logo into a wide oval when opening the tab switcher from an empty new-tab page, but only in list view.

A snapshot of the whole page was being resized to fit the destination tab-cell frame, and resizableSnapshotView stretches non-uniformly rather than scaling. A grid cell’s proportions are close enough to the screen’s that nobody noticed. A list row is nearly six times wider than it is tall, so the whole page was squashed into a letterbox.

My first fix matched the snapshot to the container’s width. It stopped the stretch, but replaced it with aggressive cropping that could clip the logo entirely. Fitting on the smaller of the two dimensions, not the larger, is what actually scales instead of crops:

let scale = min(
    containerBounds.width / sourceSize.width,
    containerBounds.height / sourceSize.height
)

An honest shrink, centred, instead of a fill.

When to steal this

Any transition that animates a snapshot instead of the live view is worth checking for two failure modes: capturing bounds instead of drawn extent, and resizing instead of scaling.

Both pass code review because both compile and look correct at the aspect ratio you happened to test. They only show up with a different shape, a wider row or a view with overflow, and by then the fix reads like a timing bug because that’s where the symptom appears.

It rarely is. Record the transition, step through it frame by frame, and check what rectangle you’re actually asking for before touching a single duration or curve.

Explore all 59 articles in today's digest

Fresh dev news, AI-digested daily. Never miss a trend at devoured.fyi