Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.nativephp.mobile.ui.nativerender

import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.runtime.compositionLocalOf

/**
Expand All @@ -20,3 +23,20 @@ val LocalAvailableHeight = compositionLocalOf { 844f }
* layer shows through.
*/
val LocalBackgroundLayerPresent = compositionLocalOf { false }

/**
* Scopes needed to place a shared element (`ref`) into a morph.
*
* `Modifier.sharedBounds` is declared on `SharedTransitionScope` and needs the
* `AnimatedVisibilityScope` of the pane it lives in. `NodeView` is a plain
* recursive composable with no receiver, so both arrive through composition
* locals — the same route the safe-area values already take.
*
* Null wherever no host provides them (previews, tests, any tree rendered
* outside `NativeUIContent`), which makes the shared-element path an inert
* pass-through rather than a crash.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null }

val LocalAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?> { null }
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.nativephp.mobile.ui.nativerender

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween
Expand Down Expand Up @@ -28,6 +30,8 @@ import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.platform.LocalView
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
Expand All @@ -37,6 +41,7 @@ import androidx.core.view.WindowInsetsCompat
* Captures safe area insets and viewport size, provides them
* via CompositionLocals, and renders the tree via NodeView.
*/
@OptIn(ExperimentalSharedTransitionApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class)
@Composable
fun NativeUIContent() {
val tree by NativeUIBridge.currentTree
Expand All @@ -57,6 +62,11 @@ fun NativeUIContent() {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
// Publishes every `testTag` in this tree as a resource-id, which
// is the only way UiAutomator — and therefore a Maestro `id:`
// selector — can see a Compose test tag. Set once at the root
// rather than per node.
.semantics { testTagsAsResourceId = true }
.imePadding()
.clickable(
indication = null,
Expand Down Expand Up @@ -95,27 +105,45 @@ fun NativeUIContent() {
// exiting pane keeps the last tree it showed and releases it
// when its exit animation completes.
val treesByKey = remember { HashMap<Int, NativeUITree>() }
AnimatedContent(
targetState = screenKey,
transitionSpec = { transitionFor(pendingTransition) },
label = "screen-transition"
) { key ->
DisposableEffect(key) {
onDispose { treesByKey.remove(key) }
}
val paneTree = if (key == screenKey) {
tree?.also { treesByKey[key] = it }
} else {
treesByKey[key]
}
paneTree?.let { t ->
// Fold any plugin-registered root hosts (side drawers,
// global overlays, …) around the rendered tree. A host
// pulls its own sentinel child out of `t.root` and renders
// nothing when absent. A no-op pass-through when none are
// registered, so trees using no plugin chrome pay nothing.
NativeRootHostRegistry.Wrap(root = t.root) {
NodeView(node = t.root)

// SharedTransitionLayout wraps the swap so elements sharing a
// `ref` across the two panes morph between them. Compose matches
// and animates these itself — unlike iOS, which has no equivalent
// and needs the source/slave roles driven by hand.
//
// A ref present on only one pane simply never matches and renders
// normally, so no pairing set is needed here.
SharedTransitionLayout {
AnimatedContent(
targetState = screenKey,
transitionSpec = { transitionFor(pendingTransition) },
label = "screen-transition"
) { key ->
DisposableEffect(key) {
onDispose { treesByKey.remove(key) }
}
val paneTree = if (key == screenKey) {
tree?.also { treesByKey[key] = it }
} else {
treesByKey[key]
}
paneTree?.let { t ->
// Publish both scopes down the tree: `NodeView` is a
// plain recursive composable and cannot receive them
// any other way.
CompositionLocalProvider(
LocalSharedTransitionScope provides this@SharedTransitionLayout,
LocalAnimatedVisibilityScope provides this@AnimatedContent
) {
// Fold any plugin-registered root hosts (side drawers,
// global overlays, …) around the rendered tree. A host
// pulls its own sentinel child out of `t.root` and renders
// nothing when absent. A no-op pass-through when none are
// registered, so trees using no plugin chrome pay nothing.
NativeRootHostRegistry.Wrap(root = t.root) {
NodeView(node = t.root)
}
}
}
}
}
Expand Down Expand Up @@ -168,6 +196,12 @@ internal fun transitionFor(type: String?): ContentTransform {
// staying visible beneath the incoming screen for a layered depth cue.
"parallax_push" -> (slideInHorizontally(intSpec) { it }) togetherWith
slideOutHorizontally(intSpec) { -it / 3 }
// Shared-element swap: the screens only cross-fade, because the motion
// the user reads comes from elements morphing across (see
// `Modifier.heroMorph`). Duration matches iOS's
// `nativeViewTransitionAnimation` so a morph is paced the same on both
// platforms.
"view_transition" -> fadeIn(tween(350)) togetherWith fadeOut(tween(350))
"none" -> fadeIn(tween(0)) togetherWith fadeOut(tween(0))
else -> fadeIn(spec) togetherWith fadeOut(spec)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package com.nativephp.mobile.ui.nativerender

import androidx.compose.animation.BoundsTransform
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect

/**
* Shared-element morph for nodes carrying a `ref` — the Android half of the
* feature iOS implements in `NodeHeroModifier`.
*
* Identity is the element's `ref`. Two screens naming an element the same
* thing morph it between them during a `view_transition` navigation. A ref
* that exists only as a test handle and must never travel opts out with
* `morph="none"`.
*
* Compose does the heavy lifting here that iOS does not: `SharedTransitionLayout`
* wrapped around the screen-swapping `AnimatedContent` matches keys across the
* two panes and animates the bounds itself. There is no source/slave role to
* assign, no arming, and no timing race — all of which the iOS side needs.
*
* A ref present on only one pane never matches and renders normally, so
* unmatched elements are inert without any pairing set.
*
* ## Parity note: `morph="position"` / `morph="size"`
*
* iOS maps these onto `MatchedGeometryProperties.position` / `.size`, which
* share only half the geometry. Compose's shared-element API always animates
* the full bounds and exposes no equivalent, so both values fall back to the
* default full-bounds morph here. That is a real behavioural difference
* between the platforms, not an oversight — the alternative was to invent an
* approximation that looks like the iOS one without matching it.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Modifier.heroMorph(node: NativeUINode): Modifier {
val ref = node.props.getString("ref", "")
val mode = node.props.getString("morph", "frame")

// Fast path — the overwhelming majority of nodes. No ref, an explicit
// opt-out, or no host providing the scopes (previews, tests, any tree
// rendered outside NativeUIContent).
if (ref.isEmpty() || mode == "none") return this

val sharedScope = LocalSharedTransitionScope.current ?: return this
val animatedScope = LocalAnimatedVisibilityScope.current ?: return this

val spec = node.boundsSpec()

return with(sharedScope) {
this@heroMorph.sharedBounds(
sharedContentState = rememberSharedContentState(key = ref),
animatedVisibilityScope = animatedScope,
boundsTransform = BoundsTransform { _, _ -> spec }
)
}
}

/**
* `morph-duration` (ms) and `morph-easing` for one element, falling back to a
* 350ms ease-in-out that matches iOS's shared view-transition pace so an
* untuned morph looks the same on both platforms.
*
* Deliberately distinct from `animate-duration` / `animate-easing`, which
* drive state-change transforms: an element may want a 200ms press response
* and a 600ms morph.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
private fun NativeUINode.boundsSpec(): FiniteAnimationSpec<Rect> {
val duration = props.getFloat("morph_duration", 0f)
val easing = props.getString("morph_easing", "")

if (easing == "spring") {
return spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = Rect.VisibilityThreshold
)
}

return tween(
durationMillis = if (duration > 0f) duration.toInt() else 350,
easing = when (easing) {
"linear" -> LinearEasing
"ease-in" -> FastOutLinearInEasing
"ease-out" -> LinearOutSlowInEasing
else -> FastOutSlowInEasing
}
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.unit.dp

/**
Expand Down Expand Up @@ -246,6 +248,8 @@ fun NodeView(node: NativeUINode, overrideModifier: Modifier? = null) {
// ── Modifier chain ───────────────────────────────────────────
// Outer to inner:
// base (sizing)
// → heroMorph (shared-element bounds; must wrap
// the background, not sit inside it)
// → press feedback graphicsLayer (wraps everything for visual scale/alpha on press)
// → animation graphicsLayer (wraps bg too so translate/alpha affect the box, not just inner content)
// → nodeStyle (background + border)
Expand All @@ -254,6 +258,20 @@ fun NodeView(node: NativeUINode, overrideModifier: Modifier? = null) {
// → nodeLayout (padding)
var modifier: Modifier = base

// Shared-element morph goes FIRST, i.e. outermost.
//
// Compose modifiers wrap outer→inner, so anything applied before
// `sharedBounds` is measured and drawn OUTSIDE the animated bounds.
// Applied last it moved only the node's children: on the three-hop
// chain the number travelled while its coloured box stayed put.
// Outermost, the background, border, clip, padding and content all
// ride the animated bounds together.
//
// Note this is the OPPOSITE order from iOS, where the equivalent
// modifier sits late in the chain — SwiftUI applies modifiers
// outward, so "last" there means the same thing "first" means here.
modifier = modifier.heroMorph(node)

if (hasPressFeedback) {
modifier = modifier.graphicsLayer {
scaleX = pressAnimScale
Expand Down Expand Up @@ -291,6 +309,21 @@ fun NodeView(node: NativeUINode, overrideModifier: Modifier? = null) {
.nodeGestures(node, interactionSource)
.nodeLayout(node.layout, safeAreaTop, safeAreaBottom, availableWidth, availableHeight)

// `ref` is both the test-targeting handle (Maestro / Compose UI tests)
// AND the shared-element identity: two screens naming an element the
// same thing morph it between them under a `view_transition`.
val ref = node.props.getString("ref", "")
if (ref.isNotEmpty()) {
// testTag ONLY — deliberately not contentDescription. Overwriting
// the description with an internal handle makes TalkBack announce
// "photo-1" in place of whatever the element actually is, which
// trades a real accessibility affordance for a test convenience.
// The tag reaches UiAutomator (and therefore Maestro `id:`) via
// `testTagsAsResourceId` set once at the root in NativeUIContent.
modifier = modifier.semantics { testTag = ref }
}


// In-place text change animation (`content_transition` — numeric
// roll / crossfade). Wraps the content in AnimatedContent keyed on
// the text prop; absent prop → straight render, hot path unchanged.
Expand Down
11 changes: 11 additions & 0 deletions resources/xcode/NativePHP/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ struct ContentView: View {
x: -10
)
.transition(nativeScreenTransition(for: nativeUIBridge.pendingTransition))
// Shared-element plumbing. A screen on its way
// out must stop reporting frames — the store
// captured them at the swap and is flying from
// them right now.
.environment(\.heroIsOutgoing, screen.isOutgoing)
// Each new screen sits above the previous one
// (keys increment), so slides cover in push
// order and a fade has a defined front/back.
Expand All @@ -81,6 +86,12 @@ struct ContentView: View {
screenBackground.ignoresSafeArea()
}
}
// Shared elements in transit, drawn ABOVE both screens so a morph is
// never occluded by the incoming screen fading in over it. Renders
// nothing at all when no element is flying.
.overlay {
HeroFlightOverlay()
}
.overlay(alignment: .top) {
// Hot-reload indicator. Mirrors iOS 26's Liquid Glass pill
// language so it feels native and doesn't intrude on the
Expand Down
Loading
Loading