diff --git a/.release-it.json b/.release-it.json index dd4734db..c93fa32f 100644 --- a/.release-it.json +++ b/.release-it.json @@ -53,7 +53,6 @@ "before:git:release": "node ../../.github/scripts/release-notes.mjs ${version} --check", "after:bump": [ "node ../../.github/scripts/sync-bun-lock-version.mjs", - "git add ../../bun.lock", "bun run --cwd ../.. build", "npm pack --dry-run" ] diff --git a/README.md b/README.md index 6c5e14c4..896be50e 100644 --- a/README.md +++ b/README.md @@ -16,18 +16,6 @@ Customizable screen transitions for React Native. Build gesture-driven, shared e - Blank stack, native stack, and Expo Router integration. - Written in TypeScript. -## Navigation Compatibility - -This README documents the stable v3 release. - -| Navigation setup | v3 status | Integration | -| ---------------- | --------- | ----------- | -| React Navigation 6 or 7 | Supported | Use the v3 blank stack or native-stack adapter APIs. | -| Expo Router on Expo SDK 55 or earlier | Supported | Wrap the v3 blank stack with `withLayoutContext()`. | -| Expo Router on Expo SDK 56 or later | Not supported by v3 | Use the [v4 alpha Expo Router integration](https://screen-transitions.esjr.org/v4-experimental/getting-started). | - -Expo SDK 55 is the last Expo Router release that uses the React Navigation-backed integration documented for v3. Expo Router forked the navigation packages it builds upon in SDK 56, so the v3 and SDK 56+ navigator internals cannot be mixed. See Expo's [SDK 55 to 56 migration guide](https://docs.expo.dev/router/migrate/sdk-55-to-56/) for the upstream change. - ## Getting Started Install the package: @@ -47,8 +35,6 @@ npm install react-native-reanimated react-native-gesture-handler \ See [the documentation site](https://screen-transitions.esjr.org). -For complete runnable projects, start with the [v3 Expo Router starter](https://github.com/eds2002/react-native-screen-transitions/tree/main/starters/expo-router), which pins the final supported Expo Router environment on Expo SDK 55, or the [v3 React Navigation starter](https://github.com/eds2002/react-native-screen-transitions/tree/main/starters/react-navigation). - ## Support v3 (current) supports Reanimated v3, Reanimated v4, and React Native Gesture Handler v2. diff --git a/apps/e2e/app.json b/apps/e2e/app.json index 93b0a55b..f38463e9 100644 --- a/apps/e2e/app.json +++ b/apps/e2e/app.json @@ -6,6 +6,7 @@ "orientation": "portrait", "scheme": "e2e", "userInterfaceStyle": "automatic", + "newArchEnabled": true, "ios": { "supportsTablet": true, "bundleIdentifier": "com.eds2002.e2e", @@ -15,6 +16,7 @@ "adaptiveIcon": { "backgroundColor": "#E6F4FE" }, + "edgeToEdgeEnabled": true, "predictiveBackGestureEnabled": false, "package": "com.eds2002.e2e" }, @@ -24,9 +26,7 @@ "plugins": [ "expo-router", "expo-video", - "expo-web-browser", - "expo-font", - "expo-image" + "expo-web-browser" ], "experiments": { "typedRoutes": true, diff --git a/apps/e2e/app/[stackType]/bottom-sheet/snap-lock-toggle.tsx b/apps/e2e/app/[stackType]/bottom-sheet/snap-lock-toggle.tsx index b66f4874..e631e2cb 100644 --- a/apps/e2e/app/[stackType]/bottom-sheet/snap-lock-toggle.tsx +++ b/apps/e2e/app/[stackType]/bottom-sheet/snap-lock-toggle.tsx @@ -1,4 +1,4 @@ -import { useNavigation } from "@react-navigation/native"; +import { useNavigation } from "expo-router"; import { useEffect, useState } from "react"; import { Dimensions, Pressable, StyleSheet, Text, View } from "react-native"; import Transition, { snapTo } from "react-native-screen-transitions"; diff --git a/apps/e2e/app/[stackType]/bottom-sheet/with-scroll.tsx b/apps/e2e/app/[stackType]/bottom-sheet/with-scroll.tsx index ade81b99..0b218b62 100644 --- a/apps/e2e/app/[stackType]/bottom-sheet/with-scroll.tsx +++ b/apps/e2e/app/[stackType]/bottom-sheet/with-scroll.tsx @@ -1,4 +1,4 @@ -import { useNavigation } from "@react-navigation/native"; +import { useNavigation } from "expo-router"; import { useEffect, useMemo, useState } from "react"; import { Dimensions, Pressable, StyleSheet, Text, View } from "react-native"; import { interpolate } from "react-native-reanimated"; diff --git a/apps/e2e/app/[stackType]/bounds/_layout.tsx b/apps/e2e/app/[stackType]/bounds/_layout.tsx index 283047b1..c10ffcd9 100644 --- a/apps/e2e/app/[stackType]/bounds/_layout.tsx +++ b/apps/e2e/app/[stackType]/bounds/_layout.tsx @@ -12,9 +12,6 @@ export default function BoundsLayout() { return ( - - - diff --git a/apps/e2e/app/[stackType]/bounds/example-1/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-1/_layout.tsx deleted file mode 100644 index 0460bb70..00000000 --- a/apps/e2e/app/[stackType]/bounds/example-1/_layout.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import Transition from "react-native-screen-transitions"; -import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; -import { BlankStack } from "@/layouts/blank-stack"; -import { Stack } from "@/layouts/stack"; -import { nestedBoundaryZoomInterpolator } from "../nested-boundary-example"; - -export default function NestedSourceExampleLayout() { - const stackType = useResolvedStackType(); - const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; - const screenOptions = - stackType === "native-stack" ? { enableTransitions: true } : undefined; - - return ( - - - - - ); -} diff --git a/apps/e2e/app/[stackType]/bounds/example-1/destination.tsx b/apps/e2e/app/[stackType]/bounds/example-1/destination.tsx deleted file mode 100644 index 3d321f48..00000000 --- a/apps/e2e/app/[stackType]/bounds/example-1/destination.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { NestedBoundaryDestination } from "../nested-boundary-example"; - -export default function ParentDestinationScreen() { - return ; -} diff --git a/apps/e2e/app/[stackType]/bounds/example-1/nested/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-1/nested/_layout.tsx deleted file mode 100644 index fbb2eb26..00000000 --- a/apps/e2e/app/[stackType]/bounds/example-1/nested/_layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; -import { BlankStack } from "@/layouts/blank-stack"; -import { Stack } from "@/layouts/stack"; - -export default function NestedSourceLevelOneLayout() { - const stackType = useResolvedStackType(); - const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; - const screenOptions = - stackType === "native-stack" ? { enableTransitions: true } : undefined; - - return ( - - - - ); -} diff --git a/apps/e2e/app/[stackType]/bounds/example-1/nested/nested/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-1/nested/nested/_layout.tsx deleted file mode 100644 index 39409293..00000000 --- a/apps/e2e/app/[stackType]/bounds/example-1/nested/nested/_layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; -import { BlankStack } from "@/layouts/blank-stack"; -import { Stack } from "@/layouts/stack"; - -export default function NestedSourceLevelTwoLayout() { - const stackType = useResolvedStackType(); - const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; - const screenOptions = - stackType === "native-stack" ? { enableTransitions: true } : undefined; - - return ( - - - - ); -} diff --git a/apps/e2e/app/[stackType]/bounds/example-1/nested/nested/nested/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-1/nested/nested/nested/_layout.tsx deleted file mode 100644 index 8b4ec823..00000000 --- a/apps/e2e/app/[stackType]/bounds/example-1/nested/nested/nested/_layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; -import { BlankStack } from "@/layouts/blank-stack"; -import { Stack } from "@/layouts/stack"; - -export default function NestedSourceLevelThreeLayout() { - const stackType = useResolvedStackType(); - const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; - const screenOptions = - stackType === "native-stack" ? { enableTransitions: true } : undefined; - - return ( - - - - ); -} diff --git a/apps/e2e/app/[stackType]/bounds/example-1/nested/nested/nested/source.tsx b/apps/e2e/app/[stackType]/bounds/example-1/nested/nested/nested/source.tsx deleted file mode 100644 index 90c7e91d..00000000 --- a/apps/e2e/app/[stackType]/bounds/example-1/nested/nested/nested/source.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { NestedBoundarySource } from "../../../../nested-boundary-example"; - -export default function DeeplyNestedSourceScreen() { - return ( - - ); -} diff --git a/apps/e2e/app/[stackType]/bounds/example-2/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-2/_layout.tsx deleted file mode 100644 index f64d3b73..00000000 --- a/apps/e2e/app/[stackType]/bounds/example-2/_layout.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import Transition from "react-native-screen-transitions"; -import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; -import { BlankStack } from "@/layouts/blank-stack"; -import { Stack } from "@/layouts/stack"; -import { nestedBoundaryZoomInterpolator } from "../nested-boundary-example"; - -export default function NestedDestinationExampleLayout() { - const stackType = useResolvedStackType(); - const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; - const screenOptions = - stackType === "native-stack" ? { enableTransitions: true } : undefined; - - return ( - - - - - ); -} diff --git a/apps/e2e/app/[stackType]/bounds/example-2/nested/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-2/nested/_layout.tsx deleted file mode 100644 index 132085ac..00000000 --- a/apps/e2e/app/[stackType]/bounds/example-2/nested/_layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; -import { BlankStack } from "@/layouts/blank-stack"; -import { Stack } from "@/layouts/stack"; - -export default function NestedDestinationLevelOneLayout() { - const stackType = useResolvedStackType(); - const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; - const screenOptions = - stackType === "native-stack" ? { enableTransitions: true } : undefined; - - return ( - - - - ); -} diff --git a/apps/e2e/app/[stackType]/bounds/example-2/nested/nested/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-2/nested/nested/_layout.tsx deleted file mode 100644 index fd49b2c1..00000000 --- a/apps/e2e/app/[stackType]/bounds/example-2/nested/nested/_layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; -import { BlankStack } from "@/layouts/blank-stack"; -import { Stack } from "@/layouts/stack"; - -export default function NestedDestinationLevelTwoLayout() { - const stackType = useResolvedStackType(); - const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; - const screenOptions = - stackType === "native-stack" ? { enableTransitions: true } : undefined; - - return ( - - - - ); -} diff --git a/apps/e2e/app/[stackType]/bounds/example-2/nested/nested/nested/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-2/nested/nested/nested/_layout.tsx deleted file mode 100644 index 20489712..00000000 --- a/apps/e2e/app/[stackType]/bounds/example-2/nested/nested/nested/_layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; -import { BlankStack } from "@/layouts/blank-stack"; -import { Stack } from "@/layouts/stack"; - -export default function NestedDestinationLevelThreeLayout() { - const stackType = useResolvedStackType(); - const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; - const screenOptions = - stackType === "native-stack" ? { enableTransitions: true } : undefined; - - return ( - - - - ); -} diff --git a/apps/e2e/app/[stackType]/bounds/example-2/nested/nested/nested/destination.tsx b/apps/e2e/app/[stackType]/bounds/example-2/nested/nested/nested/destination.tsx deleted file mode 100644 index 82e075f6..00000000 --- a/apps/e2e/app/[stackType]/bounds/example-2/nested/nested/nested/destination.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { NestedBoundaryDestination } from "../../../../nested-boundary-example"; - -export default function DeeplyNestedDestinationScreen() { - return ; -} diff --git a/apps/e2e/app/[stackType]/bounds/example-2/source.tsx b/apps/e2e/app/[stackType]/bounds/example-2/source.tsx deleted file mode 100644 index 0f92a025..00000000 --- a/apps/e2e/app/[stackType]/bounds/example-2/source.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { NestedBoundarySource } from "../nested-boundary-example"; - -export default function ParentSourceScreen() { - return ( - - ); -} diff --git a/apps/e2e/app/[stackType]/bounds/example-3/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-3/_layout.tsx deleted file mode 100644 index 404573ff..00000000 --- a/apps/e2e/app/[stackType]/bounds/example-3/_layout.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import Transition from "react-native-screen-transitions"; -import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; -import { BlankStack } from "@/layouts/blank-stack"; -import { Stack } from "@/layouts/stack"; -import { nestedBoundaryZoomInterpolator } from "../nested-boundary-example"; - -export default function NeighboringNestedExampleLayout() { - const stackType = useResolvedStackType(); - const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; - const screenOptions = - stackType === "native-stack" ? { enableTransitions: true } : undefined; - - return ( - - - - - ); -} diff --git a/apps/e2e/app/[stackType]/bounds/example-3/nested-1/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-3/nested-1/_layout.tsx deleted file mode 100644 index 3cc80982..00000000 --- a/apps/e2e/app/[stackType]/bounds/example-3/nested-1/_layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; -import { BlankStack } from "@/layouts/blank-stack"; -import { Stack } from "@/layouts/stack"; - -export default function NeighboringNestedSourceLayout() { - const stackType = useResolvedStackType(); - const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; - const screenOptions = - stackType === "native-stack" ? { enableTransitions: true } : undefined; - - return ( - - - - ); -} diff --git a/apps/e2e/app/[stackType]/bounds/example-3/nested-1/source.tsx b/apps/e2e/app/[stackType]/bounds/example-3/nested-1/source.tsx deleted file mode 100644 index 7b3d906a..00000000 --- a/apps/e2e/app/[stackType]/bounds/example-3/nested-1/source.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { NestedBoundarySource } from "../../nested-boundary-example"; - -export default function NeighboringNestedSourceScreen() { - return ( - - ); -} diff --git a/apps/e2e/app/[stackType]/bounds/example-3/nested-2/_layout.tsx b/apps/e2e/app/[stackType]/bounds/example-3/nested-2/_layout.tsx deleted file mode 100644 index 5b4c7d2a..00000000 --- a/apps/e2e/app/[stackType]/bounds/example-3/nested-2/_layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; -import { BlankStack } from "@/layouts/blank-stack"; -import { Stack } from "@/layouts/stack"; - -export default function NeighboringNestedDestinationLayout() { - const stackType = useResolvedStackType(); - const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; - const screenOptions = - stackType === "native-stack" ? { enableTransitions: true } : undefined; - - return ( - - - - ); -} diff --git a/apps/e2e/app/[stackType]/bounds/example-3/nested-2/destination.tsx b/apps/e2e/app/[stackType]/bounds/example-3/nested-2/destination.tsx deleted file mode 100644 index cb4a0c96..00000000 --- a/apps/e2e/app/[stackType]/bounds/example-3/nested-2/destination.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { NestedBoundaryDestination } from "../../nested-boundary-example"; - -export default function NeighboringNestedDestinationScreen() { - return ; -} diff --git a/apps/e2e/app/[stackType]/bounds/index.tsx b/apps/e2e/app/[stackType]/bounds/index.tsx index 4d69ef0d..7348642b 100644 --- a/apps/e2e/app/[stackType]/bounds/index.tsx +++ b/apps/e2e/app/[stackType]/bounds/index.tsx @@ -6,24 +6,6 @@ import { import { ListScreen } from "@/components/ui"; const BOUNDS_EXAMPLES = [ - { - id: "example-1/nested/nested/nested/source", - title: "Deeply nested source → parent destination", - description: - "Runtime group, bound target, and clipping controls across three nested stacks", - }, - { - id: "example-2/source", - title: "Parent source → deeply nested destination", - description: - "Runtime group, bound target, and clipping controls into three nested stacks", - }, - { - id: "example-3/nested-1/source", - title: "Nested source → neighboring nested destination", - description: - "Runtime group, bound target, and clipping controls across sibling nested stacks", - }, { id: "style-id", title: ".reveal()", @@ -44,9 +26,9 @@ const BOUNDS_EXAMPLES = [ }, { id: "matched-screen", - title: "Handoff portals", + title: "Handoff ownership overlap", description: - "One video teleported to the matched screen while the destination slides", + "Spam same- and different-ID handoffs in auto or explicit mode", }, { id: "handoff-multiflow", diff --git a/apps/e2e/app/[stackType]/bounds/matched-screen/_layout.tsx b/apps/e2e/app/[stackType]/bounds/matched-screen/_layout.tsx index 585a326f..2dcad6a4 100644 --- a/apps/e2e/app/[stackType]/bounds/matched-screen/_layout.tsx +++ b/apps/e2e/app/[stackType]/bounds/matched-screen/_layout.tsx @@ -3,37 +3,61 @@ import Transition from "react-native-screen-transitions"; import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; import { BlankStack } from "@/layouts/blank-stack"; import { Stack } from "@/layouts/stack"; -import { MATCHED_SCREEN_BOUNDARY_GROUP } from "./constants"; +import type { MatchedScreenHandoffMode } from "./constants"; -function getBoundaryId(route: { params?: object } | undefined) { +function getRouteParam(route: { params?: object } | undefined, key: string) { "worklet"; - const id = (route?.params as { id?: unknown } | undefined)?.id; - return typeof id === "string" ? id : ""; + const params = route?.params as Record | undefined; + const value = params?.[key]; + return typeof value === "string" ? value : ""; } const navigationZoomInterpolator: ScreenTransitionConfig["screenStyleInterpolator"] = ({ active, bounds, current, focused, next }) => { "worklet"; const id = - getBoundaryId(active.route) || - getBoundaryId(next?.route) || - getBoundaryId(current.route); + getRouteParam(active.route, "id") || + getRouteParam(next?.route, "id") || + getRouteParam(current.route, "id"); if (!id) { return {}; } - if (focused) { - return bounds({ - group: MATCHED_SCREEN_BOUNDARY_GROUP, - id, - }).navigation.zoom({ - keepFocusedVisible: true, - target: "bound", - }); + if (!focused) { + return null; } - return null; + const navigationStyles = bounds(id).navigation.zoom({ + keepFocusedVisible: true, + target: "bound", + }); + const handoffMode = (getRouteParam(active.route, "handoffMode") || + getRouteParam(next?.route, "handoffMode") || + getRouteParam(current.route, "handoffMode")) as + | MatchedScreenHandoffMode + | ""; + + if (handoffMode !== "explicit") { + return navigationStyles; + } + + const boundarySlot = navigationStyles[id]; + + if (!boundarySlot) { + return navigationStyles; + } + + return { + ...navigationStyles, + [id]: { + ...boundarySlot, + props: { + handoffTarget: + active.closing && active.progress <= 0.5 ? "source" : "destination", + }, + }, + }; }; export default function MatchedScreenLayout() { diff --git a/apps/e2e/app/[stackType]/bounds/matched-screen/constants.ts b/apps/e2e/app/[stackType]/bounds/matched-screen/constants.ts index 747860aa..3c5ebcc1 100644 --- a/apps/e2e/app/[stackType]/bounds/matched-screen/constants.ts +++ b/apps/e2e/app/[stackType]/bounds/matched-screen/constants.ts @@ -1,7 +1,11 @@ -export const MATCHED_SCREEN_BOUNDARY_GROUP = "video-handoff"; export const MATCHED_SCREEN_ASPECT_RATIO = 16 / 9; export const MATCHED_SCREEN_DETAIL_WIDTH = 328; +export const MATCHED_SCREEN_HANDOFF_MODES = ["auto", "explicit"] as const; + +export type MatchedScreenHandoffMode = + (typeof MATCHED_SCREEN_HANDOFF_MODES)[number]; + export const MATCHED_SCREEN_VIDEOS = [ { id: "A", diff --git a/apps/e2e/app/[stackType]/bounds/matched-screen/index.tsx b/apps/e2e/app/[stackType]/bounds/matched-screen/index.tsx index 11a8927f..4a3318f9 100644 --- a/apps/e2e/app/[stackType]/bounds/matched-screen/index.tsx +++ b/apps/e2e/app/[stackType]/bounds/matched-screen/index.tsx @@ -1,11 +1,14 @@ import { router } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { useVideoPlayer, VideoView } from "expo-video"; -import { type ComponentType, useCallback } from "react"; +import { type ComponentType, useCallback, useState } from "react"; import { type FlatListProps, type ListRenderItemInfo, + Pressable, StyleSheet, + Text, + View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import Transition from "react-native-screen-transitions"; @@ -15,9 +18,10 @@ import { } from "@/components/stack-examples/stack-routing"; import { MATCHED_SCREEN_ASPECT_RATIO, - MATCHED_SCREEN_BOUNDARY_GROUP, MATCHED_SCREEN_DETAIL_WIDTH, + MATCHED_SCREEN_HANDOFF_MODES, MATCHED_SCREEN_VIDEOS, + type MatchedScreenHandoffMode, type MatchedScreenVideo, type MatchedScreenVideoId, } from "./constants"; @@ -42,8 +46,6 @@ function VideoCard({ example, onPress }: VideoCardProps) { ("auto"); const openVideo = useCallback( (id: MatchedScreenVideoId) => { @@ -78,10 +82,10 @@ export default function MatchedScreenIndex() { stackType, "bounds/matched-screen/player", ) as never, - params: { id }, + params: { handoffMode, id }, }); }, - [stackType], + [handoffMode, stackType], ); const renderItem = useCallback( @@ -91,13 +95,59 @@ export default function MatchedScreenIndex() { [openVideo], ); + const listHeader = ( + + Overlapping handoff flows + + Open A, dismiss it, then open B before A finishes closing. Repeat with + the same card to cover same-ID replacement. + + + {MATCHED_SCREEN_HANDOFF_MODES.map((mode) => { + const selected = mode === handoffMode; + + return ( + setHandoffMode(mode)} + style={({ pressed }) => [ + styles.modeButton, + selected && styles.modeButtonSelected, + pressed && styles.modeButtonPressed, + ]} + testID={`matched-screen-mode-${mode}`} + > + + {mode === "auto" ? "Auto" : "Explicit target"} + + + ); + })} + + + {handoffMode === "auto" + ? "Default ownership — no handoffTarget is returned." + : "Explicit ownership — destination until close crosses 50%, then source."} + + + ); + return ( item.id} + ListHeaderComponent={listHeader} renderItem={renderItem} removeClippedSubviews={false} showsVerticalScrollIndicator={false} @@ -121,7 +171,59 @@ const styles = StyleSheet.create({ alignItems: "center", gap: 0, paddingHorizontal: 16, - paddingVertical: 32, + paddingBottom: 32, + }, + header: { + gap: 12, + maxWidth: MATCHED_SCREEN_DETAIL_WIDTH, + paddingBottom: 24, + paddingTop: 24, + width: "100%", + }, + title: { + color: "#17201A", + fontSize: 22, + fontWeight: "700", + }, + instructions: { + color: "#69716B", + fontSize: 14, + lineHeight: 20, + }, + modePicker: { + backgroundColor: "#EEF1EF", + borderCurve: "continuous", + borderRadius: 12, + flexDirection: "row", + padding: 3, + }, + modeButton: { + alignItems: "center", + borderCurve: "continuous", + borderRadius: 9, + flex: 1, + justifyContent: "center", + minHeight: 38, + paddingHorizontal: 10, + }, + modeButtonSelected: { + backgroundColor: "#17201A", + }, + modeButtonPressed: { + opacity: 0.72, + }, + modeButtonText: { + color: "#69716B", + fontSize: 13, + fontWeight: "700", + }, + modeButtonTextSelected: { + color: "#FFFFFF", + }, + modeDescription: { + color: "#7D857F", + fontSize: 12, + lineHeight: 17, }, card: { aspectRatio: MATCHED_SCREEN_ASPECT_RATIO, diff --git a/apps/e2e/app/[stackType]/bounds/matched-screen/player.tsx b/apps/e2e/app/[stackType]/bounds/matched-screen/player.tsx index c9e527c9..b3f02061 100644 --- a/apps/e2e/app/[stackType]/bounds/matched-screen/player.tsx +++ b/apps/e2e/app/[stackType]/bounds/matched-screen/player.tsx @@ -4,14 +4,19 @@ import { StyleSheet, Text, View } from "react-native"; import Transition from "react-native-screen-transitions"; import { MATCHED_SCREEN_ASPECT_RATIO, - MATCHED_SCREEN_BOUNDARY_GROUP, MATCHED_SCREEN_DETAIL_WIDTH, MATCHED_SCREEN_VIDEOS, } from "./constants"; export default function MatchedScreenPlayer() { - const params = useLocalSearchParams<{ id?: string | string[] }>(); + const params = useLocalSearchParams<{ + handoffMode?: string | string[]; + id?: string | string[]; + }>(); const id = Array.isArray(params.id) ? params.id[0] : params.id; + const handoffMode = Array.isArray(params.handoffMode) + ? params.handoffMode[0] + : params.handoffMode; const example = MATCHED_SCREEN_VIDEOS.find((video) => video.id === id); if (!example) { @@ -34,13 +39,17 @@ export default function MatchedScreenPlayer() { {example.title} + + {handoffMode === "explicit" + ? "Explicit handoff target" + : "Automatic handoff"} + ); @@ -66,4 +75,10 @@ const styles = StyleSheet.create({ fontSize: 13, fontWeight: "600", }, + handoffMode: { + color: "#98A09A", + fontSize: 11, + fontWeight: "600", + textTransform: "uppercase", + }, }); diff --git a/apps/e2e/app/[stackType]/bounds/nested-boundary-example.tsx b/apps/e2e/app/[stackType]/bounds/nested-boundary-example.tsx deleted file mode 100644 index db9a68b9..00000000 --- a/apps/e2e/app/[stackType]/bounds/nested-boundary-example.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import { useNavigation } from "@react-navigation/native"; -import { router, useLocalSearchParams } from "expo-router"; -import { useState } from "react"; -import { Pressable, StyleSheet, Switch, Text, View } from "react-native"; -import { makeMutable } from "react-native-reanimated"; -import { SafeAreaView } from "react-native-safe-area-context"; -import type { ScreenTransitionConfig } from "react-native-screen-transitions"; -import Transition from "react-native-screen-transitions"; -import { ScreenHeader } from "@/components/screen-header"; -import { - buildStackPath, - useResolvedStackType, -} from "@/components/stack-examples/stack-routing"; -import { useTheme } from "@/theme"; - -const BOUNDARY_GROUP = "nested-stack-group"; - -const BOUNDARY_ITEMS = [ - { id: "violet", label: "Violet", color: "#7C3AED" }, - { id: "coral", label: "Coral", color: "#F43F5E" }, - { id: "cyan", label: "Cyan", color: "#0891B2" }, -] as const; - -const activeNestedBoundaryId = makeMutable(BOUNDARY_ITEMS[0].id); - -type ExampleParams = { - groups?: string; - id?: string; - targetBound?: string; -}; - -type NestedBoundaryMeta = { - nestedBoundaryGroups?: string; - nestedBoundaryTargetBound?: string; -}; - -type NestedBoundaryNavigation = { - setOptions: (options: { meta: NestedBoundaryMeta }) => void; -}; - -const isEnabled = (value: unknown) => value === "true"; - -const getRouteParam = (route: { params?: object } | undefined, key: string) => { - "worklet"; - const params = route?.params as Record | undefined; - return params?.[key]; -}; - -export const nestedBoundaryZoomInterpolator: ScreenTransitionConfig["screenStyleInterpolator"] = - ({ active, bounds, current, next, previous }) => { - "worklet"; - const groups = - active.meta?.nestedBoundaryGroups ?? - next?.meta?.nestedBoundaryGroups ?? - current.meta?.nestedBoundaryGroups ?? - previous?.meta?.nestedBoundaryGroups ?? - getRouteParam(active.route, "groups") ?? - getRouteParam(next?.route, "groups") ?? - getRouteParam(current.route, "groups") ?? - getRouteParam(previous?.route, "groups"); - const targetBoundParam = - active.meta?.nestedBoundaryTargetBound ?? - next?.meta?.nestedBoundaryTargetBound ?? - current.meta?.nestedBoundaryTargetBound ?? - previous?.meta?.nestedBoundaryTargetBound ?? - getRouteParam(active.route, "targetBound") ?? - getRouteParam(next?.route, "targetBound") ?? - getRouteParam(current.route, "targetBound") ?? - getRouteParam(previous?.route, "targetBound"); - const id = activeNestedBoundaryId.get(); - const group = groups === "true" ? BOUNDARY_GROUP : undefined; - const targetBound = targetBoundParam === "true"; - const boundary = bounds({ id, group }); - - return targetBound - ? boundary.navigation.zoom({ target: "bound" }) - : boundary.navigation.zoom(); - }; - -type ToggleRowProps = { - label: string; - value: boolean; - onValueChange: (value: boolean) => void; - testID: string; -}; - -function ToggleRow({ label, value, onValueChange, testID }: ToggleRowProps) { - const theme = useTheme(); - - return ( - - {label} - - - ); -} - -export function NestedBoundarySource({ - destination, - title, -}: { - destination: string; - title: string; -}) { - const stackType = useResolvedStackType(); - const theme = useTheme(); - const navigation = useNavigation() as NestedBoundaryNavigation; - const [groups, setGroups] = useState(true); - const [targetBound, setTargetBound] = useState(true); - const [escapeClipping, setEscapeClipping] = useState(true); - - const openDestination = (id: string) => { - activeNestedBoundaryId.set(id); - navigation.setOptions({ - meta: { - nestedBoundaryGroups: String(groups), - nestedBoundaryTargetBound: String(targetBound), - }, - }); - router.push({ - pathname: buildStackPath(stackType, destination) as never, - params: { - groups: String(groups), - id, - targetBound: String(targetBound), - }, - }); - }; - - return ( - - - - - - - - - - - {BOUNDARY_ITEMS.map((item, index) => ( - - openDestination(item.id)} - > - SOURCE - {item.label} - - - ))} - - - - ); -} - -export function NestedBoundaryDestination({ title }: { title: string }) { - const theme = useTheme(); - const params = useLocalSearchParams(); - const groups = isEnabled(params.groups); - const [activeId, setActiveId] = useState(params.id ?? BOUNDARY_ITEMS[0].id); - const activeItem = - BOUNDARY_ITEMS.find((item) => item.id === activeId) ?? BOUNDARY_ITEMS[0]; - const selectBoundary = (id: string) => { - activeNestedBoundaryId.set(id); - setActiveId(id); - }; - - return ( - - - - - {BOUNDARY_ITEMS.map((item) => { - const active = item.id === activeItem.id; - return ( - - DESTINATION - {item.label} - - ); - })} - - - {BOUNDARY_ITEMS.map((item) => { - const active = item.id === activeItem.id; - return ( - selectBoundary(item.id)} - > - - {item.label} - - - ); - })} - - - Groups: {groups ? "on" : "off"} · target bound:{" "} - {isEnabled(params.targetBound) ? "on" : "off"} · dismisses to:{" "} - {activeItem.label} - - - - ); -} - -const styles = StyleSheet.create({ - screen: { flex: 1 }, - content: { flex: 1, padding: 16, gap: 28 }, - destinationContent: { - flex: 1, - padding: 24, - justifyContent: "center", - gap: 24, - }, - toggles: { gap: 10 }, - toggleRow: { - minHeight: 54, - borderRadius: 16, - paddingHorizontal: 16, - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - }, - toggleLabel: { fontSize: 16, fontWeight: "600" }, - sourceCards: { flex: 1, gap: 12 }, - sourceCardContainer: { - height: 92, - }, - sourceCard: { - height: "100%", - width: "100%", - borderRadius: 28, - padding: 18, - justifyContent: "flex-end", - overflow: "hidden", - }, - sourceCard1: { width: "46%" }, - sourceCard2: { width: "58%", alignSelf: "flex-end" }, - sourceCard3: { width: "72%", alignSelf: "center" }, - sourceCardTitle: { color: "#FFFFFF", fontSize: 18, fontWeight: "800" }, - destinationCardStage: { - height: 280, - width: "100%", - }, - destinationCard: { - ...StyleSheet.absoluteFillObject, - height: 280, - width: "100%", - borderRadius: 36, - backgroundColor: "#7C3AED", - padding: 24, - justifyContent: "flex-end", - overflow: "hidden", - }, - inactiveDestinationCard: { - opacity: 0, - }, - cardEyebrow: { - color: "rgba(255,255,255,0.72)", - fontSize: 11, - fontWeight: "800", - letterSpacing: 1.2, - }, - cardTitle: { - color: "#FFFFFF", - fontSize: 22, - fontWeight: "800", - marginTop: 4, - }, - idControls: { flexDirection: "row", gap: 8 }, - idControl: { - flex: 1, - alignItems: "center", - paddingVertical: 12, - borderRadius: 14, - }, - summary: { fontSize: 14, lineHeight: 21, textAlign: "center" }, -}); diff --git a/apps/e2e/app/[stackType]/bounds/stacking/[id].tsx b/apps/e2e/app/[stackType]/bounds/stacking/[id].tsx index ec8dd695..bb577a1e 100644 --- a/apps/e2e/app/[stackType]/bounds/stacking/[id].tsx +++ b/apps/e2e/app/[stackType]/bounds/stacking/[id].tsx @@ -1,17 +1,20 @@ -import { StackActions, useNavigation } from "@react-navigation/native"; -import { useLocalSearchParams } from "expo-router"; +import { router, useLocalSearchParams } from "expo-router"; import { Pressable, StyleSheet, Text, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import Transition from "react-native-screen-transitions"; -import { ScreenHeader } from "@/components/screen-header"; -import { useTheme } from "@/theme"; import { parseStackDepth, STACKING_BUTTON_BOUNDARY_ID, STACKING_CARD_BOUNDARY_ID, type StackingBoundaryId, -} from "./constants"; -import { StackingCardContent } from "./stacking-card-content"; +} from "@/components/bounds-stacking/constants"; +import { StackingCardContent } from "@/components/bounds-stacking/stacking-card-content"; +import { ScreenHeader } from "@/components/screen-header"; +import { + buildStackPath, + useResolvedStackType, +} from "@/components/stack-examples/stack-routing"; +import { useTheme } from "@/theme"; export default function StackingBoundsDetail() { const { depth: depthParam, id: idParam } = useLocalSearchParams<{ @@ -22,16 +25,17 @@ export default function StackingBoundsDetail() { const routeId = Array.isArray(idParam) ? idParam[0] : (idParam ?? STACKING_CARD_BOUNDARY_ID); - const navigation = useNavigation(); + const stackType = useResolvedStackType(); const theme = useTheme(); const pushIndex = (id: StackingBoundaryId) => { - navigation.dispatch( - StackActions.push("index", { + router.push({ + pathname: buildStackPath(stackType, "bounds/stacking"), + params: { depth: String(depth + 1), id, - }), - ); + }, + } as never); }; return ( @@ -52,13 +56,13 @@ export default function StackingBoundsDetail() { testID="stacking-detail-boundary" onPress={() => pushIndex(STACKING_CARD_BOUNDARY_ID)} > - - + @@ -75,7 +79,7 @@ export default function StackingBoundsDetail() { testID="stacking-push-index" onPress={() => pushIndex(STACKING_BUTTON_BOUNDARY_ID)} > - depth {depth + 1} → - + diff --git a/apps/e2e/app/[stackType]/bounds/stacking/_layout.tsx b/apps/e2e/app/[stackType]/bounds/stacking/_layout.tsx index 8d883cf2..84f30d41 100644 --- a/apps/e2e/app/[stackType]/bounds/stacking/_layout.tsx +++ b/apps/e2e/app/[stackType]/bounds/stacking/_layout.tsx @@ -31,7 +31,7 @@ export default function StackingBoundsLayout() { stackType === "native-stack" ? { enableTransitions: true } : undefined; return ( - + @@ -40,7 +40,6 @@ export default function StackingBoundsLayout() { backdropBehavior: "dismiss", gestureDirection: ["bidirectional", "pinch-in"], gestureEnabled: true, - gestureProgressMode: "freeform", navigationMaskEnabled: true, screenStyleInterpolator: stackingInterpolator, transitionSpec: Transition.Specs.Zoom, @@ -54,7 +53,6 @@ export default function StackingBoundsLayout() { backdropBehavior: "dismiss", gestureDirection: ["bidirectional", "pinch-in"], gestureEnabled: true, - gestureProgressMode: "freeform", navigationMaskEnabled: true, screenStyleInterpolator: stackingInterpolator, transitionSpec: Transition.Specs.Zoom, diff --git a/apps/e2e/app/[stackType]/bounds/stacking/index.tsx b/apps/e2e/app/[stackType]/bounds/stacking/index.tsx index 41a2a1f0..67b59099 100644 --- a/apps/e2e/app/[stackType]/bounds/stacking/index.tsx +++ b/apps/e2e/app/[stackType]/bounds/stacking/index.tsx @@ -1,33 +1,37 @@ -import { StackActions, useNavigation } from "@react-navigation/native"; -import { useLocalSearchParams } from "expo-router"; +import { router, useLocalSearchParams } from "expo-router"; import { Pressable, StyleSheet, Text, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import Transition from "react-native-screen-transitions"; -import { ScreenHeader } from "@/components/screen-header"; -import { useTheme } from "@/theme"; import { parseStackDepth, STACKING_BUTTON_BOUNDARY_ID, STACKING_CARD_BOUNDARY_ID, type StackingBoundaryId, -} from "./constants"; -import { StackingCardContent } from "./stacking-card-content"; +} from "@/components/bounds-stacking/constants"; +import { StackingCardContent } from "@/components/bounds-stacking/stacking-card-content"; +import { ScreenHeader } from "@/components/screen-header"; +import { + buildStackPath, + useResolvedStackType, +} from "@/components/stack-examples/stack-routing"; +import { useTheme } from "@/theme"; export default function StackingBoundsIndex() { const { depth: depthParam } = useLocalSearchParams<{ depth?: string | string[]; }>(); const depth = parseStackDepth(depthParam); - const navigation = useNavigation(); + const stackType = useResolvedStackType(); const theme = useTheme(); const pushDetail = (id: StackingBoundaryId) => { - navigation.dispatch( - StackActions.push("[id]", { + router.push({ + pathname: buildStackPath(stackType, "bounds/stacking/[id]"), + params: { depth: String(depth + 1), id, - }), - ); + }, + } as never); }; return ( @@ -61,20 +65,20 @@ export default function StackingBoundsIndex() { testID="stacking-index-boundary" onPress={() => pushDetail(STACKING_CARD_BOUNDARY_ID)} > - - + pushDetail(STACKING_BUTTON_BOUNDARY_ID)} > - depth {depth + 1} → - + diff --git a/apps/e2e/app/[stackType]/bounds/style-id/[id].tsx b/apps/e2e/app/[stackType]/bounds/style-id/[id].tsx index 3516847b..55b8ae29 100644 --- a/apps/e2e/app/[stackType]/bounds/style-id/[id].tsx +++ b/apps/e2e/app/[stackType]/bounds/style-id/[id].tsx @@ -45,7 +45,7 @@ function SharedImage({ const theme = useTheme(); const insets = useSafeAreaInsets(); return ( - - + ); } diff --git a/apps/e2e/app/[stackType]/bounds/style-id/index.tsx b/apps/e2e/app/[stackType]/bounds/style-id/index.tsx index d78ec4b4..653ed36b 100644 --- a/apps/e2e/app/[stackType]/bounds/style-id/index.tsx +++ b/apps/e2e/app/[stackType]/bounds/style-id/index.tsx @@ -214,7 +214,7 @@ export default function StyleIdBoundsIndex() { {BOARDS.map((item) => { const tag = `shared-image-${item.id}`; return ( - - + ); })} @@ -266,7 +266,7 @@ export default function StyleIdBoundsIndex() { const tag = `shared-image-${item.id}`; return ( - - + {item.title} @@ -317,7 +317,7 @@ export default function StyleIdBoundsIndex() { {TEMPLATES.map((item) => { const tag = `shared-image-${item.id}`; return ( - - + ); })} diff --git a/apps/e2e/app/[stackType]/bounds/sync/destination.tsx b/apps/e2e/app/[stackType]/bounds/sync/destination.tsx index 427f6d8f..5d339b3b 100644 --- a/apps/e2e/app/[stackType]/bounds/sync/destination.tsx +++ b/apps/e2e/app/[stackType]/bounds/sync/destination.tsx @@ -66,7 +66,7 @@ export default function BoundsSyncDestination() { {destination.description} - DST - + {/* Ghost outline showing where the source was */} - Destination - + diff --git a/apps/e2e/app/[stackType]/bounds/sync/opening-transform/index.tsx b/apps/e2e/app/[stackType]/bounds/sync/opening-transform/index.tsx index 32cd6bca..cf548086 100644 --- a/apps/e2e/app/[stackType]/bounds/sync/opening-transform/index.tsx +++ b/apps/e2e/app/[stackType]/bounds/sync/opening-transform/index.tsx @@ -53,7 +53,7 @@ export default function OpeningTransformBoundsIndex() { - Shared element source - + diff --git a/apps/e2e/app/[stackType]/bounds/sync/retarget/[id].tsx b/apps/e2e/app/[stackType]/bounds/sync/retarget/[id].tsx index 93bf92fc..ae6bea40 100644 --- a/apps/e2e/app/[stackType]/bounds/sync/retarget/[id].tsx +++ b/apps/e2e/app/[stackType]/bounds/sync/retarget/[id].tsx @@ -98,7 +98,7 @@ export default function SyncRetargetDetail() { layout frame ) : null} - - + diff --git a/apps/e2e/app/[stackType]/bounds/sync/retarget/index.tsx b/apps/e2e/app/[stackType]/bounds/sync/retarget/index.tsx index 47dd6ad4..f40da76c 100644 --- a/apps/e2e/app/[stackType]/bounds/sync/retarget/index.tsx +++ b/apps/e2e/app/[stackType]/bounds/sync/retarget/index.tsx @@ -30,7 +30,7 @@ export default function SyncRetargetIndex() { /> - return target - + ); diff --git a/apps/e2e/app/[stackType]/bounds/sync/source.tsx b/apps/e2e/app/[stackType]/bounds/sync/source.tsx index 5bc26b56..a370e6df 100644 --- a/apps/e2e/app/[stackType]/bounds/sync/source.tsx +++ b/apps/e2e/app/[stackType]/bounds/sync/source.tsx @@ -79,7 +79,7 @@ export default function BoundsSyncSource() { {source.description} - SRC - + {!needsExplicitMode && ( - {item.color.toUpperCase()} - + diff --git a/apps/e2e/app/[stackType]/bounds/zoom/_layout.tsx b/apps/e2e/app/[stackType]/bounds/zoom/_layout.tsx index 4216d419..bb88eca9 100644 --- a/apps/e2e/app/[stackType]/bounds/zoom/_layout.tsx +++ b/apps/e2e/app/[stackType]/bounds/zoom/_layout.tsx @@ -28,7 +28,7 @@ const navigationZoomInterpolator: ScreenTransitionConfig["screenStyleInterpolato const navigationStyles = bounds({ id, group: ZOOM_GROUP, - }).navigation.zoom(); + }).navigation.zoom({ target: "bound" }); return { ...navigationStyles, @@ -56,7 +56,6 @@ export default function NavigationZoomGroupTransitionsLayout() { gestureEnabled: true, gestureDirection: ["bidirectional", "pinch-in"], gestureReleaseVelocityScale: 1.6, - gestureProgressMode: "freeform", screenStyleInterpolator: navigationZoomInterpolator, transitionSpec: Transition.Specs.Zoom, }} diff --git a/apps/e2e/app/[stackType]/bounds/zoom/index.tsx b/apps/e2e/app/[stackType]/bounds/zoom/index.tsx index 46115847..a397a94e 100644 --- a/apps/e2e/app/[stackType]/bounds/zoom/index.tsx +++ b/apps/e2e/app/[stackType]/bounds/zoom/index.tsx @@ -28,7 +28,7 @@ function ZoomSourceCard({ const cardWidth = item.cols === 2 ? colWidth * 2 + GAP : colWidth; return ( - {item.title} {item.subtitle} - + ); } diff --git a/apps/e2e/app/[stackType]/custom-background.tsx b/apps/e2e/app/[stackType]/custom-background.tsx index c0d28f71..37443879 100644 --- a/apps/e2e/app/[stackType]/custom-background.tsx +++ b/apps/e2e/app/[stackType]/custom-background.tsx @@ -14,17 +14,17 @@ export default function CustomBackgroundScreen() { return ( - Surface Slot + Content Slot - This route renders `surfaceComponent` and drives styles/props via - the `surface` slot while screen motion stays in `content`. + This route renders `contentComponent` and drives its animated styles + and props through the `content` slot. ({ inactiveBehavior, }); +const INACTIVE_OPTIONS = { + hide: inactiveOptions("hide"), + pause: inactiveOptions("pause"), + unmount: inactiveOptions("unmount"), + keep: inactiveOptions("keep"), +}; + +const NATIVE_SCREEN_OPTIONS = { enableTransitions: true }; + export default function InactiveBehaviorLayout() { const stackType = useResolvedStackType(); const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; const navigatorScreenOptions = - stackType === "native-stack" ? { enableTransitions: true } : undefined; + stackType === "native-stack" ? NATIVE_SCREEN_OPTIONS : undefined; return ( - - + + - + ); } diff --git a/apps/e2e/app/[stackType]/overlay/_layout.tsx b/apps/e2e/app/[stackType]/overlay/_layout.tsx index 4025fe50..f202da7d 100644 --- a/apps/e2e/app/[stackType]/overlay/_layout.tsx +++ b/apps/e2e/app/[stackType]/overlay/_layout.tsx @@ -23,7 +23,6 @@ export default function OverlayPlaygroundLayout() { ...overlayIOSSlideOptions, gestureEnabled: false, overlay: OverlayA, - overlayMode: "float", overlayShown: true, }} /> @@ -38,7 +37,6 @@ export default function OverlayPlaygroundLayout() { options={{ ...overlayIOSSlideOptions, overlay: OverlayC, - overlayMode: "float", overlayShown: true, }} /> @@ -53,7 +51,6 @@ export default function OverlayPlaygroundLayout() { options={{ ...overlayIOSSlideOptions, overlay: OverlayE, - overlayMode: "float", overlayShown: true, }} /> diff --git a/apps/e2e/app/[stackType]/shared-x-image/[id].tsx b/apps/e2e/app/[stackType]/shared-x-image/[id].tsx deleted file mode 100644 index 63ee7898..00000000 --- a/apps/e2e/app/[stackType]/shared-x-image/[id].tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Image } from "expo-image"; -import { useLocalSearchParams } from "expo-router"; -import { StyleSheet, View } from "react-native"; -import Transition from "react-native-screen-transitions"; -import { getXPostImageUrl } from "./constants"; - -const FALLBACK_BOUND_TAG = "shared-x-image-fallback"; - -const getSingleParam = (value: string | string[] | undefined) => - Array.isArray(value) ? value[0] : value; - -export default function SharedXImageDetail() { - const params = useLocalSearchParams<{ - id?: string | string[]; - boundId?: string | string[]; - url?: string | string[]; - }>(); - const postId = getSingleParam(params.id) ?? "28"; - const boundId = getSingleParam(params.boundId) ?? FALLBACK_BOUND_TAG; - const imageUrl = getSingleParam(params.url) ?? getXPostImageUrl(postId); - - return ( - - - - - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: "center", - justifyContent: "center", - }, - imageFrame: { - width: "100%", - aspectRatio: 1, - overflow: "hidden", - }, - image: { - width: "100%", - height: "100%", - }, -}); diff --git a/apps/e2e/app/[stackType]/shared-x-image/_layout.tsx b/apps/e2e/app/[stackType]/shared-x-image/_layout.tsx deleted file mode 100644 index 248f12bf..00000000 --- a/apps/e2e/app/[stackType]/shared-x-image/_layout.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useGlobalSearchParams } from "expo-router"; -import Transition from "react-native-screen-transitions"; -import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; -import { BlankStack } from "@/layouts/blank-stack"; -import { Stack } from "@/layouts/stack"; - -const FALLBACK_BOUND_TAG = "shared-x-image-fallback"; - -const getSingleParam = (value: string | string[] | undefined) => - Array.isArray(value) ? value[0] : value; - -export default function SharedXImageLayout() { - const stackType = useResolvedStackType(); - const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; - const navigatorScreenOptions = - stackType === "native-stack" ? { enableTransitions: true } : undefined; - const params = useGlobalSearchParams<{ boundId?: string | string[] }>(); - const boundId = getSingleParam(params.boundId) ?? FALLBACK_BOUND_TAG; - - return ( - - - - - ); -} diff --git a/apps/e2e/app/[stackType]/shared-x-image/constants.ts b/apps/e2e/app/[stackType]/shared-x-image/constants.ts deleted file mode 100644 index 6f56d67e..00000000 --- a/apps/e2e/app/[stackType]/shared-x-image/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const X_POST_IDS = ["28", "29", "74", "87", "120", "121"] as const; - -export const getXPostImageUrl = (id: string) => - `https://picsum.photos/id/${id}/600/900`; diff --git a/apps/e2e/app/[stackType]/shared-x-image/index.tsx b/apps/e2e/app/[stackType]/shared-x-image/index.tsx deleted file mode 100644 index 8b06dd19..00000000 --- a/apps/e2e/app/[stackType]/shared-x-image/index.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { FontAwesome6 } from "@expo/vector-icons"; -import { Image } from "expo-image"; -import { router } from "expo-router"; -import { ScrollView, StyleSheet, Text, View } from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import Transition from "react-native-screen-transitions"; -import { ScreenHeader } from "@/components/screen-header"; -import { - buildStackPath, - useResolvedStackType, -} from "@/components/stack-examples/stack-routing"; -import { getXPostImageUrl, X_POST_IDS } from "./constants"; - -const PLACEHOLDER_COLOR = "#E5E7EB"; - -export default function SharedXImageIndex() { - const stackType = useResolvedStackType(); - - return ( - - - - - - - X - - - - - For you - Following - - - - {X_POST_IDS.map((postId) => ( - - ))} - - - ); -} - -function Post({ - postId, - stackType, -}: { - postId: string; - stackType: "blank-stack" | "native-stack"; -}) { - const boundId = `shared-x-image-${postId}`; - const imageUrl = getXPostImageUrl(postId); - - return ( - - - - - - User - @username - 12h - - - { - router.push({ - pathname: buildStackPath( - stackType, - `shared-x-image/${postId}`, - ) as never, - params: { - boundId, - url: imageUrl, - }, - }); - }} - > - - - - - - - - - - - - - - ); -} - -function Action({ - icon, - label, -}: { - icon: "comment" | "retweet" | "heart"; - label: string; -}) { - return ( - - - {label} - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#FFF", - }, - content: { - paddingBottom: 32, - }, - brandRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingHorizontal: 16, - marginTop: 8, - }, - profileDot: { - width: 32, - height: 32, - borderRadius: 999, - backgroundColor: PLACEHOLDER_COLOR, - }, - brandTitle: { - fontSize: 28, - fontWeight: "800", - color: "#000", - }, - brandSpacer: { - width: 32, - }, - tabs: { - flexDirection: "row", - gap: 22, - paddingHorizontal: 16, - marginTop: 18, - }, - activeTab: { - fontSize: 16, - fontWeight: "700", - color: "#000", - }, - inactiveTab: { - fontSize: 16, - fontWeight: "600", - color: "#6B7280", - }, - activeUnderline: { - height: 3, - width: 64, - borderRadius: 999, - backgroundColor: "#F43F5E", - marginTop: 10, - marginHorizontal: 16, - }, - post: { - paddingHorizontal: 12, - paddingVertical: 10, - borderBottomWidth: 1, - borderBottomColor: "rgba(209,213,219,0.5)", - }, - postRow: { - flexDirection: "row", - gap: 8, - }, - avatar: { - width: 44, - height: 44, - borderRadius: 999, - backgroundColor: PLACEHOLDER_COLOR, - }, - postContent: { - flex: 1, - gap: 8, - }, - metaRow: { - flexDirection: "row", - alignItems: "center", - gap: 6, - }, - author: { - fontSize: 16, - fontWeight: "700", - color: "#000", - }, - handle: { - fontSize: 14, - color: "#6B7280", - }, - imageFrame: { - aspectRatio: 1, - width: "100%", - borderRadius: 10, - overflow: "hidden", - backgroundColor: PLACEHOLDER_COLOR, - }, - image: { - flex: 1, - }, - actionsRow: { - flexDirection: "row", - gap: 28, - }, - action: { - flexDirection: "row", - alignItems: "center", - gap: 4, - }, - actionLabel: { - fontSize: 14, - color: "#6B7280", - }, -}); diff --git a/apps/e2e/app/_layout.tsx b/apps/e2e/app/_layout.tsx index 8d0c9039..b8060196 100644 --- a/apps/e2e/app/_layout.tsx +++ b/apps/e2e/app/_layout.tsx @@ -7,7 +7,6 @@ import Transition from "react-native-screen-transitions"; import type { StackType } from "@/components/stack-examples/stack-routing"; import { StackSelectionContext } from "@/components/stack-examples/stack-selection"; import { BlankStack } from "@/layouts/blank-stack"; -import { Stack } from "@/layouts/stack"; import { IOSSlide } from "@/lib/screen-transitions/ios-slide"; LogBox.ignoreAllLogs(); @@ -16,16 +15,11 @@ const stackScreen = (name: string) => `[stackType]/${name}`; export default function RootLayout() { const [stackType, setStackType] = useState("blank-stack"); - const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; - // Native stack support uses transparent modals today. It is useful for one-off custom - // animations in an existing native-stack tree, but should move to true native - // animations in the next major. - const navigatorScreenOptions = - stackType === "native-stack" ? { enableTransitions: true } : undefined; + const StackNavigator = BlankStack; return ( - + - { "worklet"; return { @@ -98,10 +88,6 @@ export default function RootLayout() { ), }, ], - }, - }, - surface: { - style: { backgroundColor: "#4A90E2", borderRadius: active.animating ? 48 : 0, overflow: "hidden", diff --git a/apps/e2e/app/maestro/_layout.tsx b/apps/e2e/app/maestro/_layout.tsx index 14e84f7c..e23c03bc 100644 --- a/apps/e2e/app/maestro/_layout.tsx +++ b/apps/e2e/app/maestro/_layout.tsx @@ -43,7 +43,10 @@ export default function MaestroLayout() { name="swipe-horizontal-inverted" options={slideOptions("horizontal-inverted")} /> - + diff --git a/apps/e2e/app/native-stack-adapter-recipe/_layout.tsx b/apps/e2e/app/native-stack-adapter-recipe/_layout.tsx index ff04b8ad..8dc19a0b 100644 --- a/apps/e2e/app/native-stack-adapter-recipe/_layout.tsx +++ b/apps/e2e/app/native-stack-adapter-recipe/_layout.tsx @@ -57,6 +57,12 @@ export default function NativeStackAdapterRecipeLayout() { transitionSpec: Transition.Specs.Zoom, }} /> + ); } diff --git a/apps/e2e/app/native-stack-adapter-recipe/avatar.tsx b/apps/e2e/app/native-stack-adapter-recipe/avatar.tsx index 8c441389..6f384b17 100644 --- a/apps/e2e/app/native-stack-adapter-recipe/avatar.tsx +++ b/apps/e2e/app/native-stack-adapter-recipe/avatar.tsx @@ -9,7 +9,7 @@ export default function NativeStackAdapterRecipeAvatar() { return ( - @@ -18,7 +18,7 @@ export default function NativeStackAdapterRecipeAvatar() { style={styles.image} contentFit="cover" /> - + { + "worklet"; + + return { + ...bounds({ id: "booking-destination" }).navigation.zoom({ + borderRadius: 28, + target: "bound", + }), + backdrop: focused + ? { + backgroundColor: "black", + opacity: interpolate( + active.transitionProgress, + [0, 1, 2], + [0, 0.35, 0], + ), + } + : undefined, + }; + }; + +export default function BookingLayout() { + return ( + + + + + + + + ); +} diff --git a/apps/e2e/app/native-stack-adapter-recipe/booking/checkout/_layout.tsx b/apps/e2e/app/native-stack-adapter-recipe/booking/checkout/_layout.tsx new file mode 100644 index 00000000..5fb39900 --- /dev/null +++ b/apps/e2e/app/native-stack-adapter-recipe/booking/checkout/_layout.tsx @@ -0,0 +1,23 @@ +import Transition from "react-native-screen-transitions"; +import { NativeStackAdapter } from "@/layouts/native-stack-adapter"; + +export default function CheckoutLayout() { + return ( + + + + + + ); +} diff --git a/apps/e2e/app/native-stack-adapter-recipe/booking/checkout/index.tsx b/apps/e2e/app/native-stack-adapter-recipe/booking/checkout/index.tsx new file mode 100644 index 00000000..dc92fda3 --- /dev/null +++ b/apps/e2e/app/native-stack-adapter-recipe/booking/checkout/index.tsx @@ -0,0 +1,101 @@ +import { router } from "expo-router"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { useBookingState } from "@/components/native-stack-adapter/booking-state"; +import { useTheme } from "@/theme"; + +export default function CheckoutTravelers() { + const theme = useTheme(); + const { travelers, setTravelers } = useBookingState(); + + return ( + + + STEP 1 OF 3 + + Who’s coming? + + This value lives above both nested navigators and should survive every + push and pop. + + + + setTravelers(Math.max(1, travelers - 1))} + style={styles.counterButton} + > + + + + + {travelers} + + + travelers + + + setTravelers(Math.min(6, travelers + 1))} + style={styles.counterButton} + > + + + + + + + router.push( + "/native-stack-adapter-recipe/booking/checkout/preferences", + ) + } + style={[styles.button, { backgroundColor: theme.actionButton }]} + > + + Choose preferences + + + + ); +} + +const styles = StyleSheet.create({ + screen: { flex: 1, padding: 22, paddingTop: 42 }, + eyebrow: { fontSize: 11, fontWeight: "900", letterSpacing: 1.6 }, + title: { + marginTop: 10, + fontSize: 38, + fontWeight: "900", + letterSpacing: -1.4, + }, + copy: { marginTop: 10, fontSize: 15, lineHeight: 22, fontWeight: "500" }, + counter: { + marginTop: 38, + borderRadius: 26, + padding: 18, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + counterButton: { + width: 58, + height: 58, + borderRadius: 20, + backgroundColor: "rgba(127,127,127,0.12)", + alignItems: "center", + justifyContent: "center", + }, + counterSymbol: { fontSize: 30, fontWeight: "600" }, + count: { alignItems: "center" }, + countValue: { fontSize: 42, fontWeight: "900" }, + countLabel: { fontSize: 12, fontWeight: "700" }, + button: { + marginTop: "auto", + minHeight: 58, + borderRadius: 19, + alignItems: "center", + justifyContent: "center", + }, + buttonText: { fontSize: 16, fontWeight: "800" }, +}); diff --git a/apps/e2e/app/native-stack-adapter-recipe/booking/checkout/preferences.tsx b/apps/e2e/app/native-stack-adapter-recipe/booking/checkout/preferences.tsx new file mode 100644 index 00000000..9b8f244d --- /dev/null +++ b/apps/e2e/app/native-stack-adapter-recipe/booking/checkout/preferences.tsx @@ -0,0 +1,131 @@ +import { router } from "expo-router"; +import { Pressable, StyleSheet, Switch, Text, View } from "react-native"; +import { useBookingState } from "@/components/native-stack-adapter/booking-state"; +import { useTheme } from "@/theme"; + +export default function CheckoutPreferences() { + const theme = useTheme(); + const { seat, setSeat, insurance, setInsurance } = useBookingState(); + + return ( + + + STEP 2 OF 3 + + Make it yours. + + + + SEAT PREFERENCE + + + {(["window", "aisle"] as const).map((value) => ( + setSeat(value)} + style={[ + styles.choice, + { + backgroundColor: + seat === value ? theme.actionButton : theme.card, + }, + ]} + > + + {value === "window" ? "◫" : "↔"} + + + {value === "window" ? "Window" : "Aisle"} + + + ))} + + + + + + + Travel protection + + + Flexible cancellation for this booking. + + + + + + + router.push("/native-stack-adapter-recipe/booking/checkout/review") + } + style={[styles.button, { backgroundColor: theme.actionButton }]} + > + + Review booking + + + + ); +} + +const styles = StyleSheet.create({ + screen: { flex: 1, padding: 22, paddingTop: 42 }, + eyebrow: { fontSize: 11, fontWeight: "900", letterSpacing: 1.6 }, + title: { + marginTop: 10, + fontSize: 38, + fontWeight: "900", + letterSpacing: -1.4, + }, + section: { marginTop: 38, gap: 10 }, + label: { fontSize: 10, fontWeight: "900", letterSpacing: 1.4 }, + row: { flexDirection: "row", gap: 10 }, + choice: { + flex: 1, + height: 118, + borderRadius: 22, + padding: 16, + justifyContent: "space-between", + }, + choiceIcon: { fontSize: 27, fontWeight: "700" }, + choiceText: { fontSize: 16, fontWeight: "800", textTransform: "capitalize" }, + insurance: { + marginTop: 18, + borderRadius: 22, + padding: 17, + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + insuranceCopy: { flex: 1, gap: 4 }, + insuranceTitle: { fontSize: 16, fontWeight: "800" }, + insuranceBody: { fontSize: 13, lineHeight: 18, fontWeight: "500" }, + button: { + marginTop: "auto", + minHeight: 58, + borderRadius: 19, + alignItems: "center", + justifyContent: "center", + }, + buttonText: { fontSize: 16, fontWeight: "800" }, +}); diff --git a/apps/e2e/app/native-stack-adapter-recipe/booking/checkout/review.tsx b/apps/e2e/app/native-stack-adapter-recipe/booking/checkout/review.tsx new file mode 100644 index 00000000..c60e406d --- /dev/null +++ b/apps/e2e/app/native-stack-adapter-recipe/booking/checkout/review.tsx @@ -0,0 +1,130 @@ +import { router } from "expo-router"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useBookingState } from "@/components/native-stack-adapter/booking-state"; + +export default function CheckoutReview() { + const { travelers, seat, insurance } = useBookingState(); + const total = 480 * travelers + (insurance ? 42 : 0); + + return ( + + + READY TO BOOK + + Ericeira is waiting. + + The selections made across the nested flow are still here. + + + + + + + + + + + router.dismissTo("/native-stack-adapter-recipe")} + style={styles.button} + > + Finish demo + + + Swipe down to return to preferences, then back through every nested + stack. + + + ); +} + +function Row({ + label, + value, + strong = false, +}: { + label: string; + value: string; + strong?: boolean; +}) { + return ( + + {label} + {value} + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: "#17151B", + padding: 24, + justifyContent: "center", + }, + badge: { + alignSelf: "flex-start", + backgroundColor: "#E75B3A", + borderRadius: 10, + paddingHorizontal: 11, + paddingVertical: 7, + }, + badgeText: { + color: "#17151B", + fontSize: 10, + fontWeight: "900", + letterSpacing: 1.3, + }, + title: { + color: "white", + fontSize: 42, + lineHeight: 44, + fontWeight: "900", + letterSpacing: -1.7, + marginTop: 18, + }, + copy: { + color: "#A9A5B0", + fontSize: 15, + lineHeight: 22, + fontWeight: "500", + marginTop: 10, + }, + summary: { + backgroundColor: "#25222A", + borderRadius: 24, + padding: 20, + gap: 16, + marginTop: 34, + }, + row: { flexDirection: "row", justifyContent: "space-between" }, + rowText: { + color: "#B9B5BF", + fontSize: 15, + fontWeight: "600", + textTransform: "capitalize", + }, + strong: { color: "white", fontSize: 20, fontWeight: "900" }, + divider: { height: 1, backgroundColor: "#3B3741" }, + button: { + minHeight: 58, + borderRadius: 19, + backgroundColor: "#E75B3A", + alignItems: "center", + justifyContent: "center", + marginTop: 22, + }, + buttonText: { color: "#17151B", fontSize: 16, fontWeight: "900" }, + hint: { + color: "#77727E", + textAlign: "center", + fontSize: 12, + lineHeight: 18, + marginTop: 14, + }, +}); diff --git a/apps/e2e/app/native-stack-adapter-recipe/booking/destination.tsx b/apps/e2e/app/native-stack-adapter-recipe/booking/destination.tsx new file mode 100644 index 00000000..f1fda412 --- /dev/null +++ b/apps/e2e/app/native-stack-adapter-recipe/booking/destination.tsx @@ -0,0 +1,122 @@ +import { router } from "expo-router"; +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import Transition from "react-native-screen-transitions"; + +export default function BookingDestination() { + return ( + + + + + + ERICEIRA, PORTUGAL + + Atlantic light,{"\n"}slow mornings. + + + + + + + + STAY + 3 nights + + + FLIGHT + Direct + + + FROM + $480 + + + + A small coastal hotel, a guided surf morning, and enough empty time + to discover the town yourself. + + + router.push("/native-stack-adapter-recipe/booking/checkout") + } + style={({ pressed }) => [ + styles.button, + { opacity: pressed ? 0.75 : 1 }, + ]} + > + Reserve this trip + + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { flex: 1, backgroundColor: "#F7F2E9" }, + content: { paddingBottom: 28 }, + hero: { + height: 455, + backgroundColor: "#E75B3A", + overflow: "hidden", + justifyContent: "flex-end", + padding: 24, + }, + sun: { + position: "absolute", + width: 300, + height: 300, + borderRadius: 150, + backgroundColor: "#FFD84D", + top: -60, + right: -40, + }, + heroCopy: { gap: 10 }, + location: { + color: "#301516", + fontSize: 11, + fontWeight: "900", + letterSpacing: 1.7, + }, + heroTitle: { + color: "#301516", + fontSize: 42, + lineHeight: 43, + fontWeight: "900", + letterSpacing: -1.6, + }, + body: { padding: 22, gap: 24 }, + facts: { + flexDirection: "row", + justifyContent: "space-between", + backgroundColor: "white", + padding: 18, + borderRadius: 20, + }, + factLabel: { + color: "#9A9187", + fontSize: 9, + fontWeight: "900", + letterSpacing: 1.2, + }, + fact: { color: "#24201C", fontSize: 16, fontWeight: "800", marginTop: 4 }, + description: { + color: "#5F574F", + fontSize: 16, + lineHeight: 24, + fontWeight: "500", + }, + button: { + minHeight: 58, + borderRadius: 19, + paddingHorizontal: 20, + backgroundColor: "#24201C", + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + buttonText: { color: "white", fontSize: 16, fontWeight: "800" }, +}); diff --git a/apps/e2e/app/native-stack-adapter-recipe/booking/index.tsx b/apps/e2e/app/native-stack-adapter-recipe/booking/index.tsx new file mode 100644 index 00000000..4534501f --- /dev/null +++ b/apps/e2e/app/native-stack-adapter-recipe/booking/index.tsx @@ -0,0 +1,112 @@ +import { router } from "expo-router"; +import { ScrollView, StyleSheet, Text, View } from "react-native"; +import Transition from "react-native-screen-transitions"; +import { useTheme } from "@/theme"; + +export default function BookingIndex() { + const theme = useTheme(); + + return ( + + + + WEEKEND PICKS + + + Go somewhere worth remembering. + + + + + router.push("/native-stack-adapter-recipe/booking/destination") + } + style={styles.destination} + > + + + PORTUGAL · 3 NIGHTS + + Atlantic light,{"\n"}slow mornings. + + From $480 + + + + + + 🏔️ + + Dolomites + + + 5 nights + + + + 🌊 + + Okinawa + + + 4 nights + + + + + ); +} + +const styles = StyleSheet.create({ + content: { padding: 20, paddingBottom: 40, gap: 24 }, + heading: { gap: 8, marginTop: 12 }, + eyebrow: { fontSize: 11, fontWeight: "900", letterSpacing: 1.8 }, + title: { + fontSize: 36, + lineHeight: 39, + fontWeight: "900", + letterSpacing: -1.4, + }, + destination: { + height: 410, + borderRadius: 28, + backgroundColor: "#E75B3A", + overflow: "hidden", + padding: 24, + justifyContent: "flex-end", + }, + sun: { + position: "absolute", + width: 230, + height: 230, + borderRadius: 115, + backgroundColor: "#FFD84D", + top: -34, + right: -30, + }, + destinationCopy: { gap: 10 }, + location: { + color: "#301516", + fontSize: 11, + fontWeight: "900", + letterSpacing: 1.6, + }, + destinationTitle: { + color: "#301516", + fontSize: 39, + lineHeight: 40, + fontWeight: "900", + letterSpacing: -1.4, + }, + price: { color: "rgba(48,21,22,0.72)", fontSize: 15, fontWeight: "800" }, + secondaryRow: { flexDirection: "row", gap: 12 }, + smallCard: { flex: 1, borderRadius: 22, padding: 18, gap: 5 }, + smallEmoji: { fontSize: 30, marginBottom: 12 }, + smallTitle: { fontSize: 18, fontWeight: "800" }, + smallMeta: { fontSize: 13, fontWeight: "600" }, +}); diff --git a/apps/e2e/app/native-stack-adapter-recipe/index.tsx b/apps/e2e/app/native-stack-adapter-recipe/index.tsx index bf7f9d43..6778eab9 100644 --- a/apps/e2e/app/native-stack-adapter-recipe/index.tsx +++ b/apps/e2e/app/native-stack-adapter-recipe/index.tsx @@ -1,24 +1,161 @@ import { router } from "expo-router"; -import { Button, StyleSheet, View } from "react-native"; +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; import { useTheme } from "@/theme"; +const EXAMPLES = [ + { + eyebrow: "BOUNDARY ZOOM", + title: "Profile → avatar", + description: "Native header first, then a shared-element style zoom.", + accent: "#5B5FEF", + route: "/native-stack-adapter-recipe/profile", + testID: "native-stack-adapter-open-profile", + }, + { + eyebrow: "NESTED FLOW", + title: "Book a trip", + description: + "A complete booking flow with three nested stacks, native screens, custom motion, forms, and preserved state.", + accent: "#F05A47", + route: "/native-stack-adapter-recipe/booking", + testID: "native-stack-adapter-open-booking", + }, +] as const; + export default function NativeStackAdapterRecipeIndex() { const theme = useTheme(); return ( - - - - {open ? ( -
-
- {publicVersions.map((version) => { - const active = version.id === value; - - return ( - { - setOpen(false); - onSelect?.(); - }} - className={`flex items-center justify-between rounded-xl px-3 py-2 text-sm font-medium transition-colors duration-150 ${ - active - ? "bg-neutral-100 text-neutral-950 dark:bg-white/10 dark:text-white" - : "text-neutral-600 hover:bg-neutral-100 hover:text-neutral-950 dark:text-white/70 dark:hover:bg-white/6 dark:hover:text-white" - }`} - > - {version.label} - - ); - })} -
-
- ) : null} - - ); -} diff --git a/packages/docs/src/components/ui/note.tsx b/packages/docs/src/components/ui/note.tsx index 4b9f3f07..8e331bb7 100644 --- a/packages/docs/src/components/ui/note.tsx +++ b/packages/docs/src/components/ui/note.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from "react"; export function Note({ children }: { children: ReactNode }) { return (
-

{children}

+
{children}
); } diff --git a/packages/docs/src/components/ui/property-table.tsx b/packages/docs/src/components/ui/property-table.tsx new file mode 100644 index 00000000..79e9337e --- /dev/null +++ b/packages/docs/src/components/ui/property-table.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; + +export function PropertyTable({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/docs/src/content/docs/adapters.mdx b/packages/docs/src/content/docs/adapters.mdx deleted file mode 100644 index a897b377..00000000 --- a/packages/docs/src/content/docs/adapters.mdx +++ /dev/null @@ -1,85 +0,0 @@ ---- -title: "Adapters" -pageTitle: "Adapters" -summary: "Wrap existing navigators with the screen transition runtime." -description: "Wrap existing navigators with the screen transition runtime." -eyebrow: "Components" -group: "Components" -order: 3 -to: /adapters ---- - -Adapters let an existing navigator use the screen transition runtime without moving to one of this package's bundled navigator creators. - -## withScreenTransitions - -Use `withScreenTransitions()` when your app primarily uses React Navigation's native stack and you want specific screens to support custom screen transitions without giving up the native-stack behavior your app already depends on. - -Wrap the native-stack navigator with the adapter to make its screens transition aware. For longer multi-screen custom-motion flows, prefer [Blank Stack](/stack-types) instead. - -```tsx -import { createNativeStackNavigator } from "@react-navigation/native-stack"; -import Transition, { - withScreenTransitions, -} from "react-native-screen-transitions"; - -const NativeStack = createNativeStackNavigator(); -const Stack = withScreenTransitions(NativeStack); -``` - - - Don't forget to set `enableTransitions` to `true` when you want this screen to use custom transitions. - - -```tsx {5} - { - "worklet"; - - return { - content: { - style: { - // ... - }, - }, - }; - }, - }} -/>; -``` - -## Expo Router - -For Expo Router, wrap the adapted native stack with `withLayoutContext()`: - - - This v3 adapter supports Expo Router through Expo SDK 55. Expo Router forked its navigation internals in SDK 56, so SDK 56+ apps must use the separate [v4 alpha Expo Router integration](/v4-experimental/getting-started). - - -```tsx -import { createNativeStackNavigator } from "@react-navigation/native-stack"; -import { withLayoutContext } from "expo-router"; -import { withScreenTransitions } from "react-native-screen-transitions"; - -const NativeStack = createNativeStackNavigator(); -const Stack = withScreenTransitions(NativeStack); - -export const ScreenTransitionsStack = withLayoutContext(Stack.Navigator); -``` - -Blank stack is still the default recommendation for custom motion. Use the native-stack adapter when the app already depends on native-stack behavior and only needs selected screens or flows to participate in custom transitions. - -## Related Reading - -- [Stack Types](/stack-types) -- [Expo Router](/expo-router) -- [New in 3.7](/changelog/updating-to-3-7) diff --git a/packages/docs/src/content/docs/api.mdx b/packages/docs/src/content/docs/api.mdx index 87d4fa54..87196d32 100644 --- a/packages/docs/src/content/docs/api.mdx +++ b/packages/docs/src/content/docs/api.mdx @@ -15,5 +15,8 @@ The API reference is split by export: - [useScreenAnimation](/api/use-screen-animation) - [useScreenGesture](/api/use-screen-gesture) - [useHistory](/api/use-history) +- [useScreenState](/api/use-screen-state) - [createTransitionAwareComponent](/api/create-transition-aware-component) - [createBoundaryComponent](/api/create-boundary-component) +- [snapTo](/api/snap-to) +- [Transition Blocking](/api/transition-blocking) diff --git a/packages/docs/src/content/docs/api/create-boundary-component.mdx b/packages/docs/src/content/docs/api/create-boundary-component.mdx index 23a907c3..ae56f47f 100644 --- a/packages/docs/src/content/docs/api/create-boundary-component.mdx +++ b/packages/docs/src/content/docs/api/create-boundary-component.mdx @@ -5,7 +5,7 @@ summary: "Wrap custom components with the boundary system." description: "Wrap custom components with the boundary system." eyebrow: "API" group: "API" -order: 5 +order: 6 to: /api/create-boundary-component --- diff --git a/packages/docs/src/content/docs/api/create-transition-aware-component.mdx b/packages/docs/src/content/docs/api/create-transition-aware-component.mdx index ebd823a1..e3030d9a 100644 --- a/packages/docs/src/content/docs/api/create-transition-aware-component.mdx +++ b/packages/docs/src/content/docs/api/create-transition-aware-component.mdx @@ -5,11 +5,11 @@ summary: "Wrap custom components so they can receive styleId styles and scroll m description: "Wrap custom components so they can receive styleId styles and scroll metadata handling." eyebrow: "API" group: "API" -order: 4 +order: 5 to: /api/create-transition-aware-component --- -`createTransitionAwareComponent()` wraps a component so it can receive `styleId` styles and participate in transition measurement. +`createTransitionAwareComponent()` wraps a component so it can receive styles from a matching `styleId` slot. ```tsx import Transition from "react-native-screen-transitions"; @@ -23,9 +23,13 @@ const TransitionBlurView = Transition.createTransitionAwareComponent(BlurView, { Options: + + | Option | Meaning | | --- | --- | | `alreadyAnimated` | Set `true` when the wrapped component is already animated | | `isScrollable` | Set `true` for custom scrollables that should coordinate gestures and publish `layouts.scroll` metadata | + + When `isScrollable` is `true`, the wrapper composes the scroll handler used by the gesture system. Keep passing your own `onScroll`, `onLayout`, and `onContentSizeChange`; the wrapper composes them with its internal handlers. diff --git a/packages/docs/src/content/docs/api/snap-to.mdx b/packages/docs/src/content/docs/api/snap-to.mdx new file mode 100644 index 00000000..5b9500d6 --- /dev/null +++ b/packages/docs/src/content/docs/api/snap-to.mdx @@ -0,0 +1,22 @@ +--- +title: "snapTo" +pageTitle: "snapTo" +summary: "Move the active snap screen to a configured snap point." +description: "Programmatically move the active snap screen to a configured snap-point index." +eyebrow: "API" +group: "API" +order: 7 +to: /api/snap-to +--- + +`snapTo(index)` searches the focused screen's navigator lineage for the most recent screen with `snapPoints`, then falls back to the most recent matching screen in global history. It moves that screen to the requested zero-based index. + +```tsx +import { snapTo } from "react-native-screen-transitions"; + +snapTo(0); +``` + +Snap points are sorted in ascending order before the index is resolved. The call does nothing and logs a warning when no screen with snap points is available or the index is outside the configured range. + +See [Snap Points](/snap-points) for screen configuration and live snap state. diff --git a/packages/docs/src/content/docs/api/transition-blocking.mdx b/packages/docs/src/content/docs/api/transition-blocking.mdx new file mode 100644 index 00000000..ed1f9e53 --- /dev/null +++ b/packages/docs/src/content/docs/api/transition-blocking.mdx @@ -0,0 +1,33 @@ +--- +title: "Transition Blocking" +pageTitle: "Transition Blocking" +summary: "Hold a pending lifecycle transition until its destination is ready." +description: "Use blockTransition and unblockTransition to control when a pending transition starts." +eyebrow: "API" +group: "API" +order: 8 +to: /api/transition-blocking +--- + +`blockTransition()` holds a screen's pending lifecycle transition at its initial frame. `unblockTransition()` releases one block. + +```tsx +import { + blockTransition, + unblockTransition, +} from "react-native-screen-transitions"; + +async function prepareForTransition(routeKey: string) { + blockTransition(routeKey); + + try { + await prepareDestination(); + } finally { + unblockTransition(routeKey); + } +} +``` + +Calls are reference-counted per route. Pair every block with one unblock for the same route. When `routeKey` is omitted, both functions resolve the most recently focused screen in navigation history. + +Blocking changes when the animation starts. It does not reduce JavaScript work or move rendering to another thread. diff --git a/packages/docs/src/content/docs/api/use-history.mdx b/packages/docs/src/content/docs/api/use-history.mdx index 8edeb831..f02892e6 100644 --- a/packages/docs/src/content/docs/api/use-history.mdx +++ b/packages/docs/src/content/docs/api/use-history.mdx @@ -21,6 +21,8 @@ const recent = history.getRecent(3); It returns: + + | Field | Meaning | | --- | --- | | `history` | Read-only map of screen keys to history entries | @@ -29,3 +31,5 @@ It returns: | `getPath(fromKey, toKey)` | Screen keys between two history entries | | `get(key)` | One history entry by screen key | | `getMostRecent()` | The latest history entry | + + diff --git a/packages/docs/src/content/docs/api/use-screen-animation.mdx b/packages/docs/src/content/docs/api/use-screen-animation.mdx index 213d6473..38ae4e3f 100644 --- a/packages/docs/src/content/docs/api/use-screen-animation.mdx +++ b/packages/docs/src/content/docs/api/use-screen-animation.mdx @@ -37,5 +37,3 @@ const childAnimation = useScreenAnimation({ depth: 1 }); `depth: 0` resolves the current transition. Negative values resolve ancestors. Positive values resolve descendants. Non-current targets can return `null`, so check `animation.value` before reading it. - -Legacy string targets such as `"self"`, `"parent"`, and `"root"` still work, but new code should use `{ depth }`. diff --git a/packages/docs/src/content/docs/api/use-screen-state.mdx b/packages/docs/src/content/docs/api/use-screen-state.mdx new file mode 100644 index 00000000..4def7ca1 --- /dev/null +++ b/packages/docs/src/content/docs/api/use-screen-state.mdx @@ -0,0 +1,42 @@ +--- +title: "useScreenState" +pageTitle: "useScreenState" +summary: "Read the current Blank Stack screen and focused route state." +description: "Read route, option, navigation, and snap state from the current Blank Stack." +eyebrow: "API" +group: "API" +order: 4 +to: /api/use-screen-state +--- + +`useScreenState()` returns screen and focused-route state from the current Blank Stack. + +```tsx +import { useScreenState } from "react-native-screen-transitions"; + +const { + index, + routes, + focusedRoute, + focusedIndex, + options, + meta, + navigation, + snapTo, +} = useScreenState(); +``` + + + +| Field | Meaning | +| --- | --- | +| `index` | Index of the current screen in the stack | +| `routes` | Routes in the current stack | +| `focusedRoute` | Focused route in the stack | +| `focusedIndex` | Index of the focused route | +| `options` | Resolved options for the focused screen | +| `meta` | Metadata from the focused screen options | +| `navigation` | Navigation object for the current screen | +| `snapTo(index)` | Snap the focused screen to a configured snap-point index | + + diff --git a/packages/docs/src/content/docs/boundary-components.mdx b/packages/docs/src/content/docs/boundary-components.mdx new file mode 100644 index 00000000..6c2c0b3c --- /dev/null +++ b/packages/docs/src/content/docs/boundary-components.mdx @@ -0,0 +1,91 @@ +--- +title: "Boundary Components" +pageTitle: "Boundary Components" +summary: "Register, measure, and place elements used by bounds-driven transitions." +description: "Use Transition.Boundary, Transition.Boundary.Target, and Transition.Boundary.Host." +eyebrow: "Components" +group: "Components" +order: 2 +to: /boundary-components +--- + +`Transition.Boundary` is the compound API for bounds-driven transitions. + +- `Transition.Boundary` +- `Transition.Boundary.Target` +- `Transition.Boundary.Host` + +## Transition.Boundary + +Use this for both pressable and passive boundary registration. + +When `onPress` is present, it renders as a pressable boundary. When `onPress` is omitted, it renders as a passive view boundary. + +```tsx +// Passive boundary + + + + +// Pressable boundary + navigation.navigate("Detail")} +> + + +``` + +This is the default recommendation for new code. + +## Transition.Boundary.Target + +Use this when the measured element is nested inside the owner. + +```tsx + + + + + + + {item.title} + + +``` + +It does not take its own `id`. It inherits the surrounding boundary owner and changes which descendant gets measured. + +## Transition.Boundary.Host + +Use this to make handoff or clipping-escape placement explicit inside a scrollable or otherwise constrained coordinate space. + +```tsx + + + + + + + +``` + +Most flows do not need an explicit host. Add one when `escapeClipping` or `handoff` content should be placed inside a specific local coordinate space. + +## Boundary Config Props + +Boundary components share the same configuration props: + +- `id` +- `group` +- `anchor` +- `scaleMode` +- `target` +- `method` +- `enabled` +- `handoff` +- `escapeClipping` + +They keep the wrapped component props as well. The root `Transition.Boundary` accepts `onPress` for pressable usage. + +Related reading: [Bounds Transitions](/shared-elements). diff --git a/packages/docs/src/content/docs/caveats.mdx b/packages/docs/src/content/docs/caveats.mdx index 5d6630ec..761a189d 100644 --- a/packages/docs/src/content/docs/caveats.mdx +++ b/packages/docs/src/content/docs/caveats.mdx @@ -1,19 +1,19 @@ --- title: "Caveats & Trade-offs" pageTitle: "Caveats & Trade-offs" -summary: "What you gain, what you give up, and which rough edges still matter in v3." -description: "What you gain, what you give up, and which rough edges still matter in v3." +summary: "Runtime trade-offs and integration limits to test before shipping." +description: "Understand Blank Stack, native-stack adapter, bounds handoff, and platform trade-offs." eyebrow: "Guides" group: "Guides" order: 1 to: /caveats --- -## Native Stack Trade-offs +## Native Stack with `withScreenTransitions` -Native stack transition support is a compatibility layer. +`withScreenTransitions` is a compatibility layer for `@react-navigation/native-stack`. -Under the hood, transitioned native-stack screens are switched into `containedTransparentModal` with native animation disabled, and the transition visuals are driven from the library on top of that. +For screens that enable transitions, the adapter switches native stack to `containedTransparentModal`, disables the native animation, and lets Screen Transitions render the animation over that container. That works, but it comes with real trade-offs: @@ -21,7 +21,7 @@ That works, but it comes with real trade-offs: - dismissal can feel worse than blank stack because native removal and JS state synchronization are coordinated separately - platform-specific edge cases are more likely here than on blank stack -Use native stack when you need native-stack integration. Do not choose it because you expect it to be the best place for advanced custom transitions. +Use `withScreenTransitions` when an existing native-stack flow needs a custom transition. Use Blank Stack when the transition system should own the full presentation model. ## Blank Stack Trade-offs @@ -47,24 +47,6 @@ That does not mean every benchmark will show blank stack as faster. More callbac Choose blank stack because you want the richer transition model, gesture control, and motion flexibility. Do not choose it because you expect every timing benchmark to come out lower. -### Reanimated synchronous UI-prop updates on iOS - -Reanimated's experimental `IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS` static feature flag can make Blank Stack navigation transitions noticeably slower on iOS. In a reported reproduction, enabling the flag added roughly 300 ms to each navigation transition. - -If Blank Stack navigation is unexpectedly slow, look for and remove this Reanimated configuration, then rebuild the native iOS app: - -```json -{ - "reanimated": { - "staticFeatureFlags": { - "IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS": true - } - } -} -``` - -Static Reanimated flags are resolved at build time, so changing this configuration requires a native rebuild. This is an experimental Reanimated configuration interaction, not currently treated as a Blank Stack bug. Android has not shown the same behavior in the reported reproduction. - ## Deep Linking Deep linking is supported. @@ -92,44 +74,3 @@ If you do want that behavior, there is an experimental escape hatch: ``` `experimental_animateOnInitialMount` is experimental and may change. - -## Masking Dependency - -`navigationMaskEnabled` still depends on `@react-native-masked-view/masked-view`. - -Without that dependency, the app still runs, but navigation masking does not activate. - -## Live Boundary Handoff - -`Transition.Boundary` handoff is experimental in v3.9. - -The feature moves one live payload between matching boundary hosts. It's useful for video players, maps, and other stateful native content, but it also makes platform view lifecycle and compositor behavior observable. - -In particular, Android video surfaces have a trade-off: - -- `SurfaceView` is efficient, but it may render outside normal React Native clipping, transforms, border radii, or portal stacking. -- `TextureView` participates in the regular view hierarchy and works with the native reparenting fast path in `react-native-teleport@1.2.0`. - -For Expo Video content that must clip or animate with a boundary, prefer: - -```tsx - -``` - -Test live handoff on physical devices for every platform you support. Ordinary bounds transitions and `escapeClipping` do not reparent a live payload and are not covered by this experimental warning. - -## Compatibility - -The current peer-version floor is: - -- `@react-navigation/native >= 6.0.0` -- `@react-navigation/native-stack >= 7.0.0` if you use native stack -- `react-native-reanimated >= 3.16.0` or `4.x` -- `react-native-gesture-handler >= 2.16.1` -- `react-native-screens >= 4.4.0` - -If your environment is older than that, debug compatibility before you debug transitions. diff --git a/packages/docs/src/content/docs/components.mdx b/packages/docs/src/content/docs/components.mdx deleted file mode 100644 index 77d732ed..00000000 --- a/packages/docs/src/content/docs/components.mdx +++ /dev/null @@ -1,33 +0,0 @@ ---- -title: "Components" -pageTitle: "Render the docs primitives in one place and tune them in context." -summary: "A live gallery for the shell, prose, code, and navigation pieces." -description: "This page is the working surface for the docs UI. The shell and page header are already live around it, and the sections below render the reusable pieces directly so they can be tuned without hopping between docs." -eyebrow: "Components" -group: "Components" -hidden: true -order: 99 -to: /components ---- - -
- -
diff --git a/packages/docs/src/content/docs/custom-animations.mdx b/packages/docs/src/content/docs/custom-animations.mdx index 0f0686f1..3009924b 100644 --- a/packages/docs/src/content/docs/custom-animations.mdx +++ b/packages/docs/src/content/docs/custom-animations.mdx @@ -1,8 +1,8 @@ --- title: "Custom Animations" pageTitle: "Custom Animations" -summary: "Write screen interpolators against the v3 transition model: per-screen state, derived helpers, runtime options, and slot output." -description: "Write screen interpolators against the v3 transition model: per-screen state, derived helpers, runtime options, and slot output." +summary: "Write screen interpolators with per-screen state, derived helpers, runtime options, and slot output." +description: "Write screen interpolators with per-screen state, derived helpers, runtime options, and slot output." eyebrow: "Core Concepts" group: "Core Concepts" order: 5 @@ -15,7 +15,7 @@ Custom animations are created inside `screenStyleInterpolator`, where each scree ```tsx import { interpolate } from "react-native-reanimated"; -import { createBlankStackNavigator } from "react-native-screen-transitions/blank-stack"; +import { createBlankStackNavigator } from "react-native-screen-transitions/react-navigation"; const Stack = createBlankStackNavigator(); @@ -69,6 +69,8 @@ In practice: ## Interpolator Props + + | Prop | Meaning | | --- | --- | | `current` | The screen whose `options.screenStyleInterpolator` is being evaluated | @@ -80,11 +82,41 @@ In practice: | `stackProgress` | Accumulated progress from this screen upward through the stack | | `focused` | `true` when there is no `next` screen above `current` | | `bounds` | Bounds accessor for shared-element and navigation zoom helpers | +| `transition` | Worklet-safe accessor for the current, ancestor, or descendant transition scope | | `insets` | Safe-area insets | | `layouts` | Layout measurements for the current screen | + + +## Related Transition Scopes + +Use `transition()` when an interpolator needs animation state from another screen transition scope: + +```tsx +screenStyleInterpolator: ({ transition }) => { + "worklet"; + + const parent = transition({ depth: -1 }); + const parentProgress = parent?.progress ?? 1; + + return { + content: { + style: { + opacity: interpolate(parentProgress, [0, 1], [0.8, 1], "clamp"), + }, + }, + }; +}; +``` + +Calling `transition()` without a target, or with `depth: 0`, returns the current scope. Negative depths resolve ancestors and positive depths resolve descendants. The accessor returns `null` when the requested scope is unavailable. + +Returned scopes expose the same interpolation state, including `current`, `previous`, `next`, `active`, `inactive`, `progress`, `stackProgress`, `bounds`, and another relative `transition()` accessor. + Each screen state object contains the same fields: + + | Field | Meaning | | --- | --- | | `progress` | This screen's own progress value | @@ -93,7 +125,7 @@ Each screen state object contains the same fields: | `entering` | `1` while this screen is opening | | `willAnimate` | One-frame pre-animation handoff signal | | `animating` | `1` while animation or gesture motion is active | -| `settled` | `1` when the screen is idle and not dismissing | +| `settled` | `1` when the screen is visually close enough to its animation target to be treated as settled | | `gesture` | Live gesture values for this screen | | `meta` | The value from `options.meta` | | `options` | Runtime transition options after base screen options and interpolator overrides are resolved | @@ -104,16 +136,18 @@ Each screen state object contains the same fields: | `animatedSnapIndex` | Live snap index during gestures and animations, including fractional values between detents | | `snapIndex` | Current target snap index, updated by mount, `snapTo()`, and gesture release | + + Use `current.animatedSnapIndex` when the visual state should follow the user's finger. Use `current.snapIndex` when the visual state should follow the selected target detent. -`logicallySettled` still exists as a deprecated alias for `settled`. New code should read `settled`. - ## Gesture Values Each screen state's `gesture` object includes live pan, pinch, rotation, and handoff values: + + | Field | Meaning | | --- | --- | | `x`, `y` | Live pan translation after `gestureSensitivity` | @@ -123,11 +157,11 @@ Each screen state's `gesture` object includes live pan, pinch, rotation, and han | `pinchOriginX`, `pinchOriginY` | Screen-coordinate focal point captured when the pinch activates | | `rotation` | Two-finger rotation in radians | | `raw` | Physical pan, pinch, and rotation values before `gestureSensitivity` | -| `handoff` | Release-time gesture snapshot for dismiss handoff animations | -| `active` | The active gesture: a resolved pan direction, `pinch-in`, `pinch-out`, or `null` | +| `initiator` | Gesture that activated the current attempt, or `"none"` | +| `handoff` | Release-time gesture snapshot for dismiss handoff animations; `handoff.active` identifies the released gesture | | `dragging`, `dismissing` | Gesture state flags | -Prefer `gesture.active` for new code. The older pan-only `gesture.direction` field still exists for compatibility. + `focalX` and `focalY` can move with the fingers. Use `pinchOriginX` and `pinchOriginY` when a transform should stay anchored to the point where the pinch began. @@ -195,16 +229,10 @@ Supported runtime option keys are: - `gestureSnapVelocityImpact` - `gestureReleaseVelocityScale` - `gestureSnapLocked` +- `sheetSnapBehavior` - `sheetScrollGestureBehavior` - `backdropBehavior` -Deprecated compatibility keys are still accepted: - -- `gestureProgressMode` -- `gestureDrivesProgress` -- `gestureActivationArea` -- `gestureResponseDistance` - ## Returning Styles Return built-in layer slots or any custom key that matches a `styleId` on a transition-aware component: @@ -217,10 +245,6 @@ return { backdrop: { style: { opacity: 0.4 }, }, - surface: { - style: { borderRadius: 24 }, - props: { pointerEvents: "none" }, - }, "hero-title": { style: { transform: [{ translateY: -8 }] }, }, @@ -231,7 +255,6 @@ Reserved layer slots are: - `content` - `backdrop` -- `surface` Any other key is treated as a `styleId` target. Each slot accepts either shorthand style output or the explicit `{ style, props }` form. diff --git a/packages/docs/src/content/docs/expo-router.mdx b/packages/docs/src/content/docs/expo-router.mdx index 3a0ea2ad..36684ea3 100644 --- a/packages/docs/src/content/docs/expo-router.mdx +++ b/packages/docs/src/content/docs/expo-router.mdx @@ -1,107 +1,37 @@ --- -title: "Expo Router" +title: "Expo Router (Alpha)" pageTitle: "Expo Router" -summary: "Create a v3 blank stack layout for Expo Router with createBlankStackNavigator and withLayoutContext." -description: "Create a v3 blank stack layout for Expo Router with createBlankStackNavigator and withLayoutContext." -eyebrow: "Guides" -group: "Guides" +summary: "Use the Alpha Blank Stack integration in an Expo Router layout." +description: "Set up the Alpha Expo Router integration with the Blank Stack Standard Navigator." +eyebrow: "Integrations · Alpha" +group: "Integrations" +availability: "Alpha" order: 2 to: /expo-router --- -## Overview - -The stable v3 Expo Router integration supports Expo SDK 55 and earlier. It wraps `createBlankStackNavigator()` with Expo Router's `withLayoutContext()`, using the React Navigation-backed navigator internals available in those SDKs. - - Expo SDK 55 is the final SDK supported by this v3 integration. Expo Router forked the navigation packages it builds upon in SDK 56. For Expo SDK 56 or later, use the separate [v4 alpha Expo Router integration](/v4-experimental/getting-started). + The Expo Router integration is Alpha. It uses Expo Router's Standard Navigator integration and currently requires Expo Router 56.2.10 or later. -The SDK 56 boundary applies to Expo Router, not to Expo projects generally. An Expo app using React Navigation directly can continue to use v3. See Expo's [SDK 55 to 56 migration guide](https://docs.expo.dev/router/migrate/sdk-55-to-56/) for details about the upstream import and runtime changes. - -If you want a complete reference app before integrating this into an existing project, use the [v3 Expo Router starter](https://github.com/eds2002/react-native-screen-transitions/tree/main/starters/expo-router). It pins Expo SDK 55, the final Expo Router environment supported by the v3 integration and the version used by the v3 e2e app. - -## Blank Stack Layout - -This is the v3 integration pattern used by the e2e app and starter: - -```tsx -// layouts/blank-stack.tsx -import "react-native-reanimated"; -import type { - ParamListBase, - StackNavigationState, -} from "@react-navigation/native"; -import { withLayoutContext } from "expo-router"; -import type { ComponentProps } from "react"; -import { - type BlankStackNavigationEventMap, - type BlankStackNavigationOptions, - createBlankStackNavigator, -} from "react-native-screen-transitions/blank-stack"; - -const { Navigator } = createBlankStackNavigator(); - -function BlankStackNavigator(props: ComponentProps) { - return ; -} - -export const BlankStack = withLayoutContext< - BlankStackNavigationOptions, - typeof BlankStackNavigator, - StackNavigationState, - BlankStackNavigationEventMap ->(BlankStackNavigator); -``` - -The local wrapper provides a stable TypeScript boundary for `withLayoutContext()` while forwarding navigator props unchanged. Native screens remain enabled by default. +## Add Blank Stack to a layout -## Using It In Route Layouts +Import `BlankStack` from the Expo Router entry point: ```tsx -// app/gestures/_layout.tsx +// app/_layout.tsx import Transition from "react-native-screen-transitions"; -import { BlankStack } from "@/layouts/blank-stack"; +import { BlankStack } from "react-native-screen-transitions/expo-router"; -export default function GesturesLayout() { +export default function RootLayout() { return ( ); } ``` - -`router.push()`, `router.replace()`, and `router.back()` still work normally. The transition behavior comes from the options you put on `BlankStack.Screen`. - -## Bounds And Router Navigation - -```tsx -import { router } from "expo-router"; -import Transition from "react-native-screen-transitions"; - - router.push("/detail")} -> - - -``` - -The destination can use a matching passive `Transition.Boundary`, `Transition.Boundary.Target`, `navigationMaskEnabled`, and `screenStyleInterpolator` the same way it would outside Expo Router. - -## Embedded Flows - -```tsx - - - -``` - -Use `independent` only when you want an isolated navigation tree, not for normal route layouts. diff --git a/packages/docs/src/content/docs/gesture-ownership.mdx b/packages/docs/src/content/docs/gesture-ownership.mdx index ba12a1a4..ba7708da 100644 --- a/packages/docs/src/content/docs/gesture-ownership.mdx +++ b/packages/docs/src/content/docs/gesture-ownership.mdx @@ -134,7 +134,7 @@ If no screen in the current ownership chain claims that direction, the scroll vi This is the cleanest setup: the parent owns one direction, the child screen owns another. ```tsx -import { createBlankStackNavigator } from "react-native-screen-transitions/blank-stack"; +import { createBlankStackNavigator } from "react-native-screen-transitions/react-navigation"; const RootStack = createBlankStackNavigator(); const DetailsStack = createBlankStackNavigator(); diff --git a/packages/docs/src/content/docs/gestures.mdx b/packages/docs/src/content/docs/gestures.mdx index 067ceabb..a4e3e559 100644 --- a/packages/docs/src/content/docs/gestures.mdx +++ b/packages/docs/src/content/docs/gestures.mdx @@ -1,8 +1,8 @@ --- title: "Gestures" pageTitle: "Gestures" -summary: "Configure dismiss gestures, snap-sheet handoff, backdrops, and gesture hooks from real screen options." -description: "Configure dismiss gestures, snap-sheet handoff, backdrops, and gesture hooks from real screen options." +summary: "Configure dismiss gestures, snap sheets, and gesture hooks from screen options." +description: "Configure dismiss gestures, snap sheets, and gesture hooks from screen options." eyebrow: "Core Concepts" group: "Core Concepts" order: 7 @@ -120,48 +120,6 @@ Controls whether the screen tracks live gesture values. -### `gestureActivationArea` - -Deprecated. Use `gestureDirection` entries with per-direction `area` instead. - - - - - - - - - - - - - - - - -
typedefaultrequired
GestureActivationArea"screen"No
- -### `gestureResponseDistance` - -Deprecated. Use a numeric `area` on `gestureDirection` entries instead. - - - - - - - - - - - - - - - - -
typedefaultrequired
numberundefinedNo
- ### `gestureSensitivity` Multiplies live gesture movement before it drives progress and non-raw gesture values. @@ -204,27 +162,6 @@ Release velocity influence on dismiss decisions. -### `gestureProgressMode` - -Deprecated. Gesture movement now always contributes to `progress`. Use `transitionProgress` when an interpolator needs transition progress without live gesture movement. - - - - - - - - - - - - - - - - -
typedefaultrequired
"progress-driven" | "freeform""progress-driven"No
- ### `gestureSnapVelocityImpact` Release velocity influence on snap-target selection. @@ -267,51 +204,12 @@ Locks gesture-driven snap movement to the current detent. -### `gestureReleaseVelocityScale` - -Multiplies normalized release velocity before the spring runs. - - - - - - - - - - - - - - - - -
typedefaultrequired
number1No
- -### `sheetScrollGestureBehavior` - -Scroll-boundary handoff mode for snap sheets. - - - - - - - - - - - - - - - - -
typedefaultrequired
"expand-and-collapse" | "collapse-only""expand-and-collapse"No
- -### `backdropBehavior` +### `sheetSnapBehavior` -Controls how taps on the backdrop are handled. +Controls how a drag maps movement between snap points. `"continuous"` maps +movement directly to global progress and can cross multiple snap points. +`"step"` gives the adjacent interval a full normalized drag range and limits +each gesture to one snap-point step. @@ -323,16 +221,16 @@ Controls how taps on the backdrop are handled. - - + +
"block" | "passthrough" | "dismiss" | "collapse"stack default"continuous" | "step""continuous" No
-### `backdropComponent` +### `gestureReleaseVelocityScale` -Custom backdrop component type. Pass a stable component such as `BlurView`, or pass a function component that renders `{ styles, props, pointerEvents }`. +Multiplies normalized release velocity before the spring runs. @@ -344,16 +242,16 @@ Custom backdrop component type. Pass a stable component such as `BlurView`, or p - - + +
React.ComponentType<any> | ScreenBackdropComponentundefinednumber1 No
-### `contentComponent` +### `sheetScrollGestureBehavior` -Custom renderer for the screen content layer. Use it when a screen needs a custom shell that receives the `content` slot's animated styles, animated props, pointer events, and children. +Scroll-boundary handoff mode for snap sheets. @@ -365,33 +263,14 @@ Custom renderer for the screen content layer. Use it when a screen needs a custo - - + +
React.ComponentType<any> | ScreenContentComponentundefined"expand-and-collapse" | "collapse-only""expand-and-collapse" No
-### `surfaceComponent` - -Deprecated custom surface wrapper. It still works for compatibility, but new custom shells should use `contentComponent`. - - - - - - - - - - - - - - - - -
typedefaultrequired
React.ComponentType<any>undefinedNo
+See [Snap Points](/snap-points#scroll-handoff) for transition-aware scrollables and the complete handoff behavior. ## Per-Direction Activation Area @@ -417,23 +296,6 @@ options={{ `"edge"` uses the default edge hit area. A number uses that many points from the edge. - - `gestureActivationArea` still works for compatibility, but new code should use `gestureDirection` entries with `area`. - - -## Gesture Response Distance - -```tsx -options={{ - gestureEnabled: true, - gestureDirection: { gesture: "horizontal", area: 24 }, -}} -``` - -`gestureResponseDistance` is deprecated. Use a numeric `area` on the pan direction that needs a custom edge-start distance. - -Despite the name, the old option controls how far from the edge a gesture may start when a direction uses `"edge"` activation. It does not change how far the pointer must move before pan activates; that movement threshold is internal. - ## Release Tuning These options control how release velocity affects the outcome and the spring feel: @@ -521,8 +383,6 @@ screenStyleInterpolator: ({ current }) => { }; ``` -`gestureProgressMode` and `gestureDrivesProgress` remain as deprecated compatibility options. New code should choose between `progress` and `transitionProgress` inside the interpolator instead of configuring a screen-level progress mode. - ## Dynamic Gesture Options React-side option changes can be applied while the screen stays mounted: @@ -591,50 +451,11 @@ Two behaviors matter here: - `gestureEnabled: false` disables dismiss at the minimum detent, but the screen can still snap between non-dismiss detents - `gestureSnapLocked` locks gesture-driven snap movement to the current detent, while programmatic changes such as `navigation.setOptions()` or `snapTo()` can still change behavior +- `sheetSnapBehavior: "step"` makes each gesture operate only between its starting detent and one adjacent detent -If you want to toggle snap locking at runtime: - -```tsx -const navigation = useNavigation(); - -navigation.setOptions({ - gestureSnapLocked: true, -}); -``` - -## Scroll Handoff for Snap Sheets - -When a snap sheet contains scroll content, use the transition-aware scrollables: - -```tsx -function SheetScreen() { - return ( - - - - ); -} -``` - -`sheetScrollGestureBehavior` controls what happens when that scroll view reaches its boundary: - -### expand-and-collapse - -At the sheet boundary: - -- dragging toward expand can expand the sheet -- dragging toward collapse can collapse or dismiss the sheet - -This is the Apple Maps-style behavior. - -### collapse-only - -At the sheet boundary: - -- dragging toward collapse can collapse or dismiss the sheet -- dragging from the scrollable content does not expand the sheet - -Expansion must start from dead space outside the scrollable content. This is the Instagram-style behavior. +Use step behavior when nearby snap points should still require a deliberate +drag. For example, the `0.9` to `1` interval below receives the same normalized +drag range as any other adjacent interval: ```tsx ``` -## Scroll Metadata - -Transition-aware scrollables publish scroll state through `current.layouts.scroll`: - -```tsx -screenStyleInterpolator: ({ current }) => { - "worklet"; - - const y = current.layouts.scroll?.vertical?.offset ?? 0; - - return { - content: { - style: { - transform: [{ translateY: -Math.min(y, 24) }], - }, - }, - }; -}; -``` - -For nested same-axis scrollables, the outermost scrollable owns that axis. Cross-axis nested scrollables can each publish their own axis. - -## Backdrop Interaction - -The supported values are: - -- `"block"` -- `"passthrough"` -- `"dismiss"` -- `"collapse"` - -Use `backdropBehavior: "dismiss"` when tapping the backdrop should close the screen, or `backdropBehavior: "collapse"` when a snap sheet should step down to the next lower detent first. - -For a custom backdrop renderer, pass a component type: +If you want to toggle snap locking at runtime: ```tsx -import { BlurView } from "expo-blur"; - - { - "worklet"; +const navigation = useNavigation(); - return { - backdrop: { - style: { - opacity: interpolate(progress, [0, 1, 2], [0, 0.2, 0]), - }, - props: { - intensity: interpolate(progress, [0, 1, 2], [0, 80, 0]), - }, - }, - }; - }, - }} -/> +navigation.setOptions({ + gestureSnapLocked: true, +}); ``` -The library wraps the backdrop component with `Animated.createAnimatedComponent` internally and drives it from the `backdrop` slot. - ## Reading Gesture State Gesture values are exposed inside the interpolator on `current.gesture`: @@ -744,12 +511,11 @@ Useful values include: - `rotation` - `velocity` - `raw` +- `initiator` - `handoff` - `dragging` - `dismissing` - `settling` -- `active` -- `direction` (deprecated pan-only alias) Live gesture values reset after release. If a dismiss animation needs the last release-time values while the live fields reset, read `current.gesture.handoff`. @@ -774,9 +540,10 @@ const parentGestureRef = useScreenGesture({ depth: -1 }); const customPan = Gesture.Pan().requireExternalGestureToFail(parentGestureRef); ``` -`useScreenGesture()` supports `depth: 0` for the current screen and negative depths for ancestors. `useScreenAnimation()` also supports positive depths for descendant transition scopes. Ancestor targeting stops at isolation boundaries. In an `independent` nested stack, negative depths can resolve to `null` when the lookup reaches that isolated tree edge. +`useScreenGesture()` supports `depth: 0` for the current screen and negative depths for ancestors. `useScreenAnimation()` also supports positive depths for descendant transition scopes. Ancestor targeting stops at navigation-host isolation boundaries, where negative depths resolve to `null`. ## Related Reading - [Gesture Ownership](/gesture-ownership) - [Snap Points](/snap-points) +- [Layers](/layers) diff --git a/packages/docs/src/content/docs/inactive-behavior.mdx b/packages/docs/src/content/docs/inactive-behavior.mdx index e7d41181..ab6b82f1 100644 --- a/packages/docs/src/content/docs/inactive-behavior.mdx +++ b/packages/docs/src/content/docs/inactive-behavior.mdx @@ -1,119 +1,50 @@ --- title: "Inactive Behavior" pageTitle: "Inactive Behavior" -summary: "Control whether inactive blank-stack screens hide, pause, unmount, or keep running." -description: "Control whether inactive blank-stack screens hide, pause, unmount, or keep running." +summary: "Choose how Blank Stack retains screens after another route becomes active." +description: "Control whether inactive Blank Stack screens pause, hide, unmount, or keep running." eyebrow: "Core Concepts" group: "Core Concepts" -order: 11 +order: 12 to: /inactive-behavior --- -## What It Controls - -`inactiveBehavior` controls what blank stack does with a route after it is no longer active. - -It is a lifecycle option. It decides whether an inactive screen stays mounted, stays visible, freezes, unmounts, or keeps running. +`inactiveBehavior` controls what Blank Stack does with a route after it becomes inactive. ```tsx -const Stack = createBlankStackNavigator(); - ``` -The default is: + + Screen Transitions v4 uses React Activity for inactive screens. Version 3 used react-native-screens. + -- native: `"hide"` -- web: `"unmount"` +The default is `"hide"` on native and `"unmount"` on web. ## `hide` -`hide` is the native default. It keeps the React subtree mounted, waits until the route above it has safely painted, then hides the inactive screen from presentation and freezes inactive rendering where native screens support it. +`hide` pauses the inactive subtree and removes its presentation. Component state stays mounted, while effects are cleaned up and updates are deprioritized. -With `react-native-screens`, blank stack sets `activityState={0}`, `shouldFreeze={true}`, and `freezeOnBlur={true}` for this mode after safe paint. - -`activityState={0}` lets the parent screen container detach the screen from the native view hierarchy. `shouldFreeze` tells `react-native-screens` that the screen should be frozen with `react-freeze`. - -Use `hide` when back navigation should stay warm, but inactive views do not need to remain visible or keep rendering. It can reduce inactive React render pressure and native presentation work. It does not release JS memory, because the component tree is still mounted. - - - `hide` uses `display: "none"`. Gesture reattachment after a hidden screen - becomes active again has been verified with React Native Gesture Handler - 2.31.1. If a restored screen is visible but its gestures do not respond, - update Gesture Handler to that known-good version. - +Use `hide` for normal screens that should return warm without spending work while covered. ## `pause` -`pause` keeps the inactive screen mounted, attached, and visible, but freezes inactive rendering where native screens support it after safe paint. - -With `react-native-screens`, blank stack keeps the screen at `activityState={1}` and sets `shouldFreeze={true}` plus `freezeOnBlur={true}`. The last paint stays visible while React rendering below that screen is suspended. +`pause` pauses the inactive subtree but preserves its last painted frame. The screen remains visible behind transparent routes, overlays, and transitions that expose older content. -Use `pause` when the previous screen must remain visible behind the current one, such as transparent presentations, overlays, or transitions that intentionally expose older content. - -This is still a retention mode. It does not release JS memory or clean up local component state. +Use `pause` when hidden React work should stop but the previous frame must remain on screen. ## `unmount` -`unmount` removes the inactive screen's React subtree after safe paint, when the route has no nested navigation state. - -Use it for memory-sensitive screens where preserving local state is less important than releasing JS objects and native resources. +`unmount` removes the inactive subtree. Local state is lost, effects clean up, and returning to the route mounts the screen again. -The trade-off is that returning to the screen is cold. Local component state is lost, effects clean up, and the screen must mount again when it becomes active. - -If the route owns nested navigator state, blank stack can retain it instead of dropping that nested navigation tree. +Use it for screens where releasing state and native resources matters more than warm back navigation. ## `keep` -`keep` keeps the inactive screen mounted, attached, visible, non-interactive, and running. - -Use it only when inactive content must keep updating while it sits behind the active route. - -This has the highest retention cost. React state, JS objects, effects, native views, layout, and accessibility structure can all remain present. - -## Safe Paint - -Blank stack avoids hiding or removing an inactive screen immediately when another screen appears above it. - -For a stack shaped like `A` inactive, `B` inert, and `C` active, `A` may still be needed visually until `C` finishes painting its transition. The library waits for the paint driver to settle before applying `hide`, `pause`, or `unmount`. - -That delay prevents blank frames during transitions. - -## Performance Shape - -`hide` and `pause` are the performance-oriented retention modes. They keep the React subtree mounted, so they do not meaningfully reduce JS memory, but they can reduce inactive rendering work through `react-freeze`. - -`hide` can also reduce native presentation work because the inactive screen is removed from the visible native presentation after safe paint. - -`unmount` is the cleanup mode. It is the option that directly releases JS component state and objects held by the inactive subtree. - -`keep` is the escape hatch for live inactive content and should not be chosen for performance. - -## Future Plans - -Longer term, React 19.2's [``](https://react.dev/blog/2025/10/01/react-19-2#activity) is the direction these names are meant to align with. Recent writing from Satyajit Sahoo ([@satya164](https://x.com/satya164)) around Activity prompted experiments in this package, and local testing showed similar retention and memory behavior with an Activity-based approach. - -Later versions can use that path to move `hide`, `pause`, `unmount`, and `keep` toward better parity across platforms. - -## Choosing A Mode - -Start with the default `"hide"` on native. - -Choose another mode when the screen has a specific need: - -- choose `"hide"` when inactive screens should stay warm but hidden and frozen -- choose `"pause"` when preserved paint matters and inactive rendering should pause -- choose `"unmount"` when JS memory, native resources, or cleanup matter more than warm back navigation -- choose `"keep"` when inactive screens must stay live and visible - -## Related Reading +`keep` leaves the inactive subtree mounted, visible, and running. Pointer events are disabled while the route is inactive, but React effects and updates continue. -- [Stack Types](/stack-types) -- [Independent Stacks](/independent-stacks) -- [Caveats & Trade-offs](/caveats) +Use it only when covered content must stay live. diff --git a/packages/docs/src/content/docs/independent-stacks.mdx b/packages/docs/src/content/docs/independent-stacks.mdx deleted file mode 100644 index 75c17663..00000000 --- a/packages/docs/src/content/docs/independent-stacks.mdx +++ /dev/null @@ -1,149 +0,0 @@ ---- -title: "Independent Stacks" -pageTitle: "Independent Stacks" -summary: "Use `independent` to isolate a blank stack, and `enableNativeScreens` to choose between native screen primitives and regular views." -description: "Use `independent` to isolate a blank stack, and `enableNativeScreens` to choose between native screen primitives and regular views." -eyebrow: "Core Concepts" -group: "Core Concepts" -order: 12 -to: /independent-stacks ---- - -## Why This Exists - -In v3, blank stack covers the old embedded-flow use case that originally pushed people toward component stack. - -The two options that matter are: - -- `independent` -- `enableNativeScreens` - -They solve different problems. - -## `independent` - -`independent` creates an isolated navigation tree for the blank stack. - -Use it when the stack lives inside another screen, sheet, host app surface, or any embedded flow that should not join the parent React Navigation tree. - -```tsx -import { createBlankStackNavigator } from "react-native-screen-transitions/blank-stack"; - -const Stack = createBlankStackNavigator(); - - - - - -``` - -When `independent` is enabled, the navigator wraps itself in its own `NavigationIndependentTree` and `NavigationContainer`. - -Leave this off for normal app-level stacks. - -## `enableNativeScreens` - -`enableNativeScreens` controls whether blank stack renders with native screen primitives from `react-native-screens` or with regular views. - -```tsx -const Stack = createBlankStackNavigator(); - - - - -``` - -With the default `true` value, blank stack keeps `react-native-screens` behavior such as native activity state and freezing. - -With `false`, blank stack renders with regular views instead of native screen primitives. - -This is often the better fit for embedded flows where plain view layering behaves more naturally than native screen containers. - -## How They Work Together - -These options are independent from each other: - -- `independent` decides whether the stack joins the parent navigation tree -- `enableNativeScreens` decides whether the stack uses native screen primitives - -That means you can combine them: - -```tsx -const Stack = createBlankStackNavigator(); - - - - - -``` - -This is the common embedded-flow setup: - -- isolated navigation state -- regular view rendering - -## When To Use Each Setup - -### Default Blank Stack - -Use the defaults when the stack is part of your main app navigation: - -```tsx - - - -``` - -### Independent Embedded Flow - -Use `independent` when the flow must manage its own navigation state: - -```tsx - - - -``` - -### Embedded Flow With Regular Views - -Use `independent` together with `enableNativeScreens={false}` when the flow is embedded and you want regular view layering instead of native screen primitives: - -```tsx - - - -``` - -This is usually the most predictable setup for nested flows, custom containers, and surfaces where native screen layering gets in the way. - -## Static API - -The same options are available in the static factory form: - -```tsx -import { createBlankStackNavigator } from "react-native-screen-transitions/blank-stack"; - -const Stack = createBlankStackNavigator({ - independent: true, - enableNativeScreens: false, - screens: { - Home: HomeScreen, - Detail: DetailScreen, - }, -}); -``` - -## Practical Guidance - -- choose blank stack first for new work -- add `independent` when the flow should not join the parent navigation tree -- set `enableNativeScreens={false}` when embedded view behavior is a better fit than native screen primitives -- keep `enableNativeScreens` on when you want the normal `react-native-screens` behavior inside blank stack - -If you are deciding between this and component stack, choose blank stack. Component stack is now the legacy path. - -## Related Reading - -- [Stack Types](/stack-types) -- [Caveats & Trade-offs](/caveats) -- [Expo Router](/expo-router) diff --git a/packages/docs/src/content/docs/installation.mdx b/packages/docs/src/content/docs/installation.mdx index ccaaae46..56d5ec27 100644 --- a/packages/docs/src/content/docs/installation.mdx +++ b/packages/docs/src/content/docs/installation.mdx @@ -1,128 +1,50 @@ --- title: "Installation" pageTitle: "Installation" -summary: "Install the package, its peer dependencies, and the optional masking dependency when needed." -description: "Install the package, its peer dependencies, and the optional masking dependency when needed." +summary: "Install Screen Transitions and its peer dependencies." +description: "Install Screen Transitions with its required React Native, animation, gesture, and navigation peers." eyebrow: "Get Started" group: "Get Started" order: 2 to: /installation --- -## Choose Your Navigation Setup +## Requirements -This page installs the stable v3 release. +The current release requires: -| Navigation setup | v3 status | Next step | -| --- | --- | --- | -| React Navigation 6 or 7 | Supported | Follow this page, then [Quick Start](/quick-start). | -| Expo Router on Expo SDK 55 or earlier | Supported | Follow this page, then [Expo Router](/expo-router). | -| Expo Router on Expo SDK 56 or later | Not supported by v3 | Use the [v4 alpha installation](/v4-experimental/installation). | +- React 19.2 or later +- Reanimated 4 +- Worklets 0.8 or later +- React Navigation 7.3 or later +- Gesture Handler 2.16.1 or later - - Expo SDK 55 is the final SDK supported by the v3 Expo Router integration. Expo Router changed navigation internals in SDK 56; do not combine an SDK 56+ Expo Router app with the v3 setup below. - +Expo Router support currently requires Expo Router 56.2.10 or later and is marked Alpha. See [Expo Router](/expo-router) before using it in production. -## 1. Install The Package +## Install the package -## 2. Install The Peer Dependencies +## Install peer dependencies -The package expects these peer dependencies: - - - -If you use Expo, prefer `expo install`: +For Expo projects: ```bash -npx expo install react-native-reanimated react-native-gesture-handler \ - @react-navigation/native @react-navigation/native-stack \ - @react-navigation/elements react-native-screens \ - react-native-safe-area-context +npx expo install @react-navigation/native react-native-gesture-handler \ + react-native-reanimated react-native-safe-area-context react-native-worklets ``` -The important version floors are: - -- `@react-navigation/native >= 6.0.0` -- `@react-navigation/native-stack >= 7.0.0` -- `react-native-reanimated >= 3.16.0 or 4.x` -- `react-native-gesture-handler >= 2.16.1` -- `react-native-screens >= 4.4.0` - -For project-level setup such as the Reanimated import, Babel plugin ordering, Gesture Handler wiring, and any native React Navigation requirements, follow the official installation guides for the underlying packages in your app. - -## 3. Optional Masking Dependency - -Install `@react-native-masked-view/masked-view` if you use: - -- `navigationMaskEnabled` -- library-managed navigation zoom masking -- the legacy `Transition.MaskedView` +For bare React Native projects: -## 4. Native Project Step +Follow the Reanimated and Gesture Handler setup instructions for your React Native version. Bare iOS projects must also install pods after changing native dependencies. Expo Router apps already install Expo Router as part of their app setup. -For bare React Native projects, install iOS pods after changing dependencies: +Continue with [React Navigation](/react-navigation) or the Alpha [Expo Router integration](/expo-router). -```bash -cd ios && pod install -``` +## Optional packages -## 5. Verify The Setup - -If this renders and navigates without Reanimated or Gesture Handler errors, the base setup is correct: - -```tsx -import Transition from "react-native-screen-transitions"; -import { createBlankStackNavigator } from "react-native-screen-transitions/blank-stack"; - -const Stack = createBlankStackNavigator(); - -export function RootNavigator() { - return ( - - - - - ); -} -``` +Install `@react-native-masked-view/masked-view` when using navigation masks or library-managed zoom masking: -Then continue to [Quick Start](/quick-start). - -## Start From A Complete App - -If you are creating a new project, these starters keep the two supported v3 navigation setups separate: +```bash +npm install @react-native-masked-view/masked-view +``` -- [v3 Expo Router starter](https://github.com/eds2002/react-native-screen-transitions/tree/main/starters/expo-router) — an Expo SDK 55 app exercising the final Router environment supported by the v3 `withLayoutContext()` integration. -- [v3 React Navigation starter](https://github.com/eds2002/react-native-screen-transitions/tree/main/starters/react-navigation) — upstream React Navigation with the v3 blank stack. +Boundary teleportation uses `react-native-teleport`: -Both starters show the same detail transition, snap-point sheet, and paired-boundary remote-media zoom, so the only meaningful difference is the navigation host. +```bash +npm install react-native-teleport@1.2.0 +``` diff --git a/packages/docs/src/content/docs/surface-slots.mdx b/packages/docs/src/content/docs/layers.mdx similarity index 78% rename from packages/docs/src/content/docs/surface-slots.mdx rename to packages/docs/src/content/docs/layers.mdx index 46ba5dd0..065d649e 100644 --- a/packages/docs/src/content/docs/surface-slots.mdx +++ b/packages/docs/src/content/docs/layers.mdx @@ -1,12 +1,12 @@ --- title: "Layers" pageTitle: "Layers" -summary: "Understand reserved transition layers, including overlays, screen content, backdrops, surfaces, and navigation masks." -description: "Understand reserved transition layers, including overlays, screen content, backdrops, surfaces, and navigation masks." +summary: "Understand reserved transition layers, including overlays, screen content, surfaces, backdrops, and navigation masks." +description: "Understand reserved transition layers, including overlays, screen content, surfaces, backdrops, and navigation masks." eyebrow: "Core Concepts" group: "Core Concepts" order: 9 -to: /surface-slots +to: /layers --- ## Mental Model @@ -50,6 +50,7 @@ return { overlay: { transform: [{ translateY: 12 }] }, backdrop: { opacity: 0.4 }, content: { transform: [{ translateY: 12 }] }, + surface: { filter: [{ blur: 18 }] }, "hero-card": { scale: 0.98 }, }; ``` @@ -61,8 +62,9 @@ Most layers render inside `screen-container` in this order: 1. `backdrop` 2. `content` 3. optional navigation mask wrapper when `navigationMaskEnabled` is enabled -4. deprecated optional surface wrapper when `surfaceComponent` is provided +4. `surface` 5. screen children and any matching `styleId` targets +6. shared-element fallback content outside the surface `overlay` is resolved separately by the floating overlay host above the navigator's screen containers. It drives the adjacent pair in the overlay stack rather than the normal screen stack. @@ -90,6 +92,8 @@ overlay: { Overlay adjacency is sparse. Given `A[] -> B -> C[]`, the `overlay` slot returned by `C` animates `A` and `C` as the adjacent pair. A plain screen may also return this slot to animate the latest overlay without owning one itself. +All retained overlays remain rendered in route order. Use the slot's `style` and `props`, including `pointerEvents`, to control their presentation and interaction. The library does not infer an inactive overlay state. + `overlay` does not inherit into nested transition scopes and must not be reused as a custom `styleId`. See [Overlays](/overlays) for the complete ownership and lifecycle contract. ## backdrop @@ -104,6 +108,31 @@ backdrop: { } ``` +### Backdrop interaction + +`backdropBehavior` controls how the backdrop layer handles touches: + + + +| Value | Behavior | +| --- | --- | +| `"block"` | Catch touches without changing navigation | +| `"passthrough"` | Pass touches through to the content behind the screen | +| `"dismiss"` | Dismiss the screen when the backdrop is tapped | +| `"collapse"` | Move to the next lower snap point, then dismiss when already at the minimum | + + + +The default is `"block"`. + +```tsx +options={{ + backdropBehavior: "dismiss", +}} +``` + +### Custom backdrop components + If you provide `backdropComponent`, pass either a component type or a render-style function component. ```tsx @@ -134,7 +163,7 @@ The render props are: - `props`: animated props from the `backdrop` slot - `pointerEvents`: the backdrop pointer-event state -Use backdrop for dimming, blur intensity, tint, or any visual treatment outside the screen itself. `backdropBehavior` still controls how taps are handled. +Use backdrop for dimming, blur intensity, tint, or any visual treatment outside the screen itself. The backdrop renderer and its tap behavior are configured independently. ## content @@ -177,26 +206,36 @@ Use `contentComponent` for custom tray shells, rounded containers, glass panels, ## surface -`surface` targets the optional wrapper created by `surfaceComponent`. +`surface` targets the visual shell nested inside the content layer. It wraps the screen subtree, but not the shared-element fallback host. -`surfaceComponent` is deprecated. It still works for compatibility, but new custom shells should use `contentComponent` instead. +The surface layer is created when `surfaceComponent` is configured; otherwise no additional wrapper is mounted. ```tsx -options={{ - surfaceComponent: SquircleView, -}} - surface: { style: { - borderRadius: interpolate(progress, [0, 1], [28, 0]), - }, - props: { - cornerRadius: interpolate(progress, [0, 1], [28, 0]), + borderRadius: 32, + filter: [{ blur: interpolate(progress, [0, 1], [18, 0.01]) }], }, } ``` -Use `surface` only for older code that still relies on `surfaceComponent`. For new code, put the custom shell in `contentComponent` and drive it with the `content` slot. +Use `surfaceComponent` when that inner visual shell needs a custom renderer: + +```tsx +options={{ + surfaceComponent: ({ styles, props, pointerEvents, children }) => ( + + {children} + + ), +}} +``` + +Use `content` for screen motion and navigation geometry. Use `surface` for visual treatments such as filters, clipping, backgrounds, and corner treatments that should not wrap shared-element fallback content. ## Element Targets @@ -283,8 +322,6 @@ return { }, content: { style: { borderRadius: 24 }, - }, - surface: { props: { cornerRadius: 24 }, }, }; diff --git a/packages/docs/src/content/docs/migrating-from-v3.mdx b/packages/docs/src/content/docs/migrating-from-v3.mdx new file mode 100644 index 00000000..0d95e175 --- /dev/null +++ b/packages/docs/src/content/docs/migrating-from-v3.mdx @@ -0,0 +1,137 @@ +--- +title: "Upgrading from v3" +pageTitle: "Upgrading from v3" +summary: "Update dependencies, navigator imports, and removed compatibility aliases." +description: "Move a Version 3 app to the current Screen Transitions architecture without rewriting its transitions." +eyebrow: "Guides" +group: "Guides" +order: 1 +to: /migrating-from-v3 +--- + +Most transition code does not need to change. Presets, screen interpolators, gestures, overlays, snap points, and bounds keep the same model. Migration work is concentrated in dependencies, navigator imports, and APIs that were already deprecated in Version 3. + +## Before migrating + +Version 3 remains available for apps that need React before 19.2, Reanimated 3, React Navigation 6, or an older Expo Router setup. Its peer floors are: + +- React: no package-level minimum +- React Navigation: 6.0 or later +- Native Stack: 7.0 or later +- Reanimated: 3.16 or later, including 4.x +- Gesture Handler: 2.16.1 or later +- React Native Screens: 4.4 or later + +## Update the runtime + +The current release requires React 19.2, Reanimated 4, Worklets 0.8, and React Navigation 7.3 or later. Expo Router requires 56.2.10 or later and remains an Alpha integration. + +## Replace navigator imports + +For React Navigation, replace the Version 3 Blank Stack entry point: + +```tsx +// Version 3 +import { createBlankStackNavigator } from "react-native-screen-transitions/blank-stack"; + +// Current +import { createBlankStackNavigator } from "react-native-screen-transitions/react-navigation"; +``` + +Expo Router imports `BlankStack` from its own host entry point: + +```tsx +import { BlankStack } from "react-native-screen-transitions/expo-router"; +``` + +Version 3 has no Expo Router entry point. It wraps the Blank Stack navigator with Expo Router's `withLayoutContext`: + +```tsx +import type { + ParamListBase, + StackNavigationState, +} from "@react-navigation/native"; +import { withLayoutContext } from "expo-router"; +import type { ComponentProps } from "react"; +import { + type BlankStackNavigationEventMap, + type BlankStackNavigationOptions, + createBlankStackNavigator, +} from "react-native-screen-transitions/blank-stack"; + +const { Navigator } = createBlankStackNavigator(); + +function BlankStackNavigator(props: ComponentProps) { + return ; +} + +export const BlankStack = withLayoutContext< + BlankStackNavigationOptions, + typeof BlankStackNavigator, + StackNavigationState, + BlankStackNavigationEventMap +>(BlankStackNavigator); +``` + +The bundled component stack and bundled native-stack creator have been removed. Replace component stack with Blank Stack. Replace the bundled native stack with `@react-navigation/native-stack` plus `withScreenTransitions`. + +## Remove Version 3 navigator props + +The current Blank Stack is a Standard Navigator and no longer exposes the Version 3 `nativeScreens` or `enableNativeScreens` presentation switches. Remove those props. Blank Stack now participates directly in the React Navigation tree that renders it. + +### Breaking: `independent` was removed + +Blank Stack no longer accepts the `independent` navigator prop or exports `BlankStackFactoryOptions`. By the final Version 3 releases, `independent` no longer created the isolated container its name promised. Version 4 removes the prop instead of preserving configuration with no effect. + +For normal nested navigation, remove `independent` without replacing it. If the flow intentionally needs a separate navigation tree, wrap its own `NavigationContainer` in React Navigation's `NavigationIndependentTree`. React Navigation documents this as an advanced setup: the nested tree is disconnected from its parent, and navigation cannot cross between them. See [Independent navigation containers](https://reactnavigation.org/docs/navigation-container/#independent-navigation-containers). + +## Remove deprecated transition aliases + +Version 4 removes compatibility fields that Version 3 kept temporarily. Common replacements include: + +- `snapVelocityImpact` → `gestureSnapVelocityImpact` +- `expandViaScrollView` → `sheetScrollGestureBehavior` +- `gestureResponseDistance` and `gestureActivationArea` → the `area` field on each `gestureDirection` entry +- `gestureDrivesProgress` and `gestureProgressMode` → read `transitionProgress` when interpolation progress must exclude live gesture movement +- gesture `active` → `initiator` +- gesture `direction` → `initiator` for the gesture that activated the transition +- `normalizedX`, `normalizedY`, `isDragging`, and `isDismissing` → `normX`, `normY`, `dragging`, and `dismissing` + +`useScreenAnimation` now uses depth targets. Replace `"self"`, `"parent"`, `"root"`, and `{ ancestor }` with the matching `{ depth }` target. + +Version 4 distinguishes `contentComponent` from `surfaceComponent`. Use `contentComponent` for the outer screen-motion and navigation-geometry layer. Use `surfaceComponent` for the nested visual shell when filters, clipping, backgrounds, or corner treatments should not wrap shared-element fallback content. + +Version 4 also removes ignored bounds and zoom compatibility options, including `bounds().math()`, `raw`, `gestures`, legacy zoom opacity ranges, and old drag sensitivity fields. Use `bounds(id).values()`, `offset`, and the current `drag` configuration instead. + +## Replace removed shared presets + +Version 4 removes `Transition.Presets.SharedIGImage()`, `Transition.Presets.SharedAppleMusic()`, and `Transition.Presets.SharedXImage()`. These presets combined product-specific gesture, masking, backdrop, and bounds decisions into fixed recipes that were difficult to extend. + +For an opinionated source-to-destination transition, use the supported zoom recipe: + +```tsx +screenStyleInterpolator: ({ bounds }) => { + "worklet"; + + return bounds(id).navigation.zoom(); +}; +``` + +When the transition needs its own gesture response, layer choreography, or visual treatment, build it in `screenStyleInterpolator` with `bounds(id).styles()` or `bounds(id).values()`. See [Bounds Transitions](/shared-elements) for the lower-level geometry helpers and [Zoom](/navigation-zoom) for the built-in recipe. + +## Check inactive screen behavior + +The four `inactiveBehavior` values remain `hide`, `pause`, `unmount`, and `keep`. Their implementation changed from React Native Screens and React Freeze to React 19.2 Activity. + +Test screens with subscriptions, text inputs, scroll state, nested navigation, and expensive effects. Activity pauses effects and deprioritizes hidden work, while Screen Transitions keeps paint visible when a transition still needs the inactive route. + +## Verify the app + +Run these cases on iOS and Android: + +- rapid pushes and programmatic pops +- gesture cancellation and repeated dismissals +- deep bounds stacks +- floating overlays during overlapping transitions +- all four inactive behavior modes +- nested stacks and deep links used by the app diff --git a/packages/docs/src/content/docs/migrating-to-3-4.mdx b/packages/docs/src/content/docs/migrating-to-3-4.mdx index 1cde0ec5..378df7cd 100644 --- a/packages/docs/src/content/docs/migrating-to-3-4.mdx +++ b/packages/docs/src/content/docs/migrating-to-3-4.mdx @@ -5,6 +5,7 @@ summary: "Move older setups onto the 3.4-era layered transition surface." description: "Move older setups onto the 3.4-era layered transition surface." eyebrow: "Changelogs" group: "Changelogs" +version: "v3" hidden: true changelogDate: "April 2026" order: 5 @@ -145,7 +146,7 @@ options={{ If you previously used component stack for embedded or independent flows, move that work to blank stack: ```tsx -import { createBlankStackNavigator } from "react-native-screen-transitions/blank-stack"; +import { createBlankStackNavigator } from "react-native-screen-transitions/react-navigation"; const Stack = createBlankStackNavigator(); @@ -156,7 +157,6 @@ const Stack = createBlankStackNavigator(); Blank stack now covers that use case directly and is the preferred path going forward. -For the v3 blank-stack model, including when to use `independent` and `enableNativeScreens={false}`, see [Independent Stacks](/independent-stacks). ## Hooks Now Target Ancestors Only diff --git a/packages/docs/src/content/docs/native-stack-adapter.mdx b/packages/docs/src/content/docs/native-stack-adapter.mdx new file mode 100644 index 00000000..ac98925a --- /dev/null +++ b/packages/docs/src/content/docs/native-stack-adapter.mdx @@ -0,0 +1,40 @@ +--- +title: "Native Stack Adapter" +pageTitle: "Native Stack Adapter" +summary: "Add Screen Transitions to an existing native-stack navigator." +description: "Use withScreenTransitions with the native stack supplied by React Navigation or Expo Router." +eyebrow: "Adapters" +group: "Adapters" +order: 1 +to: /native-stack-adapter +--- + +`withScreenTransitions` adapts the native-stack navigator supplied by the host integration. + +## Adapt the navigator + +Pass the native-stack navigator to `withScreenTransitions`: + +```tsx +import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import Transition, { withScreenTransitions } from "react-native-screen-transitions"; + +const NativeStack = createNativeStackNavigator(); +const Stack = withScreenTransitions(NativeStack); +``` + +Enable Screen Transitions on each participating screen: + +```tsx + +``` + +Use this adapter when a flow must keep native-stack headers, options, or navigator behavior. For new flows, [Blank Stack](/react-navigation) gives Screen Transitions full control over the transition lifecycle. diff --git a/packages/docs/src/content/docs/navigation-zoom.mdx b/packages/docs/src/content/docs/navigation-zoom.mdx index e12458b3..8565b500 100644 --- a/packages/docs/src/content/docs/navigation-zoom.mdx +++ b/packages/docs/src/content/docs/navigation-zoom.mdx @@ -139,6 +139,8 @@ return bounds(id).navigation.zoom({ }); ``` + + | Option | Type | Notes | | --- | --- | --- | | `target` | `"bound" \| "fullscreen" \| MeasuredDimensions` | Uses the paired destination boundary, the full screen, or an explicit rectangle. When omitted, zoom expands to a full-width rectangle that preserves the source aspect ratio. | @@ -152,22 +154,9 @@ return bounds(id).navigation.zoom({ | `drag.scale.horizontal` | `number` | Horizontal drag-scale response. Defaults to `1`. | | `drag.scale.vertical` | `number` | Vertical drag-scale response. Defaults to `1`. | -The nested `drag` values adjust intensity without replacing zoom's built-in curves. `0` disables that response on the axis, `1` keeps the default response, and values above `1` exaggerate it. - -The following earlier beta options still compile for migration safety, but zoom ignores them: + -- `debug` -- `focusedElementOpacity` -- `unfocusedElementOpacity` -- `maxSensitivity` -- `velocityDepth` -- `gestureProgressMode` -- `horizontalDragScale` -- `verticalDragScale` -- `horizontalDragTranslation` -- `verticalDragTranslation` - -Move axis response tuning to `drag`. Zoom now owns opacity timing, gesture sensitivity, and velocity-depth behavior as part of its native preset. +The nested `drag` values adjust intensity without replacing zoom's built-in curves. `0` disables that response on the axis, `1` keeps the default response, and values above `1` exaggerate it. ## Interactive Dismissal @@ -235,5 +224,5 @@ In that setup, some shared state usually tracks which id is currently active ins - [Bounds Transitions](/shared-elements) - [Custom Animations](/custom-animations) -- [Layers](/surface-slots) +- [Layers](/layers) - [Gestures](/gestures) diff --git a/packages/docs/src/content/docs/overlays.mdx b/packages/docs/src/content/docs/overlays.mdx index 1f50f53e..e9f38218 100644 --- a/packages/docs/src/content/docs/overlays.mdx +++ b/packages/docs/src/content/docs/overlays.mdx @@ -3,9 +3,9 @@ title: "Overlays" pageTitle: "Overlays" summary: "Use screen overlays as persistent, stack-relative UI with their own sparse transition order." description: "Use screen overlays as persistent, stack-relative UI with their own sparse transition order." -eyebrow: "Components" -group: "Components" -order: 1 +eyebrow: "Core Concepts" +group: "Core Concepts" +order: 11 to: /overlays --- @@ -44,7 +44,21 @@ A[] -> C[] -> E[] Pushing `B` leaves `A` floating in place. Pushing `C` mounts `C` above `A`, and pushing `E` mounts `E` above `C`. Screens without overlays do not create gaps in the overlay transition order. -An overlay mounts once and keeps its component state until its owner leaves the navigation stack or `overlayShown` becomes `false`. The top overlay accepts touches. The overlay directly below it can remain visible for the transition, but it is inert; older overlays stay mounted and inactive. +An overlay mounts once and keeps its component state until its owner leaves the navigation stack or `overlayShown` becomes `false`. Every retained overlay remains rendered in route order. + +The library owns overlay discovery, ordering, transition drivers, stack context, and deterministic layer order. Your overlay interpolator owns visibility, pointer events, modal behavior, coexistence, and transition styling. Hosts default to `pointerEvents="box-none"`, so the host itself passes touches through while interactive children continue to work. + +Use interpolated props when an overlay should stop receiving touches: + +```tsx +overlay: { + props: { + pointerEvents: "none", + }, +} +``` + +Use `overlayShown: false` when the overlay should not be rendered at all. ## Basic Example @@ -64,7 +78,7 @@ An overlay mounts once and keeps its component state until its owner leaves the ``` -Here, `OverlayA` remains above `B`. When `C` is pushed, `OverlayC` becomes active above `OverlayA`. +Here, `OverlayA` remains above `B`. When `C` is pushed, `OverlayC` is ordered above `OverlayA`; the interpolator decides how the two overlays coexist visually and interactively. ## Animate the Overlay Stack @@ -122,6 +136,8 @@ Do not use `overlay` as a custom `styleId`. It is a non-inheriting layer slot, l An overlay can outlive the moment when its owner was focused, so its props separate stable owner information from changing navigator information. + + | Prop | Purpose | | --- | --- | | `route` | Route that owns the overlay | @@ -134,6 +150,8 @@ An overlay can outlive the moment when its owner was focused, so its props separ | `navigation` | Navigation object for the overlay owner's navigator | | `progress` | Stack progress relative to the overlay owner | + + An undecided interactive dismissal keeps the current route focused. Once the dismissal commits, `focusedRoute`, `focusedIndex`, `meta`, and `options` switch to the screen underneath while the closing screen finishes animating. A cancelled gesture leaves them unchanged. ## Animation and Style IDs Inside an Overlay diff --git a/packages/docs/src/content/docs/overview.mdx b/packages/docs/src/content/docs/overview.mdx index b813af30..100ec0f1 100644 --- a/packages/docs/src/content/docs/overview.mdx +++ b/packages/docs/src/content/docs/overview.mdx @@ -1,8 +1,8 @@ --- title: "Introduction" pageTitle: "Introduction" -summary: "Build custom screen transitions, snap sheets, overlays, and bounds-driven navigation motion with the v3 API." -description: "Build custom screen transitions, snap sheets, overlays, and bounds-driven navigation motion with the v3 API." +summary: "Build custom screen transitions, snap sheets, overlays, and bounds-driven navigation motion." +description: "Build custom screen transitions, snap sheets, overlays, and bounds-driven navigation motion." eyebrow: "Get Started" group: "Get Started" order: 1 @@ -12,7 +12,7 @@ imageAlt: "A still from the example app used as a motion reference." imageCaption: "A frame from the example app, used as lightweight motion context." --- -`react-native-screen-transitions` is a React Navigation transition toolkit for apps that need more control than the platform defaults. +`react-native-screen-transitions` is a transition toolkit for apps that need more control than the platform defaults. Use it when you want to build: @@ -43,27 +43,11 @@ React Native can support smooth, 60 fps transition work across Android and iOS. Screen Transitions is a customizable package for building transitions that used to feel out of reach in React Native. -## Navigation Compatibility - -These are the supported navigation paths for the stable v3 release: - -| Navigation setup | v3 status | Integration | -| --- | --- | --- | -| React Navigation 6 or 7 | Supported | Use `react-native-screen-transitions/blank-stack` or the native-stack adapter. | -| Expo Router on Expo SDK 55 or earlier | Supported | Wrap the v3 blank stack with `withLayoutContext()`. | -| Expo Router on Expo SDK 56 or later | Not supported by v3 | Use the [v4 alpha Expo Router integration](/v4-experimental/getting-started). | - - - Expo SDK 55 is the last Expo Router release that uses the React Navigation-backed integration documented for v3. Expo Router forked the navigation packages it builds upon in SDK 56. See Expo's [SDK 55 to 56 migration guide](https://docs.expo.dev/router/migrate/sdk-55-to-56/) for the upstream change. - - -Using React Navigation directly is still supported, including inside an Expo app. The SDK 56 boundary applies specifically to Expo Router. - ## Choose A Stack ### Blank Stack -`createBlankStackNavigator()` is the default recommendation. +Blank Stack is the default recommendation. It uses the same transition runtime with React Navigation and Expo Router through host-specific entry points. It is the best fit for: @@ -71,7 +55,7 @@ It is the best fit for: - snap sheets - overlays - bounds transitions -- embedded and independent flows +- embedded flows ### Native Stack diff --git a/packages/docs/src/content/docs/presets.mdx b/packages/docs/src/content/docs/presets.mdx index aa468eff..bcece89e 100644 --- a/packages/docs/src/content/docs/presets.mdx +++ b/packages/docs/src/content/docs/presets.mdx @@ -2,7 +2,7 @@ title: "Presets" pageTitle: "Presets are the fastest way to land consistent motion." summary: "Built-in transitions for the common cases." -description: "This page is kept as an internal reference while the public v3 nav mirrors the current external docs site." +description: "Use built-in transition presets as screen options." eyebrow: "Core Concepts" group: "Core Concepts" hidden: true diff --git a/packages/docs/src/content/docs/quick-start.mdx b/packages/docs/src/content/docs/quick-start.mdx index 28c2c408..963daf04 100644 --- a/packages/docs/src/content/docs/quick-start.mdx +++ b/packages/docs/src/content/docs/quick-start.mdx @@ -12,11 +12,11 @@ to: /quick-start @@ -149,7 +148,7 @@ export function RootNavigator() {
  • Gestures for dismissal and drag behavior
  • Snap Points for sheets and detents
  • Bounds Transitions for the bounds system
  • -
  • Expo Router if your app uses file-based routing
  • +
  • React Navigation for navigator setup
  • diff --git a/packages/docs/src/content/docs/react-navigation.mdx b/packages/docs/src/content/docs/react-navigation.mdx new file mode 100644 index 00000000..c1611bf4 --- /dev/null +++ b/packages/docs/src/content/docs/react-navigation.mdx @@ -0,0 +1,43 @@ +--- +title: "React Navigation" +pageTitle: "React Navigation" +summary: "Use Blank Stack with React Navigation." +description: "Set up the stable React Navigation integration with Blank Stack." +eyebrow: "Integrations" +group: "Integrations" +order: 1 +to: /react-navigation +--- + +React Navigation is the stable navigation host for Screen Transitions. + +Complete the host setup in React Navigation's [Getting Started guide](https://reactnavigation.org/docs/getting-started/) before adding Blank Stack. + +## Create a Blank Stack + +Import the navigator factory from the React Navigation entry point: + +```tsx +import { NavigationContainer } from "@react-navigation/native"; +import Transition from "react-native-screen-transitions"; +import { createBlankStackNavigator } from "react-native-screen-transitions/react-navigation"; + +const Stack = createBlankStackNavigator(); + +export function App() { + return ( + + + + + + + ); +} +``` + +Continue to [Quick Start](/quick-start) for the transition APIs shared by every navigation host. diff --git a/packages/docs/src/content/docs/recipes/modal.mdx b/packages/docs/src/content/docs/recipes/modal.mdx index 69a006b3..d46540e3 100644 --- a/packages/docs/src/content/docs/recipes/modal.mdx +++ b/packages/docs/src/content/docs/recipes/modal.mdx @@ -17,28 +17,28 @@ This recipe builds a nested modal flow that can turn drag-to-dismiss on and off Use this pattern when a modal represents a flow the user should intentionally finish or exit, such as onboarding, account setup, checkout, or any multi-step setup. The modal can still acknowledge a pull gesture, but the resisted motion communicates that the flow is not casually dismissible. -## Route Shape +## Navigator Shape ```text -app/ - example/ - _layout.tsx - index.tsx - modal/ - _layout.tsx - index.tsx - b.tsx +AppStack + Home + Modal + ModalStack + ModalHome + ModalDisabled ``` The parent stack owns the modal presentation. The nested `modal` stack owns the screens inside that modal. Each nested screen can update the parent modal's `gestureEnabled` option when it receives focus. -## Parent Layout +## Parent Stack -```tsx title="app/example/_layout.tsx" focus="gestureEnabled,gestureTracking,current.options.gestureEnabled,gestureSensitivity,gestureReleaseVelocityScale" +```tsx title="navigation/app-stack.tsx" focus="gestureEnabled,gestureTracking,current.options.gestureEnabled,gestureSensitivity,gestureReleaseVelocityScale" 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 { createBlankStackNavigator } from "react-native-screen-transitions/react-navigation"; + +const Stack = createBlankStackNavigator(); const modalOptions: ScreenTransitionConfig = { gestureEnabled: true, @@ -95,12 +95,16 @@ const modalOptions: ScreenTransitionConfig = { }, }; -export default function ExampleLayout() { +export function AppStack() { return ( - - - - + + + + ); } ``` @@ -119,13 +123,14 @@ The `maxHeight` and `marginTop` values make the modal a real bottom-aligned card ## Launcher Screen -```tsx title="app/example/index.tsx" -import { router } from "expo-router"; +```tsx title="screens/home-screen.tsx" +import { useNavigation } from "@react-navigation/native"; import { Button, View } from "react-native"; import { useTheme } from "@/theme"; -export default function ExampleIndex() { +export function HomeScreen() { const theme = useTheme(); + const navigation = useNavigation(); return ( -