diff --git a/apps/e2e/app/[stackType]/bounds/_layout.tsx b/apps/e2e/app/[stackType]/bounds/_layout.tsx index 2016ef37..855d65ae 100644 --- a/apps/e2e/app/[stackType]/bounds/_layout.tsx +++ b/apps/e2e/app/[stackType]/bounds/_layout.tsx @@ -19,6 +19,10 @@ export default function BoundsLayout() { name="matched-screen" options={{ ...IOSSlide() }} /> + ); } diff --git a/apps/e2e/app/[stackType]/bounds/handoff-multiflow/_layout.tsx b/apps/e2e/app/[stackType]/bounds/handoff-multiflow/_layout.tsx new file mode 100644 index 00000000..36538a94 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/handoff-multiflow/_layout.tsx @@ -0,0 +1,39 @@ +import Transition from "react-native-screen-transitions"; +import type { BlankStackNavigationOptions } from "react-native-screen-transitions/blank-stack"; +import { BlankStack } from "@/layouts/blank-stack"; + +const BOUNDARY_ID = "video-nested"; + +const handoffMultiflowInterpolator: BlankStackNavigationOptions["screenStyleInterpolator"] = + ({ bounds }) => { + "worklet"; + const boundaryStyle = bounds(BOUNDARY_ID).styles() as Record; + + return { + [BOUNDARY_ID]: boundaryStyle, + }; + }; + +const singleInstanceOptions = { + gestureEnabled: true, + gestureDirection: ["vertical", "vertical-inverted"], + inactiveBehavior: "keep", + screenStyleInterpolator: handoffMultiflowInterpolator, + transitionSpec: { + open: Transition.Specs.DefaultSpec, + close: Transition.Specs.DefaultSpec, + }, +} satisfies BlankStackNavigationOptions; + +export default function HandoffMultiflowLayout() { + return ( + + + + + + + + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/handoff-multiflow/a.tsx b/apps/e2e/app/[stackType]/bounds/handoff-multiflow/a.tsx new file mode 100644 index 00000000..2265f0a1 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/handoff-multiflow/a.tsx @@ -0,0 +1,13 @@ +import { HandoffMultiflowScreen } from "./handoff-multiflow-screen"; + +export default function MatchedScreenDebugA() { + return ( + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/handoff-multiflow/b.tsx b/apps/e2e/app/[stackType]/bounds/handoff-multiflow/b.tsx new file mode 100644 index 00000000..855897cf --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/handoff-multiflow/b.tsx @@ -0,0 +1,12 @@ +import { HandoffMultiflowScreen } from "./handoff-multiflow-screen"; + +export default function MatchedScreenDebugB() { + return ( + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/handoff-multiflow/c.tsx b/apps/e2e/app/[stackType]/bounds/handoff-multiflow/c.tsx new file mode 100644 index 00000000..3a7ac4ce --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/handoff-multiflow/c.tsx @@ -0,0 +1,12 @@ +import { HandoffMultiflowScreen } from "./handoff-multiflow-screen"; + +export default function MatchedScreenDebugC() { + return ( + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/handoff-multiflow/d.tsx b/apps/e2e/app/[stackType]/bounds/handoff-multiflow/d.tsx new file mode 100644 index 00000000..ba23a019 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/handoff-multiflow/d.tsx @@ -0,0 +1,12 @@ +import { HandoffMultiflowScreen } from "./handoff-multiflow-screen"; + +export default function MatchedScreenDebugD() { + return ( + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/handoff-multiflow/e.tsx b/apps/e2e/app/[stackType]/bounds/handoff-multiflow/e.tsx new file mode 100644 index 00000000..184fa6b8 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/handoff-multiflow/e.tsx @@ -0,0 +1,11 @@ +import { HandoffMultiflowScreen } from "./handoff-multiflow-screen"; + +export default function MatchedScreenDebugE() { + return ( + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/handoff-multiflow/handoff-multiflow-screen.tsx b/apps/e2e/app/[stackType]/bounds/handoff-multiflow/handoff-multiflow-screen.tsx new file mode 100644 index 00000000..aac63a40 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/handoff-multiflow/handoff-multiflow-screen.tsx @@ -0,0 +1,298 @@ +import { router } from "expo-router"; +import { VideoView } from "expo-video"; +import { useCallback, useMemo, useState } from "react"; +import { + Pressable, + type StyleProp, + StyleSheet, + Text, + View, + type ViewStyle, +} from "react-native"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, +} from "react-native-reanimated"; +import Transition from "react-native-screen-transitions"; +import { + buildStackPath, + useResolvedStackType, +} from "@/components/stack-examples/stack-routing"; +import { handoffVideoPlayer } from "./handoff-video-player"; + +const BOUNDARY_ID = "video-nested"; +const FRAME_HEIGHT = 126; +const FRAME_WIDTH = 168; +const RANDOM_X_RANGE = 84; +const RANDOM_Y_RANGE = 160; +const SMALL_SCALE = 0.58; +const LARGE_SCALE = 1.48; +const SHOW_DEBUG_FRAME_BORDERS = false; + +type RouteLetter = "a" | "b" | "c" | "d" | "e"; + +type HandoffMultiflowScreenProps = { + backgroundColor: string; + letter: RouteLetter; + next?: RouteLetter; + placementStyle: StyleProp; + showVideo?: boolean; +}; + +export function HandoffMultiflowScreen({ + backgroundColor, + letter, + next, + placementStyle, + showVideo = false, +}: HandoffMultiflowScreenProps) { + const stackType = useResolvedStackType(); + const canGoBack = letter !== "a"; + const randomOffsetX = useSharedValue(0); + const randomOffsetY = useSharedValue(0); + const randomScale = useSharedValue(1); + const [debugState, setDebugState] = useState({ + scale: 1, + x: 0, + y: 0, + }); + + const randomizedPlacementStyle = useAnimatedStyle(() => ({ + transform: [ + { translateX: randomOffsetX.value }, + { translateY: randomOffsetY.value }, + ], + })); + + const randomizedBoundaryStyle = useMemo( + () => + ({ + transform: [{ scale: randomScale }], + }) as any, + [randomScale], + ); + + const moveBoundary = useCallback( + (scale: number) => { + const nextX = (Math.random() * 2 - 1) * RANDOM_X_RANGE; + const nextY = (Math.random() * 2 - 1) * RANDOM_Y_RANGE; + + setDebugState({ scale, x: nextX, y: nextY }); + randomOffsetX.value = withSpring(nextX); + randomOffsetY.value = withSpring(nextY); + randomScale.value = withSpring(scale); + }, + [randomOffsetX, randomOffsetY, randomScale], + ); + + return ( + + + + {SHOW_DEBUG_FRAME_BORDERS ? ( + + layout + + ) : null} + + {showVideo ? ( + + + + ) : null} + + + + + Screen {letter.toUpperCase()} + + x {Math.round(debugState.x)} y {Math.round(debugState.y)} scale{" "} + {debugState.scale.toFixed(2)} + + + + [ + styles.button, + styles.randomizeButton, + pressed ? styles.buttonPressed : undefined, + ]} + onPress={() => moveBoundary(SMALL_SCALE)} + > + Small + + [ + styles.button, + styles.randomizeButton, + pressed ? styles.buttonPressed : undefined, + ]} + onPress={() => moveBoundary(LARGE_SCALE)} + > + Large + + {canGoBack ? ( + [ + styles.button, + pressed ? styles.buttonPressed : undefined, + ]} + onPress={() => router.back()} + > + Back + + ) : null} + {next ? ( + [ + styles.button, + pressed ? styles.buttonPressed : undefined, + ]} + onPress={() => + router.push( + buildStackPath( + stackType, + `bounds/handoff-multiflow/${next}`, + ) as never, + ) + } + > + Next + + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + actions: { + alignItems: "center", + bottom: 60, + flexDirection: "row", + flexWrap: "wrap", + gap: 10, + justifyContent: "center", + left: 16, + position: "absolute", + right: 16, + }, + badge: { + backgroundColor: "rgba(0, 0, 0, 0.64)", + borderRadius: 8, + left: 16, + paddingHorizontal: 12, + paddingVertical: 10, + position: "absolute", + top: 64, + }, + badgeText: { + color: "white", + fontFamily: "monospace", + fontSize: 12, + marginTop: 4, + }, + badgeTitle: { + color: "white", + fontSize: 14, + fontWeight: "800", + letterSpacing: 0, + textTransform: "uppercase", + }, + boundaryFrame: { + backgroundColor: "rgba(123, 111, 208, 0.18)", + }, + boundaryDebugBorder: { + borderColor: "#7B6FD0", + borderRadius: 18, + borderWidth: 4, + }, + button: { + backgroundColor: "white", + borderRadius: 10, + minWidth: 96, + paddingHorizontal: 18, + paddingVertical: 14, + }, + buttonPressed: { + opacity: 0.58, + }, + buttonText: { + color: "black", + fontSize: 17, + fontWeight: "700", + textAlign: "center", + }, + frame: { + height: FRAME_HEIGHT, + overflow: "hidden", + width: FRAME_WIDTH, + }, + layoutGuide: { + alignItems: "center", + borderColor: "#FF453A", + borderStyle: "dashed", + borderWidth: 2, + height: FRAME_HEIGHT, + justifyContent: "flex-start", + position: "absolute", + width: FRAME_WIDTH, + }, + layoutGuideLabel: { + backgroundColor: "white", + color: "#FF453A", + fontSize: 11, + fontWeight: "800", + paddingHorizontal: 6, + paddingVertical: 3, + textTransform: "uppercase", + }, + motionFrame: { + height: FRAME_HEIGHT, + position: "absolute", + width: FRAME_WIDTH, + }, + placement: { + height: FRAME_HEIGHT, + position: "absolute", + width: FRAME_WIDTH, + }, + payload: { + height: FRAME_HEIGHT, + width: FRAME_WIDTH, + }, + randomizeButton: { + minWidth: 92, + }, + screen: { + flex: 1, + }, +}); diff --git a/apps/e2e/app/[stackType]/bounds/handoff-multiflow/handoff-video-player.ts b/apps/e2e/app/[stackType]/bounds/handoff-multiflow/handoff-video-player.ts new file mode 100644 index 00000000..79ab2716 --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/handoff-multiflow/handoff-video-player.ts @@ -0,0 +1,9 @@ +import { createVideoPlayer } from "expo-video"; + +const source = require("../../../../assets/videos/subway.mp4"); + +export const handoffVideoPlayer = createVideoPlayer(source); + +handoffVideoPlayer.loop = true; +handoffVideoPlayer.muted = true; +handoffVideoPlayer.play(); diff --git a/apps/e2e/app/[stackType]/bounds/handoff-multiflow/index.tsx b/apps/e2e/app/[stackType]/bounds/handoff-multiflow/index.tsx new file mode 100644 index 00000000..2a2b772a --- /dev/null +++ b/apps/e2e/app/[stackType]/bounds/handoff-multiflow/index.tsx @@ -0,0 +1,13 @@ +import { Redirect } from "expo-router"; +import { + buildStackPath, + useResolvedStackType, +} from "@/components/stack-examples/stack-routing"; + +export default function HandoffMultiflowIndex() { + const stackType = useResolvedStackType(); + + return ( + + ); +} diff --git a/apps/e2e/app/[stackType]/bounds/index.tsx b/apps/e2e/app/[stackType]/bounds/index.tsx index c551080e..826e41ea 100644 --- a/apps/e2e/app/[stackType]/bounds/index.tsx +++ b/apps/e2e/app/[stackType]/bounds/index.tsx @@ -30,6 +30,11 @@ const BOUNDS_EXAMPLES = [ description: "One video teleported to the matched screen while the destination slides", }, + { + id: "handoff-multiflow", + title: "Handoff multiflow", + description: "One payload handed across a multi-screen push and pop flow", + }, ]; export default function BoundsHubIndex() { diff --git a/apps/e2e/app/[stackType]/shared-x-image/[id].tsx b/apps/e2e/app/[stackType]/shared-x-image/[id].tsx index 416f4d95..63ee7898 100644 --- a/apps/e2e/app/[stackType]/shared-x-image/[id].tsx +++ b/apps/e2e/app/[stackType]/shared-x-image/[id].tsx @@ -21,9 +21,11 @@ export default function SharedXImageDetail() { return ( - - - + + + + + ); } diff --git a/apps/e2e/app/[stackType]/shared-x-image/_layout.tsx b/apps/e2e/app/[stackType]/shared-x-image/_layout.tsx index 912d3a1f..248f12bf 100644 --- a/apps/e2e/app/[stackType]/shared-x-image/_layout.tsx +++ b/apps/e2e/app/[stackType]/shared-x-image/_layout.tsx @@ -25,7 +25,7 @@ export default function SharedXImageLayout() { options={{ headerShown: false, ...Transition.Presets.SharedXImage({ - sharedBoundTag: boundId, + id: boundId, }), }} /> diff --git a/apps/e2e/app/[stackType]/shared-x-image/index.tsx b/apps/e2e/app/[stackType]/shared-x-image/index.tsx index a66bfe8d..8b06dd19 100644 --- a/apps/e2e/app/[stackType]/shared-x-image/index.tsx +++ b/apps/e2e/app/[stackType]/shared-x-image/index.tsx @@ -70,9 +70,8 @@ function Post({ 12h - { router.push({ pathname: buildStackPath( @@ -86,13 +85,15 @@ function Post({ }); }} > - - + + + + diff --git a/apps/e2e/app/example/_layout.tsx b/apps/e2e/app/example/_layout.tsx index 7f099c11..82519b23 100644 --- a/apps/e2e/app/example/_layout.tsx +++ b/apps/e2e/app/example/_layout.tsx @@ -2,54 +2,49 @@ import { interpolate } from "react-native-reanimated"; import type { ScreenTransitionConfig } from "react-native-screen-transitions"; import Transition from "react-native-screen-transitions"; import { BlankStack } from "@/layouts/blank-stack"; +import { SHEET_ZOOM_BOUNDARY_ID } from "./constants"; -const modalOptions: ScreenTransitionConfig = { +const sheetOptions: ScreenTransitionConfig = { gestureEnabled: true, - gestureTracking: "always", gestureDirection: "vertical", + snapPoints: [0.5], + initialSnapIndex: 0, + screenStyleInterpolator: ({ layouts: { screen: { height }, }, - progress, - focused, - active, current, + focused, }) => { "worklet"; - const isGestureDisabled = !current.options.gestureEnabled; - - const translateY = focused - ? interpolate(progress, [0, 1], [height, 0], "clamp") - : 0; - - const gestureSensitivity = isGestureDisabled - ? interpolate(progress, [0, 0.25], [1, 0.1], "clamp") - : 1; - - const gestureReleaseVelocityScale = isGestureDisabled ? 0 : 1; - - const contentStyle = { - transform: [{ translateY }], - maxHeight: focused ? height * 0.9 : undefined, - marginTop: focused ? "auto" : undefined, - } as const; - - const backdropStyle = focused - ? { - backgroundColor: "#00000050", - opacity: active.progress, - } - : undefined; + const translateY = interpolate( + current.progress, + [0, 1], + [height, 0], + "clamp", + ); return { - options: { - gestureSensitivity, - gestureReleaseVelocityScale, + content: { + style: { + transform: [{ translateY }], + }, }, - content: { style: contentStyle }, - backdrop: { style: backdropStyle }, + backdrop: focused + ? { + style: { + backgroundColor: "black", + opacity: interpolate( + current.progress, + [0, 0.5], + [0, 0.4], + "clamp", + ), + }, + } + : undefined, }; }, transitionSpec: { @@ -58,11 +53,23 @@ const modalOptions: ScreenTransitionConfig = { }, }; +const zoomOptions: ScreenTransitionConfig = { + gestureEnabled: true, + gestureDirection: ["bidirectional", "pinch-in"], + navigationMaskEnabled: true, + screenStyleInterpolator: ({ bounds }) => { + "worklet"; + return bounds(SHEET_ZOOM_BOUNDARY_ID).navigation.zoom(); + }, + transitionSpec: Transition.Specs.Zoom, +}; + export default function ExampleLayout() { return ( - - + + + ); } diff --git a/apps/e2e/app/example/constants.ts b/apps/e2e/app/example/constants.ts new file mode 100644 index 00000000..9ac64cac --- /dev/null +++ b/apps/e2e/app/example/constants.ts @@ -0,0 +1 @@ +export const SHEET_ZOOM_BOUNDARY_ID = "sheet-style-reset-zoom"; diff --git a/apps/e2e/app/example/index.tsx b/apps/e2e/app/example/index.tsx index afa2e40c..c31d6f4a 100644 --- a/apps/e2e/app/example/index.tsx +++ b/apps/e2e/app/example/index.tsx @@ -1,22 +1,103 @@ import { router } from "expo-router"; -import { Button, View } from "react-native"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; import { useTheme } from "@/theme"; export default function ExampleIndex() { const theme = useTheme(); + return ( - -