@unrulysystems/native-motion 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/unrulysystems/nativemotion/GestureExclusionModule.kt +55 -0
- package/android/src/main/java/com/unrulysystems/nativemotion/NativeMotionPackage.kt +27 -0
- package/package.json +57 -0
- package/react-native.config.js +14 -0
- package/src/native/driver/instantWindow.ts +31 -0
- package/src/native/driver/strictModeReplay.ts +20 -0
- package/src/native/driver/uiLayoutEngine.ts +845 -0
- package/src/native/driver/uiLayoutGraph.ts +338 -0
- package/src/native/driver/uiValueChannel.ts +4640 -0
- package/src/native/driver/workletDriver.ts +3860 -0
- package/src/native/motion/AnimatePresence.tsx +1605 -0
- package/src/native/motion/LayoutGroup.tsx +163 -0
- package/src/native/motion/MotionConfig.tsx +400 -0
- package/src/native/motion/MotionRoot.tsx +250 -0
- package/src/native/motion/MotionView.tsx +6761 -0
- package/src/native/motion/addScaleCorrector.ts +97 -0
- package/src/native/motion/colorEndpointUiFeed.ts +32 -0
- package/src/native/motion/colorProjection.ts +39 -0
- package/src/native/motion/composeTransform.ts +271 -0
- package/src/native/motion/constraintMeasure.ts +157 -0
- package/src/native/motion/deferredPendingSnapshots.ts +25 -0
- package/src/native/motion/discreteProjection.ts +36 -0
- package/src/native/motion/dragAncestorPanContext.ts +28 -0
- package/src/native/motion/dragControls.ts +152 -0
- package/src/native/motion/dragGestureWiring.ts +1168 -0
- package/src/native/motion/dragHandoffBinding.ts +179 -0
- package/src/native/motion/dragHubStream.ts +756 -0
- package/src/native/motion/dragPropagationLock.ts +57 -0
- package/src/native/motion/driverValueChannel.ts +3288 -0
- package/src/native/motion/externalDragDriver.ts +214 -0
- package/src/native/motion/frameData.ts +31 -0
- package/src/native/motion/gestureBinding.ts +107 -0
- package/src/native/motion/gestureStateGate.ts +563 -0
- package/src/native/motion/gestureStateResolver.ts +286 -0
- package/src/native/motion/identityValueChannelControllerAdapter.ts +2043 -0
- package/src/native/motion/identityValueChannelControllerReconciliation.ts +176 -0
- package/src/native/motion/identityValueChannelLaneMarker.ts +14 -0
- package/src/native/motion/imperativeAnimate.ts +816 -0
- package/src/native/motion/keyframeTiming.ts +7 -0
- package/src/native/motion/layoutIdBinding.ts +261 -0
- package/src/native/motion/layoutIdFlightConfig.ts +51 -0
- package/src/native/motion/layoutProjection.ts +89 -0
- package/src/native/motion/layoutScroll.ts +204 -0
- package/src/native/motion/layoutTransition.ts +1302 -0
- package/src/native/motion/lengthLayoutContext.tsx +100 -0
- package/src/native/motion/lengthLayoutHost.ts +44 -0
- package/src/native/motion/mappedKeys.ts +184 -0
- package/src/native/motion/motionViewController.ts +3180 -0
- package/src/native/motion/nativeHostMarker.ts +22 -0
- package/src/native/motion/panSessionWiring.ts +127 -0
- package/src/native/motion/pathTransition.ts +101 -0
- package/src/native/motion/popLayout.ts +176 -0
- package/src/native/motion/presenceBinding.ts +382 -0
- package/src/native/motion/scaleCorrectorRegistry.ts +123 -0
- package/src/native/motion/scrollValues.ts +260 -0
- package/src/native/motion/serializablePayload.ts +151 -0
- package/src/native/motion/severity.ts +16 -0
- package/src/native/motion/shippedSurface.ts +843 -0
- package/src/native/motion/staticLengthGate.ts +86 -0
- package/src/native/motion/styleBaseGate.ts +72 -0
- package/src/native/motion/styleValueBinding.ts +657 -0
- package/src/native/motion/systemGestureExclusion.ts +61 -0
- package/src/native/motion/tapGestureWiring.ts +215 -0
- package/src/native/motion/transformOrder.ts +38 -0
- package/src/native/motion/transformStringBinding.ts +200 -0
- package/src/native/motion/transformTemplateGate.ts +45 -0
- package/src/native/motion/transitionGate.ts +435 -0
- package/src/native/motion/useAnimate.ts +52 -0
- package/src/native/motion/useCycle.ts +44 -0
- package/src/native/motion/useInstantTransition.ts +68 -0
- package/src/native/motion/useReducedMotion.ts +58 -0
- package/src/native/motion/useScroll.ts +162 -0
- package/src/native/motion/useViewportScroll.ts +29 -0
- package/src/native/motion/valueChannel.ts +7121 -0
- package/src/native/motion/valueHooks.ts +901 -0
- package/src/native/motion/variantChildRegistry.ts +67 -0
- package/src/native/motion/variantContext.tsx +172 -0
- package/src/native/motion/variantProps.ts +598 -0
- package/src/native.ts +159 -0
- package/src/verification/harnessMetrics.ts +78 -0
- package/src/verification/probe/LayoutIdentityWorkletProbe.tsx +251 -0
- package/src/verification/probe/WorkletParityProbe.tsx +162 -0
- package/src/verification/probe/layoutIdentityProbeEngine.ts +429 -0
- package/src/verification/screens/ArcPathChecksScreen.tsx +273 -0
- package/src/verification/screens/BooleanAnimateChecksScreen.tsx +358 -0
- package/src/verification/screens/ChoreographyGalleryScreen.tsx +1010 -0
- package/src/verification/screens/ColorBindingChecksScreen.tsx +568 -0
- package/src/verification/screens/CompletionChecksScreen.tsx +294 -0
- package/src/verification/screens/ConformanceScreen.tsx +573 -0
- package/src/verification/screens/ContentionProbeScreen.tsx +224 -0
- package/src/verification/screens/DriverSmokeScreen.tsx +99 -0
- package/src/verification/screens/DurationOnlyTweenChecksScreen.tsx +294 -0
- package/src/verification/screens/DynamicDragConfigChecksScreen.tsx +237 -0
- package/src/verification/screens/FrameDataChecksScreen.tsx +252 -0
- package/src/verification/screens/GestureChecksScreen.tsx +685 -0
- package/src/verification/screens/InstantTransitionChecksScreen.tsx +839 -0
- package/src/verification/screens/LayoutAnimationStartChecksScreen.tsx +648 -0
- package/src/verification/screens/LayoutChecksScreen.tsx +822 -0
- package/src/verification/screens/LayoutCommitSpikeScreen.tsx +142 -0
- package/src/verification/screens/MotionViewChecksScreen.tsx +930 -0
- package/src/verification/screens/PresenceChecksScreen.tsx +614 -0
- package/src/verification/screens/ReducedMotionChecksScreen.tsx +614 -0
- package/src/verification/screens/RestThresholdChecksScreen.tsx +634 -0
- package/src/verification/screens/ScaleCorrectorChecksScreen.tsx +425 -0
- package/src/verification/screens/SharedLayoutContinuityChecksScreen.tsx +338 -0
- package/src/verification/screens/SharedLayoutCrossfadeChecksScreen.tsx +425 -0
- package/src/verification/screens/TransitionDefaultSelectionChecksScreen.tsx +514 -0
- package/src/verification/screens/ViewportScrollAliasChecksScreen.tsx +303 -0
- package/src/verification/screens/conformanceBanner.ts +21 -0
- package/src/verification/screens/proofConsoleTap.ts +18 -0
- package/src/verification.ts +47 -0
- package/src/web.ts +98 -0
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
// The choreography gallery — ENGINE-VERIFICATION, not a consumer example
|
|
2
|
+
// (specs/DOGFOOD-MIGRATION-BUILD-PACKET.md M3 split). This is the former native examples
|
|
3
|
+
// gallery (M2.6b) kept wholesale as the engine's device-proof choreography vehicle: it carries
|
|
4
|
+
// the L4 App Store card machine verdict (`L4PORT_APPSTORE` release-edge lines) and the
|
|
5
|
+
// BRIEF-ratified lifecycle-soak leg (seven-swap navigation, bounded driverStates, empty crash
|
|
6
|
+
// buffer). It hand-wires bindings and the substrate BY ROLE — engine verification is exempt
|
|
7
|
+
// from the consumer-isolation ban only by living in this package (REQ-EXHARNESS-010). The
|
|
8
|
+
// consumer documentation-by-example gallery lives in the app, authored on the public
|
|
9
|
+
// native-motion surface only; examples the public surface cannot express yet are BLOCKED there
|
|
10
|
+
// and tracked as packet FLAGs, never hand-rolled.
|
|
11
|
+
//
|
|
12
|
+
// Mirrors the web gallery (packages/native-motion-web/e2e/fixtures/examples.tsx)
|
|
13
|
+
// example-for-example so cross-engine behavior is eyeballable side by side. ONE example renders
|
|
14
|
+
// at a time (paged, not scrolled): iOS simulator screenshots are captured without UI
|
|
15
|
+
// automation, so every screenshot must self-describe — the header names the example, the
|
|
16
|
+
// status line carries the live evidence.
|
|
17
|
+
//
|
|
18
|
+
// AUTO-RUN drives each example's timeline programmatically then advances to the next card. iOS
|
|
19
|
+
// starts it hands-free after 1.5 s (no CLI touch injection — the M2.4 precedent); Android is
|
|
20
|
+
// driven by real adb taps/swipes, with AUTO-RUN available as a fallback.
|
|
21
|
+
//
|
|
22
|
+
// Known, ratified asymmetry surfaced here deliberately: the native MotionView spike maps exactly
|
|
23
|
+
// x/y/scale/rotate/opacity (M2.2 §semantics 6), while the web PASS table also animates colors,
|
|
24
|
+
// radii and layout props — so the native morph card uses scale + rotate and SAYS so on screen.
|
|
25
|
+
import { useEffect, useRef, useState } from 'react'
|
|
26
|
+
import { Platform, Pressable, StyleSheet, Text, View, type LayoutChangeEvent } from 'react-native'
|
|
27
|
+
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
|
|
28
|
+
import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
|
|
29
|
+
import {
|
|
30
|
+
ManualClock,
|
|
31
|
+
ManualScheduler,
|
|
32
|
+
correctRadius,
|
|
33
|
+
counterScale,
|
|
34
|
+
createMotionGraph,
|
|
35
|
+
type ElementHandle,
|
|
36
|
+
} from '@unrulysystems/native-motion-core'
|
|
37
|
+
import { uiDriverStateCount, useWorkletDriver } from '../../native/driver/workletDriver'
|
|
38
|
+
import { AnimatePresence } from '../../native/motion/AnimatePresence'
|
|
39
|
+
import {
|
|
40
|
+
createLayoutIdentityBinding,
|
|
41
|
+
type LayoutIdentityBinding,
|
|
42
|
+
type LayoutIdentityMeasureHandle,
|
|
43
|
+
} from '../../native/motion/layoutIdBinding'
|
|
44
|
+
import {
|
|
45
|
+
createGestureBinding,
|
|
46
|
+
routePanEnd,
|
|
47
|
+
routePanFinalize,
|
|
48
|
+
type GestureBinding,
|
|
49
|
+
} from '../../native/motion/gestureBinding'
|
|
50
|
+
import { createLayoutProjection, type LayoutProjection } from '../../native/motion/layoutProjection'
|
|
51
|
+
import { MotionView } from '../../native/motion/MotionView'
|
|
52
|
+
import { schedulerFrameDelta } from '../harnessMetrics'
|
|
53
|
+
|
|
54
|
+
// The house spring of the proof suite — the same constants the web gallery and the Chromium
|
|
55
|
+
// trajectory gate use, so cross-engine motion is directly comparable by eye.
|
|
56
|
+
const SPRING = { type: 'spring', stiffness: 550, damping: 45 } as const
|
|
57
|
+
// Softer demo springs where a ~1 s flight makes screenshots/video legible (M2.3/M2.5 precedent).
|
|
58
|
+
const DEMO_SPRING = { type: 'spring', stiffness: 60, damping: 14 } as const
|
|
59
|
+
|
|
60
|
+
const EXAMPLES = [
|
|
61
|
+
'spring-toggle',
|
|
62
|
+
'fade',
|
|
63
|
+
'style-morph',
|
|
64
|
+
'drag-snap',
|
|
65
|
+
'presence',
|
|
66
|
+
'flip-swap',
|
|
67
|
+
'interrupt',
|
|
68
|
+
'appstore-card',
|
|
69
|
+
] as const
|
|
70
|
+
type ExampleId = (typeof EXAMPLES)[number]
|
|
71
|
+
|
|
72
|
+
interface ExampleProps {
|
|
73
|
+
// Auto-run: the example drives its own timeline once, then reports completion.
|
|
74
|
+
readonly auto: boolean
|
|
75
|
+
readonly onAutoDone: () => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Fire a timeline once when auto-run engages; the LAST step should call onAutoDone.
|
|
79
|
+
function useAutoTimeline(
|
|
80
|
+
auto: boolean,
|
|
81
|
+
steps: readonly { readonly at: number; readonly run: () => void }[],
|
|
82
|
+
): void {
|
|
83
|
+
const fired = useRef(false)
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!auto || fired.current) return
|
|
86
|
+
fired.current = true
|
|
87
|
+
const timers = steps.map((step) => setTimeout(step.run, step.at))
|
|
88
|
+
return () => {
|
|
89
|
+
for (const timer of timers) clearTimeout(timer)
|
|
90
|
+
}
|
|
91
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
92
|
+
}, [auto])
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function requireLayoutIdentityMeasureHandle(
|
|
96
|
+
ref: { readonly current: LayoutIdentityMeasureHandle | null },
|
|
97
|
+
label: string,
|
|
98
|
+
): LayoutIdentityMeasureHandle {
|
|
99
|
+
const handle = ref.current
|
|
100
|
+
if (handle === null) {
|
|
101
|
+
throw new Error(`layout identity '${label}': measureInWindow handle unavailable on layout.`)
|
|
102
|
+
}
|
|
103
|
+
return handle
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 1. Spring toggle — x 0 ↔ 240 through the worklet driver (tap TOGGLE, or auto).
|
|
107
|
+
function SpringToggleExample({ auto, onAutoDone }: ExampleProps) {
|
|
108
|
+
const [on, setOn] = useState(false)
|
|
109
|
+
const [settles, setSettles] = useState(0)
|
|
110
|
+
useAutoTimeline(auto, [
|
|
111
|
+
{ at: 200, run: () => setOn(true) },
|
|
112
|
+
{ at: 1200, run: () => setOn(false) },
|
|
113
|
+
{ at: 2400, run: onAutoDone },
|
|
114
|
+
])
|
|
115
|
+
return (
|
|
116
|
+
<View style={styles.exampleBody}>
|
|
117
|
+
<Pressable
|
|
118
|
+
style={styles.button}
|
|
119
|
+
accessibilityLabel="spring-toggle-button"
|
|
120
|
+
onPress={() => setOn((v) => !v)}
|
|
121
|
+
>
|
|
122
|
+
<Text style={styles.buttonText}>TOGGLE → {on ? 0 : 240}</Text>
|
|
123
|
+
</Pressable>
|
|
124
|
+
<View style={styles.stage}>
|
|
125
|
+
<MotionView
|
|
126
|
+
animate={{ x: on ? 240 : 0 }}
|
|
127
|
+
transition={SPRING}
|
|
128
|
+
onAnimationComplete={() => setSettles((n) => n + 1)}
|
|
129
|
+
style={[styles.box, { backgroundColor: '#7c3aed' }]}
|
|
130
|
+
/>
|
|
131
|
+
</View>
|
|
132
|
+
<Text style={styles.status}>
|
|
133
|
+
target: {on ? 240 : 0} · settles: {settles}
|
|
134
|
+
</Text>
|
|
135
|
+
</View>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 2. Fade — opacity 1 ↔ 0.25 (same target shape as the web card; native runs the JS→worklet
|
|
140
|
+
// value path, web runs WAAPI — the visual result must be indistinguishable).
|
|
141
|
+
function FadeExample({ auto, onAutoDone }: ExampleProps) {
|
|
142
|
+
const [on, setOn] = useState(false)
|
|
143
|
+
const [settles, setSettles] = useState(0)
|
|
144
|
+
useAutoTimeline(auto, [
|
|
145
|
+
{ at: 200, run: () => setOn(true) },
|
|
146
|
+
{ at: 1200, run: () => setOn(false) },
|
|
147
|
+
{ at: 2400, run: onAutoDone },
|
|
148
|
+
])
|
|
149
|
+
return (
|
|
150
|
+
<View style={styles.exampleBody}>
|
|
151
|
+
<Pressable style={styles.button} onPress={() => setOn((v) => !v)}>
|
|
152
|
+
<Text style={styles.buttonText}>FADE → {on ? 1 : 0.25}</Text>
|
|
153
|
+
</Pressable>
|
|
154
|
+
<View style={styles.stage}>
|
|
155
|
+
<MotionView
|
|
156
|
+
animate={{ opacity: on ? 0.25 : 1 }}
|
|
157
|
+
transition={SPRING}
|
|
158
|
+
onAnimationComplete={() => setSettles((n) => n + 1)}
|
|
159
|
+
style={[styles.box, { backgroundColor: '#0ea5e9' }]}
|
|
160
|
+
/>
|
|
161
|
+
</View>
|
|
162
|
+
<Text style={styles.status}>
|
|
163
|
+
target opacity: {on ? 0.25 : 1} · settles: {settles}
|
|
164
|
+
</Text>
|
|
165
|
+
</View>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 3. Style morph — scale + rotate + borderRadius + backgroundColor through ONE target: L1
|
|
170
|
+
// (specs/L1-BUILD-PACKET.md) closing the M2.6b cross-engine surface delta. THIS card is the
|
|
171
|
+
// rung's RED anchor — it crashed the release app on borderRadius under the M2.2 spike guard
|
|
172
|
+
// (bug-bash record) and now matches the web card's full morph.
|
|
173
|
+
function StyleMorphExample({ auto, onAutoDone }: ExampleProps) {
|
|
174
|
+
const [on, setOn] = useState(false)
|
|
175
|
+
const [settles, setSettles] = useState(0)
|
|
176
|
+
useAutoTimeline(auto, [
|
|
177
|
+
{ at: 200, run: () => setOn(true) },
|
|
178
|
+
{ at: 1400, run: () => setOn(false) },
|
|
179
|
+
{ at: 2800, run: onAutoDone },
|
|
180
|
+
])
|
|
181
|
+
return (
|
|
182
|
+
<View style={styles.exampleBody}>
|
|
183
|
+
<Pressable style={styles.button} onPress={() => setOn((v) => !v)}>
|
|
184
|
+
<Text style={styles.buttonText}>MORPH</Text>
|
|
185
|
+
</Pressable>
|
|
186
|
+
<View style={styles.stage}>
|
|
187
|
+
<MotionView
|
|
188
|
+
animate={{
|
|
189
|
+
scale: on ? 1.2 : 1,
|
|
190
|
+
rotate: on ? 45 : 0,
|
|
191
|
+
borderRadius: on ? 40 : 8,
|
|
192
|
+
backgroundColor: on ? '#f59e0b' : '#7c3aed',
|
|
193
|
+
}}
|
|
194
|
+
transition={SPRING}
|
|
195
|
+
onAnimationComplete={() => setSettles((n) => n + 1)}
|
|
196
|
+
style={[styles.box, { backgroundColor: '#7c3aed' }]}
|
|
197
|
+
/>
|
|
198
|
+
</View>
|
|
199
|
+
<Text style={styles.status}>
|
|
200
|
+
scale: {on ? 1.2 : 1} · rotate: {on ? 45 : 0} · radius: {on ? 40 : 8} · settles: {settles}
|
|
201
|
+
</Text>
|
|
202
|
+
<Text style={styles.note}>
|
|
203
|
+
full morph incl. radius + color — the L1 rung (was the M2.6b crash)
|
|
204
|
+
</Text>
|
|
205
|
+
</View>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 4. Drag with snap — the M2.4 binding as an EXAMPLE: constraints 0..240 elastic 0.4, snap
|
|
210
|
+
// [0, 240]. Android: real touch. Auto-run: the recorded stream replay (the M2.4 iOS leg).
|
|
211
|
+
function DragSnapExample({ auto, onAutoDone }: ExampleProps) {
|
|
212
|
+
const [label, setLabel] = useState({ value: 0, state: 'idle' })
|
|
213
|
+
const shared = useSharedValue(0)
|
|
214
|
+
|
|
215
|
+
const rigRef = useRef<{ binding: GestureBinding; scheduler: ManualScheduler } | null>(null)
|
|
216
|
+
if (rigRef.current === null) {
|
|
217
|
+
const clock = new ManualClock()
|
|
218
|
+
const scheduler = new ManualScheduler(clock)
|
|
219
|
+
const graph = createMotionGraph({ clock, scheduler })
|
|
220
|
+
rigRef.current = {
|
|
221
|
+
binding: createGestureBinding({
|
|
222
|
+
graph,
|
|
223
|
+
clock,
|
|
224
|
+
initial: 0,
|
|
225
|
+
constraints: { min: 0, max: 240, elastic: 0.4 },
|
|
226
|
+
snap: { points: [0, 240] },
|
|
227
|
+
}),
|
|
228
|
+
scheduler,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const { binding, scheduler } = rigRef.current
|
|
232
|
+
|
|
233
|
+
const stepperRef = useRef<{ running: boolean; raf: number; last: number; frames: number }>({
|
|
234
|
+
running: false,
|
|
235
|
+
raf: 0,
|
|
236
|
+
last: 0,
|
|
237
|
+
frames: 0,
|
|
238
|
+
})
|
|
239
|
+
const refreshLabel = (): void => {
|
|
240
|
+
setLabel({ value: binding.session.value(), state: binding.session.state() })
|
|
241
|
+
}
|
|
242
|
+
const startStepper = (): void => {
|
|
243
|
+
const stepper = stepperRef.current
|
|
244
|
+
if (stepper.running) return
|
|
245
|
+
stepper.running = true
|
|
246
|
+
stepper.last = 0
|
|
247
|
+
const loop = (now: number): void => {
|
|
248
|
+
const dt = schedulerFrameDelta(stepper.last, now)
|
|
249
|
+
stepper.last = now
|
|
250
|
+
if (dt > 0) scheduler.frame(dt)
|
|
251
|
+
shared.value = binding.session.value()
|
|
252
|
+
stepper.frames += 1
|
|
253
|
+
if (stepper.frames % 6 === 0) refreshLabel()
|
|
254
|
+
if (binding.session.isSettled()) {
|
|
255
|
+
stepper.running = false
|
|
256
|
+
refreshLabel()
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
stepper.raf = requestAnimationFrame(loop)
|
|
260
|
+
}
|
|
261
|
+
stepper.raf = requestAnimationFrame(loop)
|
|
262
|
+
}
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
return () => cancelAnimationFrame(stepperRef.current.raf)
|
|
265
|
+
}, [])
|
|
266
|
+
|
|
267
|
+
const pan = Gesture.Pan()
|
|
268
|
+
.runOnJS(true)
|
|
269
|
+
.onStart((event) => {
|
|
270
|
+
startStepper()
|
|
271
|
+
binding.onStart(event)
|
|
272
|
+
})
|
|
273
|
+
.onUpdate((event) => {
|
|
274
|
+
binding.onUpdate(event)
|
|
275
|
+
shared.value = binding.session.value()
|
|
276
|
+
})
|
|
277
|
+
.onEnd((event, success) => {
|
|
278
|
+
routePanEnd(binding, event, success)
|
|
279
|
+
startStepper()
|
|
280
|
+
})
|
|
281
|
+
.onFinalize((_event, success) => {
|
|
282
|
+
routePanFinalize(binding, success)
|
|
283
|
+
startStepper()
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
// Auto-run: the M2.4 recorded stream — sweep to 200 then release flung at +900 px/s → snap 240.
|
|
287
|
+
useAutoTimeline(auto, [
|
|
288
|
+
{
|
|
289
|
+
at: 300,
|
|
290
|
+
run: () => {
|
|
291
|
+
startStepper()
|
|
292
|
+
binding.onStart({ translationX: 0, velocityX: 0 })
|
|
293
|
+
let step = 0
|
|
294
|
+
const feed = setInterval(() => {
|
|
295
|
+
step += 1
|
|
296
|
+
binding.onUpdate({ translationX: (200 * step) / 24, velocityX: 600 })
|
|
297
|
+
shared.value = binding.session.value()
|
|
298
|
+
if (step >= 24) {
|
|
299
|
+
clearInterval(feed)
|
|
300
|
+
binding.onEnd({ translationX: 200, velocityX: 900 })
|
|
301
|
+
startStepper()
|
|
302
|
+
}
|
|
303
|
+
}, 24)
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
{ at: 2600, run: onAutoDone },
|
|
307
|
+
])
|
|
308
|
+
|
|
309
|
+
const boxStyle = useAnimatedStyle(() => ({ transform: [{ translateX: shared.value }] }))
|
|
310
|
+
return (
|
|
311
|
+
<View style={styles.exampleBody}>
|
|
312
|
+
<GestureDetector gesture={pan}>
|
|
313
|
+
<View style={styles.track} collapsable={false}>
|
|
314
|
+
<View style={[styles.snapMarker, { left: 0 }]} />
|
|
315
|
+
<View style={[styles.snapMarker, { left: 240 }]} />
|
|
316
|
+
<Animated.View style={[styles.box, { backgroundColor: '#f59e0b' }, boxStyle]} />
|
|
317
|
+
</View>
|
|
318
|
+
</GestureDetector>
|
|
319
|
+
<Text style={styles.status}>
|
|
320
|
+
value: {label.value.toFixed(1)} · state: {label.state}
|
|
321
|
+
</Text>
|
|
322
|
+
<Text style={styles.note}>
|
|
323
|
+
{auto ? 'auto: replayed stream (M2.4 iOS leg)' : 'drag the box — snap [0, 240]'}
|
|
324
|
+
</Text>
|
|
325
|
+
</View>
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 5. Presence — enter from initial, exit runs before unmount, removal exactly at settle.
|
|
330
|
+
function PresenceExample({ auto, onAutoDone }: ExampleProps) {
|
|
331
|
+
const [nextId, setNextId] = useState(1)
|
|
332
|
+
const [items, setItems] = useState<readonly number[]>([])
|
|
333
|
+
const [exits, setExits] = useState(0)
|
|
334
|
+
const add = (): void => {
|
|
335
|
+
setItems((list) => (list.length >= 3 ? list : [...list, nextId]))
|
|
336
|
+
setNextId((n) => n + 1)
|
|
337
|
+
}
|
|
338
|
+
const remove = (): void => setItems((list) => list.slice(0, -1))
|
|
339
|
+
useAutoTimeline(auto, [
|
|
340
|
+
{ at: 200, run: add },
|
|
341
|
+
{ at: 900, run: add },
|
|
342
|
+
{ at: 1800, run: remove },
|
|
343
|
+
{ at: 3200, run: remove },
|
|
344
|
+
{ at: 4800, run: onAutoDone },
|
|
345
|
+
])
|
|
346
|
+
return (
|
|
347
|
+
<View style={styles.exampleBody}>
|
|
348
|
+
<View style={styles.buttonRow}>
|
|
349
|
+
<Pressable style={styles.button} onPress={add}>
|
|
350
|
+
<Text style={styles.buttonText}>ADD</Text>
|
|
351
|
+
</Pressable>
|
|
352
|
+
<Pressable style={styles.button} onPress={remove}>
|
|
353
|
+
<Text style={styles.buttonText}>REMOVE</Text>
|
|
354
|
+
</Pressable>
|
|
355
|
+
</View>
|
|
356
|
+
<View style={styles.presenceRow}>
|
|
357
|
+
<AnimatePresence onExitComplete={() => setExits((n) => n + 1)}>
|
|
358
|
+
{items.map((id) => (
|
|
359
|
+
<MotionView
|
|
360
|
+
key={String(id)}
|
|
361
|
+
initial={{ opacity: 0, y: -16, x: 0 }}
|
|
362
|
+
animate={{ opacity: 1, y: 0, x: 0 }}
|
|
363
|
+
exit={{ opacity: 0, x: 80 }}
|
|
364
|
+
transition={DEMO_SPRING}
|
|
365
|
+
style={[styles.itemBox, { backgroundColor: '#14b8a6' }]}
|
|
366
|
+
>
|
|
367
|
+
<Text style={styles.itemText}>#{id}</Text>
|
|
368
|
+
</MotionView>
|
|
369
|
+
))}
|
|
370
|
+
</AnimatePresence>
|
|
371
|
+
</View>
|
|
372
|
+
<Text style={styles.status}>
|
|
373
|
+
items: {items.length} · exits completed: {exits}
|
|
374
|
+
</Text>
|
|
375
|
+
<Text style={styles.note}>demo spring 60/14 — exits visible, removal exactly at settle</Text>
|
|
376
|
+
</View>
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 6. FLIP swap — the M2.5 projection as an EXAMPLE: the box's LAYOUT alternates (alignSelf +
|
|
381
|
+
// width) and the projection springs between layouts instead of teleporting.
|
|
382
|
+
function FlipSwapExample({ auto, onAutoDone }: ExampleProps) {
|
|
383
|
+
const [flipped, setFlipped] = useState(false)
|
|
384
|
+
const [label, setLabel] = useState({ state: 'idle', settles: 0 })
|
|
385
|
+
const tx = useSharedValue(0)
|
|
386
|
+
const ty = useSharedValue(0)
|
|
387
|
+
const sx = useSharedValue(1)
|
|
388
|
+
const sy = useSharedValue(1)
|
|
389
|
+
|
|
390
|
+
const rigRef = useRef<{ projection: LayoutProjection; scheduler: ManualScheduler } | null>(null)
|
|
391
|
+
if (rigRef.current === null) {
|
|
392
|
+
const clock = new ManualClock()
|
|
393
|
+
const scheduler = new ManualScheduler(clock)
|
|
394
|
+
const graph = createMotionGraph({ clock, scheduler })
|
|
395
|
+
rigRef.current = {
|
|
396
|
+
projection: createLayoutProjection({
|
|
397
|
+
graph,
|
|
398
|
+
token: 'examples-flip',
|
|
399
|
+
spring: { stiffness: 120, damping: 16 },
|
|
400
|
+
}),
|
|
401
|
+
scheduler,
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const { projection, scheduler } = rigRef.current
|
|
405
|
+
const settlesRef = useRef(0)
|
|
406
|
+
|
|
407
|
+
const stepperRef = useRef<{ running: boolean; raf: number; last: number; frames: number }>({
|
|
408
|
+
running: false,
|
|
409
|
+
raf: 0,
|
|
410
|
+
last: 0,
|
|
411
|
+
frames: 0,
|
|
412
|
+
})
|
|
413
|
+
const applyTransform = (): void => {
|
|
414
|
+
const t = projection.session.transform()
|
|
415
|
+
tx.value = t.translateX
|
|
416
|
+
ty.value = t.translateY
|
|
417
|
+
sx.value = t.scaleX
|
|
418
|
+
sy.value = t.scaleY
|
|
419
|
+
}
|
|
420
|
+
const refreshLabel = (): void => {
|
|
421
|
+
setLabel({ state: projection.session.state(), settles: settlesRef.current })
|
|
422
|
+
}
|
|
423
|
+
const startStepper = (): void => {
|
|
424
|
+
const stepper = stepperRef.current
|
|
425
|
+
if (stepper.running) return
|
|
426
|
+
stepper.running = true
|
|
427
|
+
stepper.last = 0
|
|
428
|
+
const loop = (now: number): void => {
|
|
429
|
+
const dt = schedulerFrameDelta(stepper.last, now)
|
|
430
|
+
stepper.last = now
|
|
431
|
+
if (dt > 0) scheduler.frame(dt)
|
|
432
|
+
applyTransform()
|
|
433
|
+
stepper.frames += 1
|
|
434
|
+
if (stepper.frames % 6 === 0) refreshLabel()
|
|
435
|
+
if (projection.session.isSettled()) {
|
|
436
|
+
stepper.running = false
|
|
437
|
+
settlesRef.current += 1
|
|
438
|
+
applyTransform()
|
|
439
|
+
refreshLabel()
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
stepper.raf = requestAnimationFrame(loop)
|
|
443
|
+
}
|
|
444
|
+
stepper.raf = requestAnimationFrame(loop)
|
|
445
|
+
}
|
|
446
|
+
useEffect(() => {
|
|
447
|
+
return () => cancelAnimationFrame(stepperRef.current.raf)
|
|
448
|
+
}, [])
|
|
449
|
+
|
|
450
|
+
const onBoxLayout = (event: LayoutChangeEvent): void => {
|
|
451
|
+
projection.onLayoutRect(event.nativeEvent.layout)
|
|
452
|
+
if (!projection.session.isSettled()) startStepper()
|
|
453
|
+
}
|
|
454
|
+
useAutoTimeline(auto, [
|
|
455
|
+
{ at: 300, run: () => setFlipped(true) },
|
|
456
|
+
{ at: 1800, run: () => setFlipped(false) },
|
|
457
|
+
{ at: 3400, run: onAutoDone },
|
|
458
|
+
])
|
|
459
|
+
|
|
460
|
+
const boxStyle = useAnimatedStyle(() => ({
|
|
461
|
+
transform: [
|
|
462
|
+
{ translateX: tx.value },
|
|
463
|
+
{ translateY: ty.value },
|
|
464
|
+
{ scaleX: sx.value },
|
|
465
|
+
{ scaleY: sy.value },
|
|
466
|
+
],
|
|
467
|
+
}))
|
|
468
|
+
return (
|
|
469
|
+
<View style={styles.exampleBody}>
|
|
470
|
+
<Pressable style={styles.button} onPress={() => setFlipped((f) => !f)}>
|
|
471
|
+
<Text style={styles.buttonText}>SWAP LAYOUT</Text>
|
|
472
|
+
</Pressable>
|
|
473
|
+
<View style={styles.arena}>
|
|
474
|
+
<Animated.View
|
|
475
|
+
onLayout={onBoxLayout}
|
|
476
|
+
style={[
|
|
477
|
+
styles.box,
|
|
478
|
+
{ backgroundColor: '#58c47a', transformOrigin: 'top left' },
|
|
479
|
+
flipped ? styles.flipEnd : styles.flipHome,
|
|
480
|
+
boxStyle,
|
|
481
|
+
]}
|
|
482
|
+
/>
|
|
483
|
+
</View>
|
|
484
|
+
<Text style={styles.status}>
|
|
485
|
+
layout: {flipped ? 'end/128w' : 'start/64w'} · state: {label.state} · settles:{' '}
|
|
486
|
+
{label.settles}
|
|
487
|
+
</Text>
|
|
488
|
+
<Text style={styles.note}>transformOrigin top-left — core Transform anchoring</Text>
|
|
489
|
+
</View>
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// 7. Interrupt — every tap retargets, INCLUDING mid-flight: the spring seeds from its live
|
|
494
|
+
// state (no jump). Auto-run taps at 140 ms — mid-flight for 550/45.
|
|
495
|
+
function InterruptExample({ auto, onAutoDone }: ExampleProps) {
|
|
496
|
+
const [on, setOn] = useState(false)
|
|
497
|
+
const [settles, setSettles] = useState(0)
|
|
498
|
+
useAutoTimeline(auto, [
|
|
499
|
+
{ at: 300, run: () => setOn(true) },
|
|
500
|
+
{ at: 440, run: () => setOn(false) }, // 140 ms later — mid-flight retarget
|
|
501
|
+
{ at: 2200, run: onAutoDone },
|
|
502
|
+
])
|
|
503
|
+
return (
|
|
504
|
+
<View style={styles.exampleBody}>
|
|
505
|
+
<Pressable style={styles.button} onPress={() => setOn((v) => !v)}>
|
|
506
|
+
<Text style={styles.buttonText}>RETARGET → {on ? 0 : 240}</Text>
|
|
507
|
+
</Pressable>
|
|
508
|
+
<View style={styles.stage}>
|
|
509
|
+
<MotionView
|
|
510
|
+
animate={{ x: on ? 240 : 0 }}
|
|
511
|
+
transition={SPRING}
|
|
512
|
+
onAnimationComplete={() => setSettles((n) => n + 1)}
|
|
513
|
+
style={[styles.box, { backgroundColor: '#8b5cf6' }]}
|
|
514
|
+
/>
|
|
515
|
+
</View>
|
|
516
|
+
<Text style={styles.status}>
|
|
517
|
+
target: {on ? 240 : 0} · settles: {settles}
|
|
518
|
+
</Text>
|
|
519
|
+
<Text style={styles.note}>tap twice fast — the second flight continues from the live x</Text>
|
|
520
|
+
</View>
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const TITLES: Record<ExampleId, string> = {
|
|
525
|
+
'spring-toggle': 'Spring toggle — x: 0 ↔ 240',
|
|
526
|
+
fade: 'Fade — opacity 1 ↔ 0.25',
|
|
527
|
+
'style-morph': 'Style morph — scale + rotate + radius + color',
|
|
528
|
+
'drag-snap': 'Drag — snap [0, 240]',
|
|
529
|
+
presence: 'Presence — enter / exit',
|
|
530
|
+
'flip-swap': 'FLIP — layout swap',
|
|
531
|
+
interrupt: 'Interrupt — retarget mid-flight',
|
|
532
|
+
'appstore-card': 'App Store card — layoutId expand / collapse (L4)',
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// 8. App Store card (L4, specs/SPEC-LAYOUT.md §native) — the golden-set choreography over
|
|
536
|
+
// L1+L2+L3: the L2 identity registry crossfades the list card into a top-anchored hero overlay
|
|
537
|
+
// in the SAME tree (no reparenting; the neighbor never moves); a stiffer core radius channel
|
|
538
|
+
// (draft {900, 60}) completes ahead of the rect; the scrim/close-X/body copy ride the flight's
|
|
539
|
+
// own crossfade scalar (X enters the second half, body the final 40% — draft thresholds);
|
|
540
|
+
// children ride UNDISTORTED via core's counterScale — its first device exercise. Symmetric,
|
|
541
|
+
// interruptible close through the registry's exit/resume reverse flight.
|
|
542
|
+
// The L4 card's machine verdict (packet Acceptance item 4): one machine-readable line per RELEASE
|
|
543
|
+
// edge — lifecycle-rate JS, never per-frame — cumulative counters plus the all-released predicate
|
|
544
|
+
// (every open eventually delivered its releasable→safeToRemove edge). RN Release drops plain
|
|
545
|
+
// console.log, so the verdict routes through globalThis.nativeLoggingHook (the M2 posture); the
|
|
546
|
+
// logcat/syslog scrape reads the LAST line after a run.
|
|
547
|
+
const L4PORT_LOG_PREFIX = 'L4PORT_APPSTORE '
|
|
548
|
+
const L4PORT_INFO_LOG_LEVEL = 1
|
|
549
|
+
|
|
550
|
+
interface L4PortLoggingGlobal {
|
|
551
|
+
nativeLoggingHook?: (message: string, level: number) => void
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function logCardVerdict(counts: { readonly swaps: number; readonly releases: number }): void {
|
|
555
|
+
const message = `${L4PORT_LOG_PREFIX}${JSON.stringify({
|
|
556
|
+
swaps: counts.swaps,
|
|
557
|
+
releases: counts.releases,
|
|
558
|
+
allReleased: counts.swaps === counts.releases,
|
|
559
|
+
// The disposal observable (round 4 major 5): live UI-runtime driver records. The gallery
|
|
560
|
+
// mounts ONE example at a time and useWorkletDriver disposes at unmount, so this stays
|
|
561
|
+
// bounded (1 here) across page changes and auto-mode remounts — a leak grows it per remount.
|
|
562
|
+
driverStates: uiDriverStateCount(),
|
|
563
|
+
})}`
|
|
564
|
+
const nativeLoggingHook = (globalThis as L4PortLoggingGlobal).nativeLoggingHook
|
|
565
|
+
if (nativeLoggingHook !== undefined) {
|
|
566
|
+
nativeLoggingHook(message, L4PORT_INFO_LOG_LEVEL)
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
console.log(message)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function AppStoreCardExample({ auto, onAutoDone }: ExampleProps) {
|
|
573
|
+
const [phase, setPhase] = useState<'closed' | 'open' | 'closing'>('closed')
|
|
574
|
+
const [label, setLabel] = useState({ swaps: 0, releases: 0, radius: 24 })
|
|
575
|
+
|
|
576
|
+
const phaseRef = useRef<'closed' | 'open' | 'closing'>('closed')
|
|
577
|
+
const thumbMeasureRef = useRef<View | null>(null)
|
|
578
|
+
const heroMeasureRef = useRef<View | null>(null)
|
|
579
|
+
const thumbAttachedRef = useRef(false)
|
|
580
|
+
const countsRef = useRef({ swaps: 0, releases: 0 })
|
|
581
|
+
|
|
582
|
+
// The UI-thread engine (specs/LAYOUT-UITHREAD-PORT-BUILD-PACKET.md): the driver's frame loop
|
|
583
|
+
// steps the identity flight AND the radius element on the UI runtime; JS work is lifecycle-edge
|
|
584
|
+
// only (open/close/release + label refreshes). The house 550/45 — the golden flights run
|
|
585
|
+
// ≈200 ms, this profile's range (draft).
|
|
586
|
+
const driverBinding = useWorkletDriver({
|
|
587
|
+
layoutSpring: { stiffness: 550, damping: 45 },
|
|
588
|
+
onAllSettled: () => refreshLabel(),
|
|
589
|
+
})
|
|
590
|
+
const bindingRef = useRef<LayoutIdentityBinding | null>(null)
|
|
591
|
+
// The radius rides a driver ELEMENT (the M2.1 command path) instead of a JS graph value: the
|
|
592
|
+
// stiffer draft channel {900, 60} completes ahead of the rect (contract item 3), retargets
|
|
593
|
+
// carry live velocity on the interrupted close.
|
|
594
|
+
const radiusHandleRef = useRef<ElementHandle | null>(null)
|
|
595
|
+
if (bindingRef.current === null) {
|
|
596
|
+
bindingRef.current = createLayoutIdentityBinding(driverBinding)
|
|
597
|
+
radiusHandleRef.current = driverBinding.driver.register({ radius: 24 })
|
|
598
|
+
}
|
|
599
|
+
const binding = bindingRef.current
|
|
600
|
+
const radiusHandle = radiusHandleRef.current as ElementHandle
|
|
601
|
+
const radiusShared = driverBinding.sharedFor(radiusHandle, 'radius')
|
|
602
|
+
|
|
603
|
+
// Holder outputs, written by the UI frame loop; styles below derive children/scrim/body from
|
|
604
|
+
// them INSIDE the style worklets — zero per-frame JS.
|
|
605
|
+
const thumbShared = binding.sharedViewFor('appstore', 'thumb')
|
|
606
|
+
const heroShared = binding.sharedViewFor('appstore', 'hero')
|
|
607
|
+
|
|
608
|
+
const refreshLabel = (): void => {
|
|
609
|
+
setLabel({
|
|
610
|
+
swaps: countsRef.current.swaps,
|
|
611
|
+
releases: countsRef.current.releases,
|
|
612
|
+
radius: Math.round(radiusShared.value),
|
|
613
|
+
})
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const retargetRadius = (to: number): void => {
|
|
617
|
+
driverBinding.driver.command(radiusHandle, {
|
|
618
|
+
kind: 'retarget',
|
|
619
|
+
targets: { radius: to },
|
|
620
|
+
transition: { type: 'spring', stiffness: 900, damping: 60 },
|
|
621
|
+
})
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const openCard = (): void => {
|
|
625
|
+
if (phaseRef.current !== 'closed') return
|
|
626
|
+
phaseRef.current = 'open'
|
|
627
|
+
countsRef.current.swaps += 1
|
|
628
|
+
setPhase('open')
|
|
629
|
+
retargetRadius(0)
|
|
630
|
+
refreshLabel()
|
|
631
|
+
// The hero mounts and attaches on its first layout (the L2 pending-pairing path).
|
|
632
|
+
}
|
|
633
|
+
const closeCard = (): void => {
|
|
634
|
+
if (phaseRef.current !== 'open') return
|
|
635
|
+
phaseRef.current = 'closing'
|
|
636
|
+
setPhase('closing')
|
|
637
|
+
retargetRadius(24)
|
|
638
|
+
binding.detach('appstore', 'hero')
|
|
639
|
+
binding.coupleRelease('appstore', 'hero', () => {
|
|
640
|
+
binding.detach('appstore', 'hero')
|
|
641
|
+
phaseRef.current = 'closed'
|
|
642
|
+
countsRef.current.releases += 1
|
|
643
|
+
setPhase('closed')
|
|
644
|
+
refreshLabel()
|
|
645
|
+
logCardVerdict(countsRef.current)
|
|
646
|
+
})
|
|
647
|
+
refreshLabel()
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
useAutoTimeline(auto, [
|
|
651
|
+
{ at: 300, run: openCard },
|
|
652
|
+
{ at: 1500, run: closeCard },
|
|
653
|
+
// The interrupted close: reopen, then close mid-flight (~120 ms in — inside the ~200 ms
|
|
654
|
+
// open flight), the golden symmetric-interruptibility contract.
|
|
655
|
+
{ at: 2700, run: openCard },
|
|
656
|
+
{ at: 2820, run: closeCard },
|
|
657
|
+
{ at: 4300, run: onAutoDone },
|
|
658
|
+
])
|
|
659
|
+
|
|
660
|
+
const onThumbLayout = (_event: LayoutChangeEvent): void => {
|
|
661
|
+
const thumbMeasure = requireLayoutIdentityMeasureHandle(thumbMeasureRef, 'appstore/thumb')
|
|
662
|
+
if (!thumbAttachedRef.current) {
|
|
663
|
+
binding.attach('appstore', 'thumb', thumbMeasure)
|
|
664
|
+
thumbAttachedRef.current = true
|
|
665
|
+
return
|
|
666
|
+
}
|
|
667
|
+
binding.commitLayout('appstore', 'thumb', thumbMeasure)
|
|
668
|
+
}
|
|
669
|
+
const heroAttachedRef = useRef(false)
|
|
670
|
+
const onHeroLayout = (_event: LayoutChangeEvent): void => {
|
|
671
|
+
const heroMeasure = requireLayoutIdentityMeasureHandle(heroMeasureRef, 'appstore/hero')
|
|
672
|
+
if (!heroAttachedRef.current) {
|
|
673
|
+
// Mount → attach through the host handle; the binding enters unmeasured and commits the
|
|
674
|
+
// callback's window-space rect, so the flight departs from the thumb's comparable frame.
|
|
675
|
+
binding.attach('appstore', 'hero', heroMeasure)
|
|
676
|
+
heroAttachedRef.current = true
|
|
677
|
+
return
|
|
678
|
+
}
|
|
679
|
+
binding.commitLayout('appstore', 'hero', heroMeasure)
|
|
680
|
+
}
|
|
681
|
+
// The hero unmounts at release; its next mount is a fresh attach.
|
|
682
|
+
useEffect(() => {
|
|
683
|
+
if (phase === 'closed') heroAttachedRef.current = false
|
|
684
|
+
}, [phase])
|
|
685
|
+
|
|
686
|
+
const thumbStyle = useAnimatedStyle(() => ({
|
|
687
|
+
transform: [
|
|
688
|
+
{ translateX: thumbShared.translateX.value },
|
|
689
|
+
{ translateY: thumbShared.translateY.value },
|
|
690
|
+
{ scaleX: thumbShared.scaleX.value },
|
|
691
|
+
{ scaleY: thumbShared.scaleY.value },
|
|
692
|
+
],
|
|
693
|
+
opacity: thumbShared.opacity.value,
|
|
694
|
+
}))
|
|
695
|
+
// Children ride undistorted (REQ-LAYOUT-013) — core's counterScale, computed in the style
|
|
696
|
+
// worklet from the flight's live scales (the dist function is worklet-tagged for exactly this).
|
|
697
|
+
const thumbKidStyle = useAnimatedStyle(() => {
|
|
698
|
+
const kid = counterScale({
|
|
699
|
+
translateX: thumbShared.translateX.value,
|
|
700
|
+
translateY: thumbShared.translateY.value,
|
|
701
|
+
scaleX: thumbShared.scaleX.value,
|
|
702
|
+
scaleY: thumbShared.scaleY.value,
|
|
703
|
+
})
|
|
704
|
+
return { transform: [{ scaleX: kid.scaleX }, { scaleY: kid.scaleY }] }
|
|
705
|
+
})
|
|
706
|
+
const heroStyle = useAnimatedStyle(() => {
|
|
707
|
+
// The radius channel corrected per axis so the PAINTED radius matches the driven value under
|
|
708
|
+
// the flight's anisotropic scale (core correctRadius).
|
|
709
|
+
const corrected = correctRadius(
|
|
710
|
+
radiusShared.value,
|
|
711
|
+
heroShared.scaleX.value,
|
|
712
|
+
heroShared.scaleY.value,
|
|
713
|
+
)
|
|
714
|
+
return {
|
|
715
|
+
transform: [
|
|
716
|
+
{ translateX: heroShared.translateX.value },
|
|
717
|
+
{ translateY: heroShared.translateY.value },
|
|
718
|
+
{ scaleX: heroShared.scaleX.value },
|
|
719
|
+
{ scaleY: heroShared.scaleY.value },
|
|
720
|
+
],
|
|
721
|
+
opacity: heroShared.opacity.value,
|
|
722
|
+
borderRadius: (corrected.x + corrected.y) / 2,
|
|
723
|
+
}
|
|
724
|
+
})
|
|
725
|
+
const heroKidStyle = useAnimatedStyle(() => {
|
|
726
|
+
const kid = counterScale({
|
|
727
|
+
translateX: heroShared.translateX.value,
|
|
728
|
+
translateY: heroShared.translateY.value,
|
|
729
|
+
scaleX: heroShared.scaleX.value,
|
|
730
|
+
scaleY: heroShared.scaleY.value,
|
|
731
|
+
})
|
|
732
|
+
return { transform: [{ scaleX: kid.scaleX }, { scaleY: kid.scaleY }] }
|
|
733
|
+
})
|
|
734
|
+
// Scrim / close-X / body ride the flight's own crossfade scalar via the hero's opacity (draft
|
|
735
|
+
// thresholds: scrim linear, X second half, body final 40%) — derived in the worklet.
|
|
736
|
+
const scrimStyle = useAnimatedStyle(() => ({ opacity: heroShared.opacity.value * 0.92 }))
|
|
737
|
+
const closeXStyle = useAnimatedStyle(() => ({
|
|
738
|
+
opacity: Math.max(0, (heroShared.opacity.value - 0.5) / 0.5),
|
|
739
|
+
}))
|
|
740
|
+
const bodyStyle = useAnimatedStyle(() => {
|
|
741
|
+
const bodyProgress = Math.max(0, (heroShared.opacity.value - 0.6) / 0.4)
|
|
742
|
+
// The drafted body pair: opacity 0 → 1 WITH translateY 12 → 0 over the final 40%.
|
|
743
|
+
return { opacity: bodyProgress, transform: [{ translateY: 12 * (1 - bodyProgress) }] }
|
|
744
|
+
})
|
|
745
|
+
return (
|
|
746
|
+
<View style={styles.exampleBody}>
|
|
747
|
+
<View style={appstore.stage}>
|
|
748
|
+
<View style={appstore.list}>
|
|
749
|
+
<View style={appstore.neighbor}>
|
|
750
|
+
<Text style={appstore.cardTitle}>Vikings</Text>
|
|
751
|
+
<Text style={appstore.cardSub}>GAME OF THE DAY — static neighbor</Text>
|
|
752
|
+
</View>
|
|
753
|
+
<Pressable accessibilityLabel="appstore-thumb" onPress={openCard}>
|
|
754
|
+
<Animated.View
|
|
755
|
+
ref={thumbMeasureRef}
|
|
756
|
+
onLayout={onThumbLayout}
|
|
757
|
+
style={[appstore.thumb, { transformOrigin: 'top left' }, thumbStyle]}
|
|
758
|
+
>
|
|
759
|
+
<Animated.View style={[{ transformOrigin: 'top left' }, thumbKidStyle]}>
|
|
760
|
+
<Text style={appstore.cardTitle}>Dragonic</Text>
|
|
761
|
+
<Text style={appstore.cardSub}>Legends return — tap to expand</Text>
|
|
762
|
+
<View style={appstore.footerRow}>
|
|
763
|
+
<Text style={appstore.cardSub}>Dragonic Legends</Text>
|
|
764
|
+
<View style={appstore.getPill}>
|
|
765
|
+
<Text style={appstore.getPillText}>GET</Text>
|
|
766
|
+
</View>
|
|
767
|
+
</View>
|
|
768
|
+
</Animated.View>
|
|
769
|
+
</Animated.View>
|
|
770
|
+
</Pressable>
|
|
771
|
+
</View>
|
|
772
|
+
{phase !== 'closed' && (
|
|
773
|
+
<Animated.View pointerEvents="none" style={[appstore.scrim, scrimStyle]} />
|
|
774
|
+
)}
|
|
775
|
+
{phase !== 'closed' && (
|
|
776
|
+
<Pressable
|
|
777
|
+
accessibilityLabel="appstore-hero"
|
|
778
|
+
onPress={closeCard}
|
|
779
|
+
style={appstore.heroSlot}
|
|
780
|
+
>
|
|
781
|
+
<Animated.View
|
|
782
|
+
ref={heroMeasureRef}
|
|
783
|
+
onLayout={onHeroLayout}
|
|
784
|
+
style={[appstore.hero, { transformOrigin: 'top left' }, heroStyle]}
|
|
785
|
+
>
|
|
786
|
+
<Animated.View style={[{ transformOrigin: 'top left' }, heroKidStyle]}>
|
|
787
|
+
<Text style={appstore.cardTitle}>Dragonic</Text>
|
|
788
|
+
<Animated.Text style={[appstore.closeX, closeXStyle]}>×</Animated.Text>
|
|
789
|
+
<Animated.View style={bodyStyle}>
|
|
790
|
+
<Text style={appstore.cardSub}>
|
|
791
|
+
Body copy staggers into the final 40% — the detail overlay lives in the same
|
|
792
|
+
tree as the list. Tap to close.
|
|
793
|
+
</Text>
|
|
794
|
+
</Animated.View>
|
|
795
|
+
<View style={appstore.footerRow}>
|
|
796
|
+
<Text style={appstore.cardSub}>Dragonic Legends</Text>
|
|
797
|
+
<View style={appstore.getPill}>
|
|
798
|
+
<Text style={appstore.getPillText}>GET</Text>
|
|
799
|
+
</View>
|
|
800
|
+
</View>
|
|
801
|
+
</Animated.View>
|
|
802
|
+
</Animated.View>
|
|
803
|
+
</Pressable>
|
|
804
|
+
)}
|
|
805
|
+
</View>
|
|
806
|
+
<Text style={styles.status}>
|
|
807
|
+
{phase} · radius {label.radius} · swaps {label.swaps} · releases {label.releases}
|
|
808
|
+
</Text>
|
|
809
|
+
</View>
|
|
810
|
+
)
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const appstore = StyleSheet.create({
|
|
814
|
+
stage: {
|
|
815
|
+
alignSelf: 'stretch',
|
|
816
|
+
height: 330,
|
|
817
|
+
borderRadius: 12,
|
|
818
|
+
backgroundColor: '#16161f',
|
|
819
|
+
overflow: 'hidden',
|
|
820
|
+
},
|
|
821
|
+
list: { padding: 12, gap: 12 },
|
|
822
|
+
neighbor: { backgroundColor: '#3a4a5e', borderRadius: 24, padding: 12, height: 72 },
|
|
823
|
+
thumb: { backgroundColor: '#0ea5e9', borderRadius: 24, padding: 12, height: 120 },
|
|
824
|
+
heroSlot: { position: 'absolute', top: 0, left: 0, right: 0 },
|
|
825
|
+
hero: { backgroundColor: '#0ea5e9', padding: 12, height: 200 },
|
|
826
|
+
scrim: {
|
|
827
|
+
position: 'absolute',
|
|
828
|
+
top: 0,
|
|
829
|
+
left: 0,
|
|
830
|
+
right: 0,
|
|
831
|
+
bottom: 0,
|
|
832
|
+
backgroundColor: '#ffffff',
|
|
833
|
+
},
|
|
834
|
+
cardTitle: { color: '#fff', fontSize: 16, fontWeight: '800' },
|
|
835
|
+
cardSub: { color: '#e0f2fe', fontSize: 11, marginTop: 2 },
|
|
836
|
+
footerRow: {
|
|
837
|
+
flexDirection: 'row',
|
|
838
|
+
justifyContent: 'space-between',
|
|
839
|
+
alignItems: 'center',
|
|
840
|
+
marginTop: 10,
|
|
841
|
+
},
|
|
842
|
+
getPill: {
|
|
843
|
+
backgroundColor: '#fff',
|
|
844
|
+
borderRadius: 12,
|
|
845
|
+
paddingHorizontal: 14,
|
|
846
|
+
paddingVertical: 4,
|
|
847
|
+
},
|
|
848
|
+
getPillText: { color: '#0ea5e9', fontSize: 11, fontWeight: '800' },
|
|
849
|
+
closeX: { position: 'absolute', top: 0, right: 4, color: '#fff', fontSize: 20 },
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
export function ChoreographyGalleryScreen() {
|
|
853
|
+
const [index, setIndex] = useState(0)
|
|
854
|
+
const [auto, setAuto] = useState(false)
|
|
855
|
+
const [autoDone, setAutoDone] = useState(false)
|
|
856
|
+
const id = EXAMPLES[index] as ExampleId
|
|
857
|
+
|
|
858
|
+
const advance = (): void => {
|
|
859
|
+
setIndex((i) => {
|
|
860
|
+
if (i + 1 >= EXAMPLES.length) {
|
|
861
|
+
setAuto(false)
|
|
862
|
+
setAutoDone(true)
|
|
863
|
+
return i
|
|
864
|
+
}
|
|
865
|
+
return i + 1
|
|
866
|
+
})
|
|
867
|
+
}
|
|
868
|
+
// iOS: hands-free auto-run (the simulator has no CLI touch injection — M2.4 precedent).
|
|
869
|
+
useEffect(() => {
|
|
870
|
+
if (Platform.OS !== 'ios') return
|
|
871
|
+
const timer = setTimeout(() => setAuto(true), 1500)
|
|
872
|
+
return () => clearTimeout(timer)
|
|
873
|
+
}, [])
|
|
874
|
+
|
|
875
|
+
const example =
|
|
876
|
+
id === 'spring-toggle' ? (
|
|
877
|
+
<SpringToggleExample key={`${id}-${auto}`} auto={auto} onAutoDone={advance} />
|
|
878
|
+
) : id === 'fade' ? (
|
|
879
|
+
<FadeExample key={`${id}-${auto}`} auto={auto} onAutoDone={advance} />
|
|
880
|
+
) : id === 'style-morph' ? (
|
|
881
|
+
<StyleMorphExample key={`${id}-${auto}`} auto={auto} onAutoDone={advance} />
|
|
882
|
+
) : id === 'drag-snap' ? (
|
|
883
|
+
<DragSnapExample key={`${id}-${auto}`} auto={auto} onAutoDone={advance} />
|
|
884
|
+
) : id === 'presence' ? (
|
|
885
|
+
<PresenceExample key={`${id}-${auto}`} auto={auto} onAutoDone={advance} />
|
|
886
|
+
) : id === 'flip-swap' ? (
|
|
887
|
+
<FlipSwapExample key={`${id}-${auto}`} auto={auto} onAutoDone={advance} />
|
|
888
|
+
) : id === 'interrupt' ? (
|
|
889
|
+
<InterruptExample key={`${id}-${auto}`} auto={auto} onAutoDone={advance} />
|
|
890
|
+
) : (
|
|
891
|
+
<AppStoreCardExample key={`${id}-${auto}`} auto={auto} onAutoDone={advance} />
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
return (
|
|
895
|
+
<GestureHandlerRootView style={styles.root}>
|
|
896
|
+
<View style={styles.container}>
|
|
897
|
+
<Text style={styles.title}>Native Motion — examples</Text>
|
|
898
|
+
<Text style={styles.subtitle}>
|
|
899
|
+
{index + 1}/{EXAMPLES.length} · {TITLES[id]}
|
|
900
|
+
</Text>
|
|
901
|
+
<View style={styles.navRow}>
|
|
902
|
+
<Pressable
|
|
903
|
+
style={styles.navButton}
|
|
904
|
+
onPress={() => setIndex((i) => (i - 1 + EXAMPLES.length) % EXAMPLES.length)}
|
|
905
|
+
>
|
|
906
|
+
<Text style={styles.buttonText}>PREV</Text>
|
|
907
|
+
</Pressable>
|
|
908
|
+
<Pressable
|
|
909
|
+
style={styles.navButton}
|
|
910
|
+
onPress={() => {
|
|
911
|
+
setAutoDone(false)
|
|
912
|
+
setIndex(0)
|
|
913
|
+
setAuto(true)
|
|
914
|
+
}}
|
|
915
|
+
>
|
|
916
|
+
<Text style={styles.buttonText}>AUTO</Text>
|
|
917
|
+
</Pressable>
|
|
918
|
+
<Pressable
|
|
919
|
+
style={styles.navButton}
|
|
920
|
+
onPress={() => setIndex((i) => (i + 1) % EXAMPLES.length)}
|
|
921
|
+
>
|
|
922
|
+
<Text style={styles.buttonText}>NEXT</Text>
|
|
923
|
+
</Pressable>
|
|
924
|
+
</View>
|
|
925
|
+
{example}
|
|
926
|
+
<Text style={styles.mode}>
|
|
927
|
+
{autoDone
|
|
928
|
+
? `AUTO-RUN COMPLETE ${EXAMPLES.length}/${EXAMPLES.length}`
|
|
929
|
+
: auto
|
|
930
|
+
? 'mode: auto-run'
|
|
931
|
+
: Platform.OS === 'ios'
|
|
932
|
+
? 'mode: manual (auto-run arms at boot)'
|
|
933
|
+
: 'mode: manual (real touch input)'}
|
|
934
|
+
</Text>
|
|
935
|
+
</View>
|
|
936
|
+
</GestureHandlerRootView>
|
|
937
|
+
)
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const styles = StyleSheet.create({
|
|
941
|
+
root: { flex: 1 },
|
|
942
|
+
container: {
|
|
943
|
+
flex: 1,
|
|
944
|
+
backgroundColor: '#0b0b12',
|
|
945
|
+
alignItems: 'center',
|
|
946
|
+
paddingTop: 84,
|
|
947
|
+
paddingHorizontal: 24,
|
|
948
|
+
},
|
|
949
|
+
title: { color: '#fff', fontSize: 24, fontWeight: '700' },
|
|
950
|
+
subtitle: { color: '#8a8aa0', fontSize: 13, marginTop: 6, textAlign: 'center' },
|
|
951
|
+
navRow: { flexDirection: 'row', gap: 12, marginTop: 16 },
|
|
952
|
+
navButton: {
|
|
953
|
+
backgroundColor: '#1e1e2e',
|
|
954
|
+
borderRadius: 8,
|
|
955
|
+
paddingVertical: 10,
|
|
956
|
+
paddingHorizontal: 18,
|
|
957
|
+
borderWidth: 1,
|
|
958
|
+
borderColor: '#2c2c3d',
|
|
959
|
+
},
|
|
960
|
+
exampleBody: { alignSelf: 'stretch', alignItems: 'center', marginTop: 28 },
|
|
961
|
+
button: {
|
|
962
|
+
backgroundColor: '#1e1e2e',
|
|
963
|
+
borderRadius: 8,
|
|
964
|
+
paddingVertical: 10,
|
|
965
|
+
paddingHorizontal: 18,
|
|
966
|
+
borderWidth: 1,
|
|
967
|
+
borderColor: '#3d3d55',
|
|
968
|
+
},
|
|
969
|
+
buttonRow: { flexDirection: 'row', gap: 12 },
|
|
970
|
+
buttonText: { color: '#c9c9d8', fontSize: 13, fontWeight: '600' },
|
|
971
|
+
stage: { alignSelf: 'stretch', height: 96, justifyContent: 'center', marginTop: 20 },
|
|
972
|
+
track: {
|
|
973
|
+
width: 240 + 64,
|
|
974
|
+
height: 96,
|
|
975
|
+
justifyContent: 'center',
|
|
976
|
+
marginTop: 20,
|
|
977
|
+
},
|
|
978
|
+
snapMarker: {
|
|
979
|
+
position: 'absolute',
|
|
980
|
+
top: 16,
|
|
981
|
+
width: 64,
|
|
982
|
+
height: 64,
|
|
983
|
+
borderRadius: 16,
|
|
984
|
+
borderWidth: 1,
|
|
985
|
+
borderColor: '#2c2c3d',
|
|
986
|
+
},
|
|
987
|
+
box: { width: 64, height: 64, borderRadius: 16 },
|
|
988
|
+
presenceRow: {
|
|
989
|
+
flexDirection: 'row',
|
|
990
|
+
gap: 12,
|
|
991
|
+
minHeight: 72,
|
|
992
|
+
marginTop: 20,
|
|
993
|
+
alignSelf: 'stretch',
|
|
994
|
+
justifyContent: 'center',
|
|
995
|
+
},
|
|
996
|
+
itemBox: {
|
|
997
|
+
width: 64,
|
|
998
|
+
height: 64,
|
|
999
|
+
borderRadius: 16,
|
|
1000
|
+
alignItems: 'center',
|
|
1001
|
+
justifyContent: 'center',
|
|
1002
|
+
},
|
|
1003
|
+
itemText: { color: '#04302b', fontSize: 14, fontWeight: '700' },
|
|
1004
|
+
arena: { alignSelf: 'stretch', height: 160, justifyContent: 'center', marginTop: 20 },
|
|
1005
|
+
flipHome: { alignSelf: 'flex-start', width: 64 },
|
|
1006
|
+
flipEnd: { alignSelf: 'flex-end', width: 128 },
|
|
1007
|
+
status: { color: '#c9c9d8', fontSize: 13, marginTop: 24, fontFamily: 'Menlo' },
|
|
1008
|
+
note: { color: '#6f6f85', fontSize: 11, marginTop: 8, textAlign: 'center' },
|
|
1009
|
+
mode: { color: '#6f6f85', fontSize: 11, marginTop: 'auto', marginBottom: 24 },
|
|
1010
|
+
})
|