Skip to content

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

Merged
simonhamp merged 5 commits into
mainfrom
feat/view-transitions
Oct 23, 2026
Merged

simonhamp merged 5 commits into
mainfrom
feat/view-transitions

Conversation

@shanerbaner82

@shanerbaner82 shanerbaner82 commented Aug 24, 2026 •

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.morph('/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.morph>…</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 morph navigation, and only
then. 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

Android — SharedTransitionLayout 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

After merging main (see the update below): Pest 2123 passed, 0 failed, 5 skipped,
4 risky. The tests for this feature are in tests/Unit/Edge/MorphTransitionTest.php.
Pint and PHPStan are 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. That was before
the merge and the rename, which haven't been run on a device yet.

Update: merged with main and renamed

Simon merged main into this branch and renamed the modifier. Two commits:

040d662, the merge. Conflicts in NativeElementCollector (main's border and
variant prop builders sit next to buildHeroProps) and SwiftUINodeRenderer.
Main's #576 now skips modifiers a node doesn't use, so the hero and identity
modifiers are wired in through a new .ref decoration. Nodes without a ref no
longer each observe HeroFlightStore.

Main's #580 (a11y labels on containers) changed things under this branch
without a conflict, and two fixes were needed:

  • iOS: NodeIdentityModifier now sits outside NodeAccessibilityModifier and
    only sets the identifier when that modifier has already made the node one
    element. Before, a labelled pressable with a ref lost one or the other.
  • Android: the ref test tag moved outside nodeAccessibility. Its
    clearAndSetSemantics dropped the tag whenever the node had an a11y-label.

9969bdb, the rename. @navigate.viewTransition is now @navigate.morph,
Transition::ViewTransition is Transition::Morph, and the wire value is
morph. Nothing shipped with the old name, so there's no alias.

The same commit stops Android morphing under other transitions. Its
SharedTransitionLayout wrapped every screen swap, so matching refs morphed
during plain slides too, while iOS only flies elements for this transition. The
shared transition scope is now only handed down when the pending transition is
morph.

Worth knowing: a push between two screens that both have native chrome of the
same kind (stack to stack, or tab to tab) doesn't morph on either platform. The
NavigationStack / native stack renderer animates those itself and the router
swap never runs. Tabs to stack and screens without chrome do morph. This is
documented rather than fixed.

Docs: NativePHP/nativephp.com#569.

🤖 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>
simonhamp and others added 2 commits October 22, 2026 23:25
Two conflicts:

- NativeElementCollector: main added buildBorderProps and
  buildVariantProps next to where this branch added buildHeroProps. All
  three are kept, hero props before the variant snapshot.
- SwiftUINodeRenderer: main (#576) now only puts a node through the
  modifiers it uses, via NodeDecorations. The hero and identity modifiers
  are wired into that with a new `.ref` decoration, so nodes without a
  ref no longer each subscribe to HeroFlightStore.

Two fixes for things main changed underneath this branch that git merged
without complaint (#580, a11y-label on containers):

- iOS: NodeIdentityModifier no longer wraps a node in
  `.accessibilityElement(children: .contain)` when NodeAccessibilityModifier
  has already made it one element. It now sits outside that modifier and
  only sets the identifier there, so a labelled pressable with a ref keeps
  both its label and its id.
- Android: the `ref` testTag moves outside nodeAccessibility. An
  a11y-label there uses clearAndSetSemantics, which dropped any semantics
  set inside it, including the tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`@navigate.viewTransition` is now `@navigate.morph`, and
`Transition::ViewTransition` is `Transition::Morph`. The wire value
changes from `view_transition` to `morph` on both platforms. Nothing has
shipped with the old name, so there's no alias.

Android now only morphs under this transition. SharedTransitionLayout
wrapped every screen swap, so two screens sharing a ref morphed during
plain slides and fades too, while iOS only ever flew elements for this
one transition. A ref used only as a test handle on several screens
would have started moving on Android. The shared transition scope is
now only handed down when the pending transition is `morph`.

Also tidied on the way:

- ScreenTransitions.swift: the morph constant had been inserted into the
  middle of nativeScreenSwapAnimation's doc comment. Moved it above.
- Comments that still described the earlier matchedGeometryEffect
  approach now describe the overlay.
- Dropped an unused SwiftUI import and two stray blank lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@simonhamp simonhamp changed the title Shared-element view transitions (ref + @navigate.viewTransition) Shared-element morph transitions (ref + @navigate.morph) Oct 22, 2026
@simonhamp
simonhamp merged commit 020ee1b into main Oct 23, 2026
5 checks passed
@simonhamp
simonhamp deleted the feat/view-transitions branch October 23, 2026 01:06
simonhamp added a commit to NativePHP/nativephp.com that referenced this pull request Oct 23, 2026
Adds a "Morphing elements between screens" section to the routing page
for `@navigate.morph` and `Transition::Morph`, from NativePHP/mobile-air#372.
It covers matching elements by `ref`, the morph, morph-duration and
morph-easing attributes, which elements morph, and the native chrome
case where nothing does.

Also lists the new transition with the existing ones, points to it from
Gestures & Animation, and notes on the testing page that a ref now
reaches UI drivers and can make an element morph.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
simonhamp added a commit that referenced this pull request Oct 24, 2026
…le (#602)

Release builds of an app on mobile-air main abort in swift-frontend while
lowering ResolvedNodeView.body: "Possible non-terminating type substitution
detected". Debug builds are fine.

Each nodeModifier(if:) step names the view it's called on in both branches,
so the chain's type doubles with every step. With the laid-out node as the
base, nine steps (two came with shared-element morphs in #372) need more
than the compiler's 120,000 substitution steps under whole-module
optimisation. Eight fit.

The chain now lives in NodeDecorationsModifier.body(content:), where it
starts from SwiftUI's small Content placeholder. The whole module then fits
in under 5,000 steps. Undecorated nodes still get only their layout;
decorated ones get one more modifier layer than before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
simonhamp added a commit that referenced this pull request Oct 24, 2026
Release builds on main aborted in swift-frontend from #372 until #602.
Debug built fine and nothing in CI compiles the iOS project, so nobody
noticed until someone built for release.

The job makes a Laravel app that requires this checkout and mobile-ui
main, runs native:install ios and native:build --simulated (Debug), then
builds Release for devices with xcodebuild, unsigned. It runs on GitHub's
xcode-27 image with Xcode 27.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simonhamp added a commit that referenced this pull request Oct 24, 2026
Release builds on main aborted in swift-frontend from #372 until #602.
Debug built fine and nothing in CI compiles the iOS project, so nobody
noticed until someone built for release.

The job makes a Laravel app that requires this checkout and mobile-ui
main, runs native:install ios and native:build --simulated (Debug), then
builds Release for devices with xcodebuild, unsigned. It runs on GitHub's
xcode-27 image with Xcode 27.0.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
gwleuverink added a commit that referenced this pull request Oct 25, 2026
Covers mobile PRs #241, #305, #344, #348, #370, #372, #401, #436, #439, #441, #447, #451, #461, #499, #500, #501, #502, #509, #511, #517, #522, #524, #529, #530, #537, #539, #541, #543, #545, #549, #550, #553, #557, #558, #561, #564, #565, #566, #571, #572, #575, #576, #577, #578, #579, #580, #581, #582, #583, #584, #585, #586, #587, #589, #591, #592, #593, #594, #595, #596, #597, #598, #599, #600, #601, #602, #603 and #604 (main d5dd874..b107ed9). #539 was already on this branch, merged as if accepted on 10 Oct. Its later pushes only brought main in and fixed OnWhenTest, and both were already here.

Ported to core in NativePHP/core#17 (branch sync/mobile-main-2026-10-25), which this merge needs: #572 and #589 (the ImageFit enum, which moves to core), #578 (route() and external URLs in navigate() and replace(), with an openedExternally() seam), #502 (unknown element tags; UnknownElementException moves to core with a hint seam), #529, #499, #372, #586, #241, #549, #601, #603, #344 and #593, the core halves of #511, #370, #348 and #401, and --json on core's native:run for #305. --fresh on core's native:run too, for #513, which came before the last sync and was missed then.

Kept on this branch: #578's exitToWeb() and the openedExternally() that opens the browser, #558's browser handoff, #370's system back and #596's root back, all in InteractsWithAppShell. #439's stack depth in WrapsScreensInNativeChrome, read through router(). #511's PERF gating in runLoop(). ScreenGuard (#348, #595) and UnknownElementHint (#502) stay in mobile, and the provider hands core the hints at boot. Platform reads go through System::platform(), as #596 has it.

Removed again in the merge: the moved files main changed, ImageFit and UnknownElementException (now core's), and src/Events/GlobalEventDispatcher.php, which main added back (core has it, and the old name is an alias). Main's import of the old dispatcher name came out of DispatchEventFromAppController and NativeServiceProvider, where it clashed with core's.

Adapted for the extraction: RunloopIdleTest reflects on core's NativeComponent, where the runloop state it reads is private. NativeComponentAnalyzer counts a render() declared on core's class as the default one, which #501's new test caught.

Needs NativePHP/core#17.

Tests: mobile on this merge against the core sync branch (52db12f), 2,343 passed, 4 risky, 5 skipped (Devkeepr run 5371). PHPStan clean (run 5372). Mobile main (b107ed9), 2,288 passed, 4 risky, 5 skipped (run 5359). Desktop main (24f3003) against the core sync branch, test:php 1,883 passed (run 5373), as before.
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.

2 participants