Shared-element view transitions (ref + @navigate.viewTransition) - #372
Open
shanerbaner82 wants to merge 3 commits into
Open
Shared-element view transitions (ref + @navigate.viewTransition)#372shanerbaner82 wants to merge 3 commits into
ref + @navigate.viewTransition)#372shanerbaner82 wants to merge 3 commits into
Conversation
…yout Brings Android to parity with the iOS `ref` morph. `SharedTransitionLayout` wraps the screen-swapping `AnimatedContent`, so Compose matches refs across the two panes and animates the bounds itself — no source/slave roles, no arming, and no timing race, all of which the SwiftUI side has to do by hand. `NodeView` is a plain recursive composable with no receiver, so both scopes reach it through composition locals, the same route the safe-area values already take. Three things worth calling out: - `heroMorph` is applied FIRST, i.e. outermost. Compose modifiers wrap outer→inner, so applied last it animated only the node's children: on the three-hop chain the number travelled while its coloured box stayed put. This is the OPPOSITE order from iOS, where SwiftUI applies modifiers outward and the equivalent sits late in the chain. - The root opts into `testTagsAsResourceId`, which is the only way a Compose test tag reaches UiAutomator. Without it every `ref` was invisible to a driver even though it was set. `ref` now sets testTag only — writing it to contentDescription as well made TalkBack announce "photo-1" in place of whatever the element actually is. - `morph="position"` / `"size"` fall back to a full-bounds morph here. Compose's shared-element API always animates the full bounds and exposes no equivalent, so this is a real parity gap rather than an approximation. Also carries `ref` and the morph props through the streaming builtin path, which bypasses applyStyle/applyCallbacks and dropped them silently — invisible to unit tests, which use the Element path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `matchedGeometryEffect` approach worked but never looked right, and the
reason is structural: it can only slave one view's frame to another's, and both
views stay inside their own screen layer. While the screens cross-fade, a hero
travelling inside either layer is subject to that layer's opacity — measured on
device as "animates most of the way, then fades", because the tail of the
journey is progressively occluded.
Compose has no such problem: `SharedTransitionLayout` lifts the element into
its own layer ABOVE both panes. This gives SwiftUI the same model by hand, via
FLIP:
1. Every ref'd element reports its global frame (`NodeHeroModifier`).
2. At the swap the store snapshots those, then waits for the incoming screen
to report where the same refs landed.
3. A COPY is rendered in `HeroFlightOverlay`, above both screens, and
animated between the two frames. Both real elements stay hidden for the
duration, so nothing double-draws and nothing is occluded.
Only refs on BOTH screens fly. A grid has six tagged tiles and one partner; the
other five never had anywhere to go, and treating them as paired blanked the
whole grid mid-swap.
Three traps, each measured rather than reasoned about:
- Arming from the publish path with `asyncAfter(.now())` landed 2ms later,
before SwiftUI had laid the incoming screen out, so the hero was already the
source on its first render and never moved. Frames now drive the flight; no
timer exists.
- `withAnimation` around an `@Published` mutation does not reliably carry its
transaction into observing views. The roles flipped correctly while the
geometry snapped — forward animated only by luck, via an incidental
intermediate render pass, and back had none. Animations are now declared on
the value that moves.
- The overlay's copy renders through `NodeHeroModifier` too, so it obediently
hid itself: an invisible element flying beautifully across the screen. The
`heroIsFlyingCopy` environment flag exempts it from both hiding and frame
reporting.
Interpolating the rect directly also makes `morph="position"` / `"size"` exact
here, which Compose's full-bounds-only API cannot match.
Separately, `NodeIdentityModifier` exposes `ref` as an accessibility
identifier — nothing in the iOS renderer read `ref` at all, so every UI-driver
`id:` selector failed. It needs `.accessibilityElement(children: .contain)`:
naming a container otherwise collapses its descendants into one element, which
hid the player's artwork, title and close control from both a driver and a
screen reader.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Elements that morph between screens — the native analogue of the CSS View
Transitions API — on iOS and Android.
Two elements sharing a
refacross two screens morph between them. Motion isshaped by three optional props:
morph="frame|position|size|none",morph-duration(ms) andmorph-easing(
linear|ease-in|ease-out|ease-in-out|spring).Why
refrefalready existed as a per-element name, used as a test-targeting handle.Two screens naming an element the same thing is exactly what a shared element
is, so there is no second naming concept to learn.
idwas rejected becauseit is already the node's numeric wire identity and what event dispatch routes
on;
keywas rejected because it is scoped to a parent key-path and the docsencourage domain ids, so
key="3"on unrelated things would pair constantly.Consequence worth reviewing: a
refthat exists only as a test handle andrepeats across two screens will now morph under a
view_transition. The opt-outis
morph="none".refcosts nothing extra on the wire — the node-level field is dropped bynpui_serialize_node, so only the generic prop is transmitted, and it replacedtransition_name.How it works, per platform
Android —
SharedTransitionLayoutwraps the screen-swappingAnimatedContent; Compose matches refs across panes and animates the boundsitself. Scopes reach the recursive
NodeViewthrough composition locals.iOS — SwiftUI has no equivalent.
matchedGeometryEffectonly slaves oneview's frame to another's, and both stay inside their own screen layer, so a
hero travelling while the screens cross-fade is progressively occluded ("moves
most of the way, then fades"). Instead a FLIP overlay renders a copy of each
travelling element above both screens: frames are reported continuously, the
swap snapshots them, and the copy animates between them while both real
elements stay hidden.
Known parity gap
morph="position"/"size"are exact on iOS (the rect is interpolateddirectly) but fall back to a full-bounds morph on Android — Compose's
shared-element API always animates the full bounds and exposes no equivalent.
Documented in
NodeHeroMorph.ktrather than approximated.Also in here
refreaches UI drivers on both platforms: anaccessibilityIdentifieroniOS (nothing read
refat all before) andtestTagsAsResourceIdon Android(a Compose test tag is otherwise invisible to UiAutomator). On iOS this needs
.accessibilityElement(children: .contain), or naming a container collapsesits descendants out of the tree — which hid content from screen readers too,
not just tests.
refsetstestTagonly on Android, notcontentDescription— writing aninternal handle there made TalkBack announce "photo-1" in place of the
element's real description.
refand the morph props now survive the streaming builtin path, whichbypasses
applyStyle/applyCallbacksand dropped them silently. Unit testscould not catch this; they use the Element path.
Testing
Pest 58 passed / 0 failed (3041 assertions), including a new
tests/Unit/Edge/ViewTransitionTest.php. Pint clean.Verified frame-by-frame from screen recordings on an iPhone 14 simulator and a
Pixel 9 emulator across twelve demo screens, forward and back — automated flows
prove taps land and destinations render, never that a morph played.
🤖 Generated with Claude Code