Pinterest-quality cell ↔ detail ↔ lightbox transitions
Created by: jedmund
Summary
Replaces SwiftUI's .navigationTransition(.zoom) (which flashed black, jumped aspect ratios, and offered no interactive dismiss) with an owned floating-image transition pattern across both transitions in the detail flow:
-
Cell → detail:
FloatingHeroCoordinator+FloatingHeroOverlayrender a singlePixivImageat the top z-index, animated viaactiveFramefrom the cell's window-frame to the detail hero's pedestal frame. No matched-geometry cross-fade artifacts. -
Detail → lightbox: Same pattern applied to the hero
↔ lightbox pair (FloatingLightboxCoordinator+FloatingLightboxOverlay). Replaces the previousmatchedGeometryEffect. -
Pull-to-dismiss everywhere: detail dismiss rides on
onScrollPhaseChange+onScrollGeometryChange(so it doesn't fight the innerScrollView's pan recognizer); lightbox dismiss bridges UIKit pan data fromZoomableImageViewto the coordinator'sdragOffsetY/dragScaleso the image follows the finger 1:1 in both cases. - Spring tuner moved from a permanent floating panel to Settings → Developer.
-
Lightbox preload routed through a dedicated
ImagePipeline.pixivLightbox(~400 MB cache) so the original-resolution image doesn't evict the hero from the main pipeline. -
Multi-resolution image fallback in
PixivImageso the lift always has something to show on a fresh-cell tap (cell's 300pt variant under the loading 1200pt), with the upgrade suppressed during the spring so it doesn't flash mid-flight.
Architectural pieces
-
DetailPresenter+PresentedDetailstack (designed for v3 nested related-illust overlays; v1 ever holds a single layer). - Composite source IDs (
<feedID>/<illustID>) so the same illust in multiple feeds doesn't cross-bind. -
CellFrameReporter+HeroFrameReporter(async, refines on scroll/rotation) AND synchronous frame capture inPressableCellStyleon press-down (eliminates the first-tap-doesn't-zoom race). -
HeroFrameComputer+LightboxFrameComputerpure functions matching the actual SwiftUI layouts. - Three velocity / settle-handler safeguards:
disablesAnimationstransaction inhandleStackChange(kills inherited spring velocity on rapid re-tap), gated lift/dismiss settle handlers (a stale settle can't clobber the next transition),holdFallbackDuringLoadso a 1200pt arriving mid-spring doesn't flash.
Plus a few orthogonal wins along the way:
- iPad correctly distinguishes portrait vs landscape column counts (was always using
portraitColumnsbecauseverticalSizeClassis.regularin both iPad orientations). - Cells fade in only on genuine network loads — cached cells render directly, no flash on re-entry into the viewport.
- Spring Tuner relocated to Settings → Developer.
What's NOT in this PR
- v2 horizontal swipe through the feed (architecture supports it; not yet implemented).
- v3 nested related-illusts overlays (architecture supports it;
RelatedIllustsSection.swiftis a stub). - Dominant-color placeholders (planned, shelved as future exploration).
- Removing the temporary
🟥debug logs inZoomableImageView/FloatingLightboxOverlayfor diagnosing silent CDN failures.
Test plan
-
Cold scroll Home — cells fade in on first appearance, no fade on re-scroll into viewport. -
Tap fresh cell (one not yet tapped this session) — clean lift, image visible the entire time. Repeat with several different cells. -
Re-tap same cell — no exaggerated bounce / velocity inheritance from previous dismiss. -
Pull-down dismiss — image follows finger 1:1, chrome and pedestal fade with the pull, release at any positive offset commits, scroll-into-content rescues. -
Close button dismiss — same dismiss spring, no jump. -
Multi-page illust — swipe carousel, tap close, dismiss returns from page 0 (carousel resets first). Tap hero → lightbox shows current page. -
Lightbox lift / dismiss — single floating image throughout, no cross-fade. Pinch to zoom; pan-down at min zoom commits dismiss with finger tracking. -
Tag pill / user byline in detail — overlay dismisses cleanly, then NavigationStack pushes destination. -
Settings → Developer → Spring Tuner — sliders adjust springs in real time. Reset restores defaults. -
iPad portrait + landscape rotation — column count switches per the user's chosen settings. -
Memory — open lightbox on 5+ different illusts; main pipeline cache (heroes/cells) untouched, lightbox pipeline holds ≤6 entries.