Skip to content

Shared-element view transitions (ref + @navigate.viewTransition) - #372

Open
shanerbaner82 wants to merge 3 commits into
mainfrom
feat/view-transitions
Open

Shared-element view transitions (ref + @navigate.viewTransition)#372
shanerbaner82 wants to merge 3 commits into
mainfrom
feat/view-transitions

Conversation

@shanerbaner82

Copy link
Copy Markdown
Contributor

Elements that morph between screens — the native analogue of the CSS View
Transitions API — on iOS and Android.

<column @navigate.viewTransition('/album/3')>
    <column ref="album-3" class="w-16 h-16 rounded-xl">…</column>
</column>

{{-- destination --}}
<column ref="album-3" class="w-full h-80">…</column>

<row @navigate.back.viewTransition>…</row>

Two elements sharing a ref across two screens morph between them. Motion is
shaped by three optional props: morph="frame|position|size|none",
morph-duration (ms) and morph-easing
(linear|ease-in|ease-out|ease-in-out|spring).

Why ref

ref already 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. id was rejected because
it is already the node's numeric wire identity and what event dispatch routes
on; key was rejected because it is scoped to a parent key-path and the docs
encourage domain ids, so key="3" on unrelated things would pair constantly.

Consequence worth reviewing: a ref that exists only as a test handle and
repeats across two screens will now morph under a view_transition. The opt-out
is morph="none".

ref costs nothing extra on the wire — the node-level field is dropped by
npui_serialize_node, so only the generic prop is transmitted, and it replaced
transition_name.

How it works, per platform

AndroidSharedTransitionLayout wraps the screen-swapping
AnimatedContent; Compose matches refs across panes and animates the bounds
itself. Scopes reach the recursive NodeView through composition locals.

iOS — SwiftUI has no equivalent. matchedGeometryEffect only slaves one
view'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 interpolated
directly) 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.kt rather than approximated.

Also in here

  • ref reaches UI drivers on both platforms: an accessibilityIdentifier on
    iOS (nothing read ref at all before) and testTagsAsResourceId on Android
    (a Compose test tag is otherwise invisible to UiAutomator). On iOS this needs
    .accessibilityElement(children: .contain), or naming a container collapses
    its descendants out of the tree — which hid content from screen readers too,
    not just tests.
  • ref sets testTag only on Android, not contentDescription — writing an
    internal handle there made TalkBack announce "photo-1" in place of the
    element's real description.
  • ref and the morph props now survive the streaming builtin path, which
    bypasses applyStyle/applyCallbacks and dropped them silently. Unit tests
    could 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.

Pre-existing CI note: PHPStan reports 17 errors in
src/Commands/Concerns/* (native:watch argument/option signatures). They
are untouched by this branch and present on main.

🤖 Generated with Claude Code

shanerbaner82 and others added 3 commits August 24, 2026 06:53
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant