Skip to content

Fix: second-dismiss hero snapping to centered square

jedmund requested to merge jedmund/sao-paulo-v5 into main

Created by: jedmund

Summary

Second-time hero dismissal was snapping the flying snapshot into a 200×200 centered square instead of the grid cell it came from. Root cause was in CellRectRegistry bookkeeping — the rect was missing from the registry on the second pop, so the animator fell through to fallbackCenteredRect.

Evidence (from heroLog)

Second dismiss trace:

registry REMOVE id=home/143773013          (onDisappear during post-push cleanup)

registry QUERY  id=home/143773013 → nil
animator BUILD  sourceRect window=nil origin=registry (live) [FALLBACK]
animator ADD    main-anim snapshot → (588,416 200×200)

The first dismiss had 19 registry ADD id=home/... events between the post-push cleanup and the animator BUILD. The second dismiss had zero — the cells were visible on screen but had no registry entry.

Root cause

.onGeometryChange only fires on change. SwiftUI's LazyVStack retains cells across UIHostingController detach/reattach cycles (push → post-push cleanup → pop). On the first pop, cells report their rect for the first time post-reattach — counts as a change, callback fires, registry repopulated. On the second pop, cached geometry is identical to the first pop's value — no change, no callback — while .onDisappear during the post-push cleanup had already cleared the registry.

Fix

Stash the last rect reported by .onGeometryChange in a local @State on the cell and re-assert it on .onAppear:

@State private var lastRect: CGRect?

.onGeometryChange(for: CGRect.self) { geo in
    geo.frame(in: .global)
} action: { newRect in
    lastRect = newRect
    cellRectRegistry?.register(id: sourceID, rect: newRect)
}
.onAppear {
    if let lastRect {
        cellRectRegistry?.register(id: sourceID, rect: lastRect)
    }
}
.onDisappear {
    cellRectRegistry?.unregister(id: sourceID)
}

.onGeometryChange still handles live moves (scroll, rotation, column reflow); .onAppear provides the baseline republish for the detach/reattach case where SwiftUI elides the geometry callback.

Test plan

  • Tap a home cell → detail opens with hero → pull to dismiss. Land on the original cell. (First cycle, already worked.)
  • Tap the same cell again → detail opens → pull to dismiss. Should now land on the original cell instead of a centered square. (This was the bug.)
  • Repeat the open/dismiss cycle 5+ times on the same cell; every dismiss should land correctly.
  • Open/dismiss different cells (not just the same one). Each should land on its own cell.
  • Scroll the feed after dismissing, tap a cell now at a new scroll position → hero should follow the cell's new rect (live .onGeometryChange still fires on scroll).
  • iPad rotation during a detail view → back to grid → tap a cell → hero uses the post-rotation rect.
  • Check Console (subsystem:com.attractor.pixillate category:hero) on second dismiss — should see registry QUERY id=home/... → (x,y w×h) instead of → nil, and the animator line should no longer say [FALLBACK].

🤖 Generated with Claude Code

Merge request reports

Loading