Fix: second-dismiss hero snapping to centered square
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 .onGeometryChangestill 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 seeregistry QUERY id=home/... → (x,y w×h)instead of→ nil, and the animator line should no longer say[FALLBACK].