Repository navigation
Shared-element morph transitions (ref + @navigate.morph) - #372
Merged
Merged
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>
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>
ref + @navigate.viewTransition)ref + @navigate.morph)
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.
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
morphnavigation, and onlythen. The opt-out is
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
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 andvariant prop builders sit next to
buildHeroProps) andSwiftUINodeRenderer.Main's #576 now skips modifiers a node doesn't use, so the hero and identity
modifiers are wired in through a new
.refdecoration. Nodes without a ref nolonger each observe
HeroFlightStore.Main's #580 (a11y labels on containers) changed things under this branch
without a conflict, and two fixes were needed:
NodeIdentityModifiernow sits outsideNodeAccessibilityModifierandonly 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.
reftest tag moved outsidenodeAccessibility. ItsclearAndSetSemanticsdropped the tag whenever the node had ana11y-label.9969bdb, the rename.
@navigate.viewTransitionis now@navigate.morph,Transition::ViewTransitionisTransition::Morph, and the wire value ismorph. Nothing shipped with the old name, so there's no alias.The same commit stops Android morphing under other transitions. Its
SharedTransitionLayoutwrapped every screen swap, so matching refs morphedduring 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