On animating what you can't see

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 the states around the toolbar too, including how it collapses during a scroll and makes room for a focused address field. They’re small changes on their own, but I wanted the toolbar to keep feeling connected as it moved between them.

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.

I was changing how the problem looked without getting to what was causing it.

Looking at the recording

I finally recorded the transition at 60fps with the simulator’s slow-animations mode on, a 10× slowdown, then stepped through it frame by frame with ffmpeg. The real toolbar’s tab-count badge was fully visible 24 frames before the snapshot animation had even started. So I’d been spending all that time adjusting an animation that hadn’t started when the bug happened.

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)
}

The flag stays set whilst the transition owns the view, and every path that writes the property checks it first.

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.

I needed to capture the area the capsule was actually drawing into, including the overflow. The bounds alone gave me a snapshot that looked almost right, with the missing part becoming obvious once it moved.

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

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

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. That stopped the stretch but cropped it quite aggressively, sometimes enough to clip the logo entirely. Using the smaller of the two scale factors kept the whole thing inside the destination:

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

Then I centred it in the space that was left.

What I’d check next time

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.

It’s easy to miss those cases when the aspect ratio in front of you happens to work. I needed the wider list row and the capsule’s overflow to see what was wrong with the snapshots I was using.

The recording was what made it clear. I’ll still need to tune durations and curves, but next time I see something like this I want to check what’s actually being drawn, and which view owns it, a bit earlier.

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论