diff --git a/apps/e2e/app/[stackType]/overlay/_layout.tsx b/apps/e2e/app/[stackType]/overlay/_layout.tsx index 2e64b413..4025fe50 100644 --- a/apps/e2e/app/[stackType]/overlay/_layout.tsx +++ b/apps/e2e/app/[stackType]/overlay/_layout.tsx @@ -1,23 +1,28 @@ -// @ts-nocheck -import { interpolate } from "react-native-reanimated"; -import Transition from "react-native-screen-transitions"; +import { + OverlayA, + OverlayC, + OverlayE, + overlayIOSSlideOptions, + screenIOSSlideOptions, +} from "@/components/overlay-playground"; import { useResolvedStackType } from "@/components/stack-examples/stack-routing"; -import { TabBarOverlay } from "@/components/tab-bar-overlay"; import { BlankStack } from "@/layouts/blank-stack"; import { Stack } from "@/layouts/stack"; -import { IOSSlide } from "@/lib/screen-transitions/ios-slide"; -export default function BlankStackOverlayLayout() { +export default function OverlayPlaygroundLayout() { const stackType = useResolvedStackType(); const StackNavigator = stackType === "native-stack" ? Stack : BlankStack; const navigatorScreenOptions = stackType === "native-stack" ? { enableTransitions: true } : undefined; + return ( { - "worklet"; - const translateX = interpolate( - progress, - [0, 1, 2], - [width, 0, -width * 0.3], - ); - return { - content: { - style: { - transform: [{ translateX }], - }, - }, - }; - }, - transitionSpec: { - open: Transition.Specs.DefaultSpec, - close: Transition.Specs.DefaultSpec, - }, - // Inherit overlay from previous screen - overlay: TabBarOverlay, - overlayMode: "float", - overlayShown: true, + ...screenIOSSlideOptions, }} /> { - "worklet"; - const translateX = interpolate( - progress, - [0, 1, 2], - [width, 0, -width * 0.3], - ); - return { - content: { - style: { - transform: [{ translateX }], - }, - }, - }; - }, - transitionSpec: { - open: Transition.Specs.DefaultSpec, - close: Transition.Specs.DefaultSpec, - }, - // No overlay on this screen - overlayShown: false, + ...screenIOSSlideOptions, + }} + /> + diff --git a/apps/e2e/app/[stackType]/overlay/fifth.tsx b/apps/e2e/app/[stackType]/overlay/fifth.tsx new file mode 100644 index 00000000..b4af6f28 --- /dev/null +++ b/apps/e2e/app/[stackType]/overlay/fifth.tsx @@ -0,0 +1,5 @@ +import { OverlayPlaygroundScreen } from "@/components/overlay-playground"; + +export default function OverlayFifth() { + return ; +} diff --git a/apps/e2e/app/[stackType]/overlay/fourth.tsx b/apps/e2e/app/[stackType]/overlay/fourth.tsx new file mode 100644 index 00000000..1cf5fb84 --- /dev/null +++ b/apps/e2e/app/[stackType]/overlay/fourth.tsx @@ -0,0 +1,5 @@ +import { OverlayPlaygroundScreen } from "@/components/overlay-playground"; + +export default function OverlayFourth() { + return ; +} diff --git a/apps/e2e/app/[stackType]/overlay/index.tsx b/apps/e2e/app/[stackType]/overlay/index.tsx index 4776cfb3..cdf4efae 100644 --- a/apps/e2e/app/[stackType]/overlay/index.tsx +++ b/apps/e2e/app/[stackType]/overlay/index.tsx @@ -1,93 +1,5 @@ -import { router } from "expo-router"; -import { Pressable, StyleSheet, Text, View } from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import { - buildStackPath, - useResolvedStackType, -} from "@/components/stack-examples/stack-routing"; -import { screenTints, useTheme } from "@/theme"; +import { OverlayPlaygroundScreen } from "@/components/overlay-playground"; export default function OverlayIndex() { - const stackType = useResolvedStackType(); - const theme = useTheme(); - return ( - - - Overlay Demo - - A floating tab bar overlay is shown at the bottom. It animates based - on screen transitions and stack progress. - - - - - router.push(buildStackPath(stackType, "overlay/second")) - } - > - Push Second Screen - - - - router.push(buildStackPath(stackType, "overlay/no-overlay")) - } - > - Push Without Overlay - - - - - ); + return ; } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: screenTints.navy, - }, - content: { - flex: 1, - padding: 24, - justifyContent: "center", - alignItems: "center", - }, - title: { - fontSize: 28, - fontWeight: "bold", - color: "#fff", - marginBottom: 16, - textAlign: "center", - }, - description: { - fontSize: 16, - color: "rgba(255,255,255,0.7)", - textAlign: "center", - marginBottom: 40, - lineHeight: 24, - }, - buttons: { - gap: 12, - width: "100%", - maxWidth: 280, - }, - button: { - backgroundColor: "rgba(255,255,255,0.2)", - paddingVertical: 16, - paddingHorizontal: 32, - borderRadius: 999, - alignItems: "center", - }, - secondaryButton: { - backgroundColor: "rgba(0,0,0,0.3)", - }, - buttonText: { - fontSize: 16, - fontWeight: "600", - color: "#fff", - }, -}); diff --git a/apps/e2e/app/[stackType]/overlay/second.tsx b/apps/e2e/app/[stackType]/overlay/second.tsx index 708feb95..47925c70 100644 --- a/apps/e2e/app/[stackType]/overlay/second.tsx +++ b/apps/e2e/app/[stackType]/overlay/second.tsx @@ -1,89 +1,5 @@ -import { router } from "expo-router"; -import { Pressable, StyleSheet, Text, View } from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import { - buildStackPath, - useResolvedStackType, -} from "@/components/stack-examples/stack-routing"; +import { OverlayPlaygroundScreen } from "@/components/overlay-playground"; export default function OverlaySecond() { - const stackType = useResolvedStackType(); - return ( - - - Second Screen - - The overlay remains visible and shows the updated route. Notice the - tab bar dims as more screens stack. - - - - - router.push(buildStackPath(stackType, "overlay/third")) - } - > - Push Third Screen - - - router.back()} - > - Go Back - - - - - ); + return ; } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#2d4a3e", - }, - content: { - flex: 1, - padding: 24, - justifyContent: "center", - alignItems: "center", - }, - title: { - fontSize: 28, - fontWeight: "bold", - color: "#fff", - marginBottom: 16, - textAlign: "center", - }, - description: { - fontSize: 16, - color: "rgba(255,255,255,0.7)", - textAlign: "center", - marginBottom: 40, - lineHeight: 24, - }, - buttons: { - gap: 12, - width: "100%", - maxWidth: 280, - }, - button: { - backgroundColor: "rgba(255,255,255,0.2)", - paddingVertical: 16, - paddingHorizontal: 32, - borderRadius: 12, - alignItems: "center", - }, - secondaryButton: { - backgroundColor: "rgba(0,0,0,0.3)", - }, - buttonText: { - fontSize: 16, - fontWeight: "600", - color: "#fff", - }, -}); diff --git a/apps/e2e/app/[stackType]/overlay/third.tsx b/apps/e2e/app/[stackType]/overlay/third.tsx index 9762bf4a..878b9299 100644 --- a/apps/e2e/app/[stackType]/overlay/third.tsx +++ b/apps/e2e/app/[stackType]/overlay/third.tsx @@ -1,74 +1,5 @@ -import { router } from "expo-router"; -import { Pressable, StyleSheet, Text, View } from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; +import { OverlayPlaygroundScreen } from "@/components/overlay-playground"; export default function OverlayThird() { - return ( - - - Third Screen - - With 3 screens in the stack, the overlay has faded further. The - stackProgress drives this dimming animation. - - - - router.back()} - > - Go Back - - - - - ); + return ; } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#4a2d5f", - }, - content: { - flex: 1, - padding: 24, - justifyContent: "center", - alignItems: "center", - }, - title: { - fontSize: 28, - fontWeight: "bold", - color: "#fff", - marginBottom: 16, - textAlign: "center", - }, - description: { - fontSize: 16, - color: "rgba(255,255,255,0.7)", - textAlign: "center", - marginBottom: 40, - lineHeight: 24, - }, - buttons: { - gap: 12, - width: "100%", - maxWidth: 280, - }, - button: { - backgroundColor: "rgba(255,255,255,0.2)", - paddingVertical: 16, - paddingHorizontal: 32, - borderRadius: 12, - alignItems: "center", - }, - secondaryButton: { - backgroundColor: "rgba(0,0,0,0.3)", - }, - buttonText: { - fontSize: 16, - fontWeight: "600", - color: "#fff", - }, -}); diff --git a/apps/e2e/components/overlay-playground.tsx b/apps/e2e/components/overlay-playground.tsx new file mode 100644 index 00000000..4a8f7b0e --- /dev/null +++ b/apps/e2e/components/overlay-playground.tsx @@ -0,0 +1,207 @@ +import { router } from "expo-router"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { interpolate } from "react-native-reanimated"; +import type { ScreenTransitionConfig } from "react-native-screen-transitions"; +import Transition from "react-native-screen-transitions"; +import { + buildStackPath, + useResolvedStackType, +} from "@/components/stack-examples/stack-routing"; + +type OverlayScreenName = "A" | "B" | "C" | "D" | "E"; + +const nextRoute: Partial> = { + A: "overlay/second", + B: "overlay/third", + C: "overlay/fourth", + D: "overlay/fifth", +}; + +const overlayTints: Record<"A" | "C" | "E", string> = { + A: "#3b82f6", + C: "#22c55e", + E: "#f59e0b", +}; + +const createIOSSlideOptions = ({ + includeOverlaySlot, +}: { + includeOverlaySlot: boolean; +}): ScreenTransitionConfig => ({ + enableTransitions: true, + gestureEnabled: true, + gestureDirection: "horizontal", + screenStyleInterpolator: ({ + progress, + layouts: { + screen: { width }, + }, + }) => { + "worklet"; + const contentTranslateX = interpolate( + progress, + [0, 1, 2], + [width, 0, -width * 0.3], + "clamp", + ); + const content = { + style: { + transform: [{ translateX: contentTranslateX }], + }, + }; + const overlay = includeOverlaySlot + ? { + style: { + transform: [ + { + translateX: interpolate( + progress, + [0, 1, 2], + [width, 0, -width], + "clamp", + ), + }, + ], + }, + } + : undefined; + + return { content, overlay }; + }, + transitionSpec: { + open: Transition.Specs.DefaultSpec, + close: Transition.Specs.DefaultSpec, + }, +}); + +export const screenIOSSlideOptions = createIOSSlideOptions({ + includeOverlaySlot: false, +}); +export const overlayIOSSlideOptions = createIOSSlideOptions({ + includeOverlaySlot: true, +}); + +function FullScreenOverlay({ screen }: { screen: "A" | "C" | "E" }) { + return ( + + + Overlay {screen} + + ); +} + +export const OverlayA = () => ; +export const OverlayC = () => ; +export const OverlayE = () => ; + +export function OverlayPlaygroundScreen({ + screen, +}: { + screen: OverlayScreenName; +}) { + const stackType = useResolvedStackType(); + const next = nextRoute[screen]; + + return ( + + + Screen {screen} + + Overlay checkpoints are Screen A, Screen C, and Screen E. + + {next ? ( + router.push(buildStackPath(stackType, next))} + > + + Push Screen {String.fromCharCode(screen.charCodeAt(0) + 1)} + + + ) : null} + {screen !== "A" ? ( + router.back()} + > + Back + + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + screen: { + // Keep the canvas neutral so the initial Screen A tint is visible before + // any navigation begins. + backgroundColor: "#f5f7fb", + flex: 1, + }, + content: { + alignItems: "center", + flex: 1, + gap: 16, + justifyContent: "center", + paddingHorizontal: 28, + }, + title: { + color: "#101828", + fontSize: 36, + fontWeight: "900", + }, + description: { + color: "rgba(16,24,40,0.72)", + fontSize: 16, + lineHeight: 24, + maxWidth: 280, + textAlign: "center", + }, + button: { + alignItems: "center", + backgroundColor: "rgba(16,24,40,0.1)", + borderRadius: 999, + marginTop: 8, + paddingHorizontal: 28, + paddingVertical: 16, + }, + secondaryButton: { + backgroundColor: "rgba(16,24,40,0.18)", + }, + buttonText: { + color: "#101828", + fontSize: 16, + fontWeight: "700", + }, + overlay: { + alignItems: "center", + borderColor: "red", + borderWidth: 2, + flex: 1, + justifyContent: "center", + }, + overlayTint: { + ...StyleSheet.absoluteFillObject, + opacity: 0.25, + }, + overlayLabel: { + backgroundColor: "rgba(255,255,255,0.82)", + borderRadius: 999, + color: "#101828", + fontSize: 24, + fontWeight: "800", + overflow: "hidden", + paddingHorizontal: 20, + paddingVertical: 10, + }, +}); diff --git a/apps/e2e/components/tab-bar-overlay.tsx b/apps/e2e/components/tab-bar-overlay.tsx index a4f9b0ba..d5166416 100644 --- a/apps/e2e/components/tab-bar-overlay.tsx +++ b/apps/e2e/components/tab-bar-overlay.tsx @@ -4,10 +4,7 @@ import Animated, { useAnimatedStyle, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { - type OverlayProps, - useScreenAnimation, -} from "react-native-screen-transitions"; +import type { OverlayProps } from "react-native-screen-transitions"; import { useTheme } from "@/theme"; /** @@ -21,13 +18,13 @@ export function TabBarOverlay({ focusedIndex, routes, navigation, + progress, }: OverlayProps) { const insets = useSafeAreaInsets(); - const screenAnimation = useScreenAnimation(); const theme = useTheme(); const containerStyle = useAnimatedStyle(() => { - const { stackProgress } = screenAnimation.value; + const stackProgress = progress.get(); // Fade out when stack progress increases (more screens on top) // stackProgress = 1 means just this screen, > 1 means screens above diff --git a/bun.lock b/bun.lock index 9f7a7625..7b231cf1 100644 --- a/bun.lock +++ b/bun.lock @@ -62,18 +62,18 @@ "name": "docs", "dependencies": { "@resvg/resvg-js": "^2.6.2", - "@tanstack/react-router": "^1.170.4", + "@tanstack/react-router": "1.170.18", "@tanstack/react-start": "1.168.32", - "react": "19.1.0", - "react-dom": "19.1.0", + "react": "19.2.3", + "react-dom": "19.2.3", }, "devDependencies": { "@mdx-js/rollup": "^3.1.1", "@netlify/vite-plugin-tanstack-start": "1.3.17", "@tailwindcss/vite": "^4.3.0", "@types/node": "^25.9.0", - "@types/react": "~19.1.0", - "@types/react-dom": "~19.1.0", + "@types/react": "~19.2.0", + "@types/react-dom": "~19.2.0", "@vitejs/plugin-react": "^6.0.2", "geist": "^1.7.1", "remark-frontmatter": "^5.0.0", @@ -1192,9 +1192,9 @@ "@types/parse-path": ["@types/parse-path@7.1.0", "", { "dependencies": { "parse-path": "*" } }, "sha512-EULJ8LApcVEPbrfND0cRQqutIOdiIgJ1Mgrhpy755r14xMohPTEpkV/k28SJvuOs9bHRFW8x+KeDAEPiGQPB9Q=="], - "@types/react": ["@types/react@19.1.17", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA=="], + "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], - "@types/react-dom": ["@types/react-dom@19.1.11", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-3BKc/yGdNTYQVVw4idqHtSOcFsgGuBbMveKCOgF8wQ5QtrYOc3jDIlzg3jef04zcXFIHLelyGlj0T+BJ8+KN+w=="], + "@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="], "@types/react-native": ["@types/react-native@0.73.0", "", { "dependencies": { "react-native": "*" } }, "sha512-6ZRPQrYM72qYKGWidEttRe6M5DZBEV5F+MHMHqd4TTYx0tfkcdrUFGdef6CCxY0jXU7wldvd/zA/b0A/kTeJmA=="], @@ -2886,11 +2886,11 @@ "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], - "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], + "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], - "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], + "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], "react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="], @@ -3046,7 +3046,7 @@ "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], @@ -3776,6 +3776,12 @@ "dotenv-expand/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="], + "e2e/@types/react": ["@types/react@19.1.17", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA=="], + + "e2e/react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], + + "e2e/react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], + "e2e/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], @@ -3930,6 +3936,8 @@ "react-devtools-core/ws": ["ws@7.5.11", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA=="], + "react-native/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + "react-native-builder-bob/babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.34.0", "", { "dependencies": { "hermes-parser": "0.34.0" } }, "sha512-q4xeAymMrot/21MHA3+fd5mcFF7stx6ntKFO/Of5ldyDpgTBcK1l0NiHAh4NdHHdb4aHqHgQOy7r6yk0IIlz8Q=="], "react-native-builder-bob/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -3940,12 +3948,16 @@ "react-native-monorepo-config/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "react-native-screen-transitions/@types/react": ["@types/react@19.1.17", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA=="], + "react-native-web/@react-native/normalize-colors": ["@react-native/normalize-colors@0.74.89", "", {}, "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="], "react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="], "react-native-worklets/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "react-test-renderer/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + "read-package-up/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "read-pkg/normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], @@ -4248,6 +4260,8 @@ "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], + "e2e/react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "jest-diff/pretty-format/@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], diff --git a/packages/docs/package.json b/packages/docs/package.json index 5e115733..04317441 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -10,18 +10,18 @@ }, "dependencies": { "@resvg/resvg-js": "^2.6.2", - "@tanstack/react-router": "^1.170.4", + "@tanstack/react-router": "1.170.18", "@tanstack/react-start": "1.168.32", - "react": "19.1.0", - "react-dom": "19.1.0" + "react": "19.2.3", + "react-dom": "19.2.3" }, "devDependencies": { "@mdx-js/rollup": "^3.1.1", "@netlify/vite-plugin-tanstack-start": "1.3.17", "@tailwindcss/vite": "^4.3.0", "@types/node": "^25.9.0", - "@types/react": "~19.1.0", - "@types/react-dom": "~19.1.0", + "@types/react": "~19.2.0", + "@types/react-dom": "~19.2.0", "@vitejs/plugin-react": "^6.0.2", "geist": "^1.7.1", "remark-frontmatter": "^5.0.0", diff --git a/packages/docs/src/content/docs/overlays.mdx b/packages/docs/src/content/docs/overlays.mdx index 363d9b80..1f50f53e 100644 --- a/packages/docs/src/content/docs/overlays.mdx +++ b/packages/docs/src/content/docs/overlays.mdx @@ -1,8 +1,8 @@ --- title: "Overlays" pageTitle: "Overlays" -summary: "Use screen overlays as floating stack-relative UI that persists from the owning screen upward through the rest of that stack." -description: "Use screen overlays as floating stack-relative UI that persists from the owning screen upward through the rest of that stack." +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 @@ -11,122 +11,147 @@ to: /overlays ## Mental Model -An overlay is a floating screen layer owned by a screen in a stack. +An overlay is a floating layer owned by one screen in a navigator. It renders above that navigator's screens and stays mounted while its owner remains in the stack. -Configure it on screen options with `overlay` and `overlayShown`. +Configure it with the `overlay` and `overlayShown` screen options: ```tsx ``` -The overlay belongs to the screen that defines it, not the whole app. +Overlays are scoped to their own navigator. They do not float into a parent, child, or sibling navigator. -## How Overlay Ownership Works +## The Overlay Stack -An overlay starts floating when its owner screen becomes active and stays active while you push screens above that owner. +Screens and overlays have separate adjacency. If `[]` means that a screen owns an overlay, this screen stack: -- if `index` defines an overlay, it can float from `index` through the rest of the stack -- if screen 3 defines an overlay, it does not appear on screens 1 or 2 -- once you reach screen 3, that overlay can float from screen 3 through any screens above it -- if a higher screen defines a visible overlay, that higher screen becomes the new overlay owner +```text +A[] -> B -> C[] -> D -> E[] +``` + +produces this overlay stack: + +```text +A[] -> C[] -> E[] +``` -This is stack-relative, not a global portal. +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. ## Basic Example ```tsx - + - - ``` -The same overlay can keep floating deeper through the stack. +Here, `OverlayA` remains above `B`. When `C` is pushed, `OverlayC` becomes active above `OverlayA`. -If you push a screen with `overlayShown: false`, the system skips that screen and keeps scanning downward for the highest visible overlay owner. +## Animate the Overlay Stack -## Overlay Component Example +`overlay` is a reserved `screenStyleInterpolator` slot. The destination screen's interpolator drives the current adjacent overlay pair. ```tsx -import Animated, { - interpolate, - useAnimatedStyle, -} from "react-native-reanimated"; -import { - type OverlayProps, - useScreenAnimation, -} from "react-native-screen-transitions"; - -function TabBarOverlay({ - focusedRoute, - focusedIndex, - routes, - navigation, -}: OverlayProps) { - const animation = useScreenAnimation(); +screenStyleInterpolator: ({ + progress, + layouts: { + screen: { width }, + }, +}) => { + "worklet"; + + return { + content: { + style: { + transform: [ + { + translateX: interpolate( + progress, + [0, 1, 2], + [width, 0, -width * 0.3], + "clamp", + ), + }, + ], + }, + }, + overlay: { + style: { + transform: [ + { + translateX: interpolate( + progress, + [0, 1, 2], + [width, 0, -width], + "clamp", + ), + }, + ], + }, + }, + }; +} +``` - const style = useAnimatedStyle(() => { - const { stackProgress } = animation.value; +In `A[] -> B -> C[]`, the `overlay` slot from `C` animates `A` out and `C` in. `B` is part of the screen stack, but not the overlay stack. - return { - opacity: interpolate(stackProgress, [1, 2], [1, 0.3], "clamp"), - }; - }); +A screen does not need to own an overlay to drive the latest one. In `A[] -> B`, an `overlay` slot returned by `B` styles `A`. If `B` does not return the slot, `A` is left alone. - return ( - - {focusedRoute.name} - - {focusedIndex + 1} / {routes.length} - - - ); -} -``` +Do not use `overlay` as a custom `styleId`. It is a non-inheriting layer slot, like `content` and `backdrop`. -`stackProgress` is relative to the overlay owner's position in the stack. +## Owner State and Focused State -## Overlay Props +An overlay can outlive the moment when its owner was focused, so its props separate stable owner information from changing navigator information. | Prop | Purpose | | --- | --- | -| `focusedRoute` | Focused route in the current stack | -| `focusedIndex` | Focused route index | -| `routes` | All routes in the current stack | -| `meta` | `meta` from the focused screen options | -| `navigation` | Navigation object for the overlay's owning stack | -| `options` | Focused screen transition options | -| `progress` | Overlay-relative stack progress | - -Many overlay components read animation state through `useScreenAnimation()` instead of consuming `progress` directly. +| `route` | Route that owns the overlay | +| `index` | Owner route index | +| `focusedRoute` | Currently presented route in this navigator | +| `focusedIndex` | Currently presented route index | +| `routes` | All routes in this navigator's stack | +| `meta` | Metadata from the focused screen options | +| `options` | Transition options from the focused screen | +| `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 + +The floating host reuses the stores created by the owner screen. It does not build a second descriptor, options, gesture, or animation provider tree. + +That keeps these APIs scoped to the route that owns the overlay: + +```tsx +function HomeOverlay() { + const animation = useScreenAnimation(); + + return ( + + + + ); +} +``` + +Use the reserved interpolator `overlay` slot when the screen transition should move the overlay layer itself. Use `useScreenAnimation()` or a custom `styleId` when elements inside the overlay need their own motion. diff --git a/packages/docs/src/content/docs/surface-slots.mdx b/packages/docs/src/content/docs/surface-slots.mdx index 4e6e577f..46ba5dd0 100644 --- a/packages/docs/src/content/docs/surface-slots.mdx +++ b/packages/docs/src/content/docs/surface-slots.mdx @@ -1,8 +1,8 @@ --- title: "Layers" pageTitle: "Layers" -summary: "Understand the screen-container layering system: backdrop, content, deprecated surface wrappers, element targets, and navigation masks." -description: "Understand the screen-container layering system: backdrop, content, deprecated surface wrappers, element targets, and navigation masks." +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." eyebrow: "Core Concepts" group: "Core Concepts" order: 9 @@ -15,6 +15,7 @@ to: /surface-slots The layer keys are: +- `overlay` - `backdrop` - `content` - `surface` @@ -32,6 +33,7 @@ import { } from "react-native-screen-transitions"; return { + overlay: { style: { ... }, props: { ... } }, backdrop: { style: { ... }, props: { ... } }, content: { style: { ... }, props: { ... } }, surface: { style: { ... }, props: { ... } }, @@ -45,6 +47,7 @@ Each slot also supports shorthand style-only output: ```tsx return { + overlay: { transform: [{ translateY: 12 }] }, backdrop: { opacity: 0.4 }, content: { transform: [{ translateY: 12 }] }, "hero-card": { scale: 0.98 }, @@ -53,7 +56,7 @@ return { ## Render Order -Inside `screen-container`, layers are resolved in this order: +Most layers render inside `screen-container` in this order: 1. `backdrop` 2. `content` @@ -61,11 +64,34 @@ Inside `screen-container`, layers are resolved in this order: 4. deprecated optional surface wrapper when `surfaceComponent` is provided 5. screen children and any matching `styleId` targets +`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. + That gives you two levels of control: - screen-level layers for the overall container structure +- the floating overlay layer for persistent stack-relative UI - element-level targets for individual transition-aware components inside the screen +## overlay + +`overlay` controls the floating overlay transition for the destination screen. + +```tsx +overlay: { + style: { + transform: [ + { + translateX: interpolate(progress, [0, 1, 2], [width, 0, -width]), + }, + ], + }, +} +``` + +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. + +`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 `backdrop` controls the full-screen layer behind the active screen content. diff --git a/packages/docs/src/content/docs/updating-to-3-12.mdx b/packages/docs/src/content/docs/updating-to-3-12.mdx new file mode 100644 index 00000000..89109c28 --- /dev/null +++ b/packages/docs/src/content/docs/updating-to-3-12.mdx @@ -0,0 +1,114 @@ +--- +title: "New in 3.12" +pageTitle: "New in 3.12" +summary: "v3.12 gives floating overlays their own stack and lets screen interpolators animate them without rebuilding the transition provider tree." +description: "v3.12 gives floating overlays their own stack and lets screen interpolators animate them without rebuilding the transition provider tree." +eyebrow: "Changelogs" +group: "Changelogs" +hidden: true +changelogDate: "August 2026" +order: -3 +to: /changelog/updating-to-3-12 +--- + +Overlays were one of the more rushed parts of screen-transitions. They could float above a stack, but animating them meant reaching for `useScreenAnimation()`, and the floating layer rebuilt much of the same provider tree that already existed for the screen. + +v3.12 replaces that setup. Overlays now have a small transition system of their own while still borrowing the animation and style state from their original screen. + +## Overlays now form their own stack + +If `[]` means that a screen owns an overlay, consider this stack: + +```text +A[] -> B -> C[] -> D -> E[] +``` + +The overlay stack is: + +```text +A[] -> C[] -> E[] +``` + +`A` keeps floating when `B` is pushed because `B` does not replace it. When `C` is pushed, its overlay appears above `A`. The same thing happens when `E` is pushed above `C`. + +Once shown, an overlay stays mounted while its owning route remains in the navigation stack and `overlayShown` is not disabled. This means state inside an overlay, such as a carousel position or an in-progress interaction, does not reset when another screen is pushed. Only the top overlay accepts touches; the overlays below it remain mounted as part of the visual transition. + +## Animate overlays from the interpolator + +`screenStyleInterpolator` can now return an `overlay` slot alongside `content`, `backdrop`, and other style slots. + +```tsx +screenStyleInterpolator: ({ + progress, + layouts: { + screen: { width }, + }, +}) => { + "worklet"; + + return { + content: { + style: { + transform: [ + { + translateX: interpolate( + progress, + [0, 1, 2], + [width, 0, -width * 0.3], + "clamp", + ), + }, + ], + }, + }, + overlay: { + style: { + transform: [ + { + translateX: interpolate( + progress, + [0, 1, 2], + [width, 0, -width], + "clamp", + ), + }, + ], + }, + }, + }; +} +``` + +The destination screen drives the transition. In `A[] -> B -> C[]`, the `overlay` slot returned by `C` animates `A` out and `C` in as one adjacent overlay pair. `B` is still part of the normal screen stack, but it does not sit between those overlays. + +A screen does not need to own an overlay to animate the current one. If `B` returns an `overlay` slot in `A[] -> B`, those styles are applied to `A`. If the interpolator does not return an `overlay` slot, the existing overlay is left alone. + +## Less work at the floating layer + +The previous overlay host rebuilt the descriptor, options, and animation providers around every floating overlay. The screen had already created that state, so doing it again was wasteful and made the overlay path harder to reason about. + +Animation and slot providers can now register their store by route key. The floating overlay reads the original store instead of constructing another transition pipeline. The registry only carries stable store references; animation values continue to update on the UI thread. + +This also means `useScreenAnimation()` and transition components with a `styleId` work inside an overlay without adding another scope component. Both resolve against the route that owns the overlay. + +## Owner state and focused state + +Overlay props now make the difference between ownership and focus explicit. + +- `route` and `index` belong to the screen that created the overlay. +- `focusedRoute`, `focusedIndex`, `options`, and `meta` follow the screen currently presented by that navigator. +- `navigation` remains scoped to the overlay owner's navigator. + +During an interactive dismissal, focused values switch after the dismissal commits. A cancelled drag keeps the current screen focused; a committed drag exposes the screen underneath while the closing screen finishes its animation. + +`OverlayProps` also accepts a navigator param list and owner route name, so an overlay can type its owner route and navigation object without adding metadata generics to the navigator itself. + +## Programmatic back actions + +Blank Stack no longer waits for one closing route to leave React state before accepting another programmatic back action. Repeated back actions can advance through the stack while earlier screens finish their closing animations, matching the behavior of repeated gesture dismissals. + +## Upgrade notes + +No migration is required for existing overlays. The `overlay` and `overlayShown` screen options keep the same shape, and overlays that use `useScreenAnimation()` continue to work. + +The new interpolator slot is optional. Add it only when the screen transition should move or style the floating overlay. Existing interpolators that return only `content`, `backdrop`, custom style IDs, or other slots keep their previous behavior. diff --git a/packages/react-native-screen-transitions/CHANGELOG.md b/packages/react-native-screen-transitions/CHANGELOG.md index 50ea16fb..9fff2758 100644 --- a/packages/react-native-screen-transitions/CHANGELOG.md +++ b/packages/react-native-screen-transitions/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [3.12.0](https://github.com/eds2002/react-native-screen-transitions/compare/v3.11.2...v3.12.0) (2026-08-09) + +### Features + +* add stacked floating overlays ([3e12de0](https://github.com/eds2002/react-native-screen-transitions/commit/3e12de0b6d58324c5aaa0561b2c04d7403d121bc)) +* coordinate sparse overlay transitions ([b5be642](https://github.com/eds2002/react-native-screen-transitions/commit/b5be642b494ad3ec3a233c299d9c5ad02fc99917)) +* **types:** annotate overlay metadata ([9b940f8](https://github.com/eds2002/react-native-screen-transitions/commit/9b940f8669ec2107aa0a4a5874a635c2912e0f24)) + +### Bug Fixes + +* allow consecutive programmatic dismissals ([eb5b5d2](https://github.com/eds2002/react-native-screen-transitions/commit/eb5b5d2a9d6b205c9ecc4c6dc0661590f63c1b79)) +* **overlays:** finalize slot contracts ([c0e649a](https://github.com/eds2002/react-native-screen-transitions/commit/c0e649a12b3671684668c84b89c8a20b79708cc0)) +* **overlays:** preserve route-scoped host state ([9c52a59](https://github.com/eds2002/react-native-screen-transitions/commit/9c52a59717c133fcd405b291fc2b78a8a6139cf7)) + +### Performance Improvements + +* **gestures:** isolate active ownership subscriptions ([a9c4881](https://github.com/eds2002/react-native-screen-transitions/commit/a9c4881d3b69c393c6056adeafeb8f65d7811066)) +* **providers:** narrow store subscriptions ([ba67cf6](https://github.com/eds2002/react-native-screen-transitions/commit/ba67cf69e93ce7b32c438fd6fb9d275de6ae345c)) + ## [3.11.3](https://github.com/eds2002/react-native-screen-transitions/compare/v3.11.2...v3.11.3) (2026-08-06) ## [3.11.2](https://github.com/eds2002/react-native-screen-transitions/compare/v3.11.1...v3.11.2) (2026-08-03) diff --git a/packages/react-native-screen-transitions/package.json b/packages/react-native-screen-transitions/package.json index 5d8db769..1eedcc91 100644 --- a/packages/react-native-screen-transitions/package.json +++ b/packages/react-native-screen-transitions/package.json @@ -1,6 +1,6 @@ { "name": "react-native-screen-transitions", - "version": "3.11.3", + "version": "3.12.0", "description": "Easy screen transitions for React Native and Expo", "author": "Ed", "license": "MIT", diff --git a/packages/react-native-screen-transitions/src/blank-stack/components/stack-view.tsx b/packages/react-native-screen-transitions/src/blank-stack/components/stack-view.tsx index 993b8330..e94f7f4a 100644 --- a/packages/react-native-screen-transitions/src/blank-stack/components/stack-view.tsx +++ b/packages/react-native-screen-transitions/src/blank-stack/components/stack-view.tsx @@ -80,13 +80,10 @@ const StackViewContent = memo(function StackViewContent() { const routeKeys = useBlankStackStore( (store) => store?.routeKeys ?? EMPTY_ROUTE_KEYS, ); - const shouldShowFloatOverlay = useBlankStackStore( - (store) => store?.shouldShowFloatOverlay ?? false, - ); return ( - {shouldShowFloatOverlay ? : null} + {routeKeys.map((routeKey) => ( diff --git a/packages/react-native-screen-transitions/src/blank-stack/types.ts b/packages/react-native-screen-transitions/src/blank-stack/types.ts index 90196663..a51c0e4f 100644 --- a/packages/react-native-screen-transitions/src/blank-stack/types.ts +++ b/packages/react-native-screen-transitions/src/blank-stack/types.ts @@ -98,8 +98,14 @@ export interface BlankStackFactoryOptions { * Props passed to overlay components in blank-stack. * Uses the shared OverlayProps type with blank-stack's navigation type. */ -export type BlankStackOverlayProps = OverlayProps< - BlankStackNavigationProp +export type BlankStackOverlayProps< + ParamList extends ParamListBase = ParamListBase, + RouteName extends keyof ParamList = keyof ParamList, + NavigatorID extends string | undefined = undefined, +> = OverlayProps< + BlankStackNavigationProp, + ParamList, + RouteName >; export type BlankStackNavigationOptions = ScreenTransitionConfig & { diff --git a/packages/react-native-screen-transitions/src/component-stack/components/stack-view.tsx b/packages/react-native-screen-transitions/src/component-stack/components/stack-view.tsx index 062b13a1..bb427ce6 100644 --- a/packages/react-native-screen-transitions/src/component-stack/components/stack-view.tsx +++ b/packages/react-native-screen-transitions/src/component-stack/components/stack-view.tsx @@ -54,13 +54,10 @@ const StackViewContent = memo(function StackViewContent() { const routeKeys = useBlankStackStore( (store) => store?.routeKeys ?? EMPTY_ROUTE_KEYS, ); - const shouldShowFloatOverlay = useBlankStackStore( - (store) => store?.shouldShowFloatOverlay ?? false, - ); return ( - {shouldShowFloatOverlay ? : null} + {routeKeys.map((routeKey) => ( diff --git a/packages/react-native-screen-transitions/src/component-stack/types.ts b/packages/react-native-screen-transitions/src/component-stack/types.ts index e72c4460..ad3a73b7 100644 --- a/packages/react-native-screen-transitions/src/component-stack/types.ts +++ b/packages/react-native-screen-transitions/src/component-stack/types.ts @@ -74,8 +74,14 @@ type ComponentStackNavigationConfig = {}; * * @deprecated Component stack is deprecated. Use blank stack instead. */ -export type ComponentStackOverlayProps = OverlayProps< - ComponentStackNavigationProp +export type ComponentStackOverlayProps< + ParamList extends ParamListBase = ParamListBase, + RouteName extends keyof ParamList = keyof ParamList, + NavigatorID extends string | undefined = undefined, +> = OverlayProps< + ComponentStackNavigationProp, + ParamList, + RouteName >; type ComponentStackScreenTransitionConfig = ScreenTransitionConfig & { diff --git a/packages/react-native-screen-transitions/src/native-stack/types.ts b/packages/react-native-screen-transitions/src/native-stack/types.ts index aa425238..37b1a652 100644 --- a/packages/react-native-screen-transitions/src/native-stack/types.ts +++ b/packages/react-native-screen-transitions/src/native-stack/types.ts @@ -154,8 +154,14 @@ export type NativeStackHeaderLeftProps = NativeStackHeaderRightProps & { * Props passed to overlay components in native-stack. * Uses the shared OverlayProps type with native-stack's navigation type. */ -export type NativeStackOverlayProps = OverlayProps< - NativeStackNavigationProp +export type NativeStackOverlayProps< + ParamList extends ParamListBase = ParamListBase, + RouteName extends keyof ParamList = keyof ParamList, + NavigatorID extends string | undefined = undefined, +> = OverlayProps< + NativeStackNavigationProp, + ParamList, + RouteName >; /** diff --git a/packages/react-native-screen-transitions/src/native-stack/views/NativeStackView.native.tsx b/packages/react-native-screen-transitions/src/native-stack/views/NativeStackView.native.tsx index 009906f5..ec96695e 100644 --- a/packages/react-native-screen-transitions/src/native-stack/views/NativeStackView.native.tsx +++ b/packages/react-native-screen-transitions/src/native-stack/views/NativeStackView.native.tsx @@ -482,14 +482,8 @@ const SceneView = ({ }; function NativeStackViewContent() { - const { - state, - navigation, - descriptors, - scenes, - focusedIndex, - shouldShowFloatOverlay, - } = useDirectStackStore(); + const { state, navigation, descriptors, scenes, focusedIndex } = + useDirectStackStore(); const { setNextDismissedKey } = useDismissedRouteError(state); useInvalidPreventRemoveError(descriptors); @@ -498,7 +492,7 @@ function NativeStackViewContent() { return ( <> - {shouldShowFloatOverlay ? : null} + {scenes.map((scene, index) => { const { route, descriptor, isPreloaded } = scene; diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/blank-stack-controller.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/blank-stack-controller.test.ts index 9cea3aba..dcc9203c 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/blank-stack-controller.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/blank-stack-controller.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { createContext, useContext } from "react"; import type { BlankStackProviderProps } from "../types/providers/blank-stack-provider.types"; import type { BaseStackDescriptor, @@ -6,10 +7,24 @@ import type { BaseStackRoute, } from "../types/stack.types"; +const NavigationContext = createContext(undefined); +const NavigationRouteContext = createContext< + { key: string; name: string } | undefined +>(undefined); + mock.module("@react-navigation/native", () => ({ + NavigationContext, + NavigationRouteContext, StackActions: { pop: () => ({ type: "POP" }), }, + useRoute: () => { + const route = useContext(NavigationRouteContext); + if (!route) { + throw new Error("Navigation route was not provided"); + } + return route; + }, })); const { createBlankStackController } = await import( @@ -116,6 +131,41 @@ describe("createBlankStackController", () => { ]); }); + it("replaces the retained programmatic close when navigation pops again", () => { + const navigation = createNavigation(); + const routeA = createRoute("a"); + const routeB = createRoute("b"); + const routeC = createRoute("c"); + const descriptorA = createDescriptor(routeA, navigation); + const descriptorB = createDescriptor(routeB, navigation); + const descriptorC = createDescriptor(routeC, navigation); + const controller = createBlankStackController( + createProps( + [routeA, routeB, routeC], + { a: descriptorA, b: descriptorB, c: descriptorC }, + navigation, + ), + ); + + controller.update( + createProps([routeA, routeB], { a: descriptorA, b: descriptorB }, navigation), + ); + expect(controller.getSnapshot().state.closingRouteKeys).toEqual( + new Set(["c"]), + ); + + controller.update(createProps([routeA], { a: descriptorA }, navigation)); + + const snapshot = controller.getSnapshot(); + expect(snapshot.state.routes.map((route) => route.key)).toEqual(["a", "b"]); + expect(snapshot.state.closingRouteKeys).toEqual(new Set(["b"])); + expect(snapshot.state.descriptors.c).toBeUndefined(); + expect(snapshot.state.scenes.map((scene) => scene.activity)).toEqual([ + "inert", + "closing", + ]); + }); + it("soft-dismisses a focused route before React Navigation removes it", () => { const navigation = createNavigation(); const routeA = createRoute("a"); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/create-provider.test.tsx b/packages/react-native-screen-transitions/src/shared/__tests__/create-provider.test.tsx new file mode 100644 index 00000000..5fed0bdb --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/__tests__/create-provider.test.tsx @@ -0,0 +1,179 @@ +import { describe, expect, it } from "bun:test"; +import type { ReactNode } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import createProvider from "../utils/create-provider"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +type TestProviderProps = { + children?: ReactNode; + id: string; + value: number; +}; + +const { TestProvider, useTestStore } = createProvider("Test", { + guarded: true, + global: true, +})(({ children, id, value }) => ({ + children, + key: id, + value: { value }, +})); + +describe("createProvider global stores", () => { + it("preserves local context selectors", () => { + let observed = 0; + let renderer: ReactTestRenderer; + + function Reader() { + observed = useTestStore((store) => store.value); + return null; + } + + act(() => { + renderer = create( + + + , + ); + }); + + expect(observed).toBe(7); + act(() => renderer.unmount()); + }); + + it("reads a provider store by key outside its context", () => { + let observed: number | null = null; + let renderer: ReactTestRenderer; + + function Reader() { + observed = useTestStore("screen-a", (store) => store.value); + return null; + } + + act(() => { + renderer = create( + <> + + + , + ); + }); + + expect(observed).toBe(1); + act(() => renderer.unmount()); + }); + + it("resolves a keyed read from its matching local provider immediately", () => { + const observed: Array = []; + let renderer: ReactTestRenderer; + + function Reader() { + observed.push(useTestStore("screen-a", (store) => store.value)); + return null; + } + + act(() => { + renderer = create( + + + , + ); + }); + + expect(observed).toEqual([1]); + act(() => renderer.unmount()); + }); + + it("does not resolve a keyed read from an unrelated local provider", () => { + let observed: number | null = 1; + let renderer: ReactTestRenderer; + + function Reader() { + observed = useTestStore("screen-b", (store) => store.value); + return null; + } + + act(() => { + renderer = create( + + + , + ); + }); + + expect(observed).toBeNull(); + act(() => renderer.unmount()); + }); + + it("isolates keyed stores and forwards updates from their providers", () => { + const observed: Record = {}; + let renderer: ReactTestRenderer; + + function Reader({ id }: { id: string }) { + observed[id] = useTestStore(id, (store) => store.value); + return null; + } + + const renderTree = (a: number, b: number) => ( + <> + + + + + + ); + + act(() => { + renderer = create(renderTree(1, 2)); + }); + + expect(observed).toEqual({ "screen-a": 1, "screen-b": 2 }); + + act(() => { + renderer.update(renderTree(3, 4)); + }); + + expect(observed).toEqual({ "screen-a": 3, "screen-b": 4 }); + act(() => renderer.unmount()); + }); + + it("does not let stale cleanup remove a newer store with the same key", () => { + let observed: number | null = null; + let renderer: ReactTestRenderer; + + function Reader() { + observed = useTestStore("shared", (store) => store.value); + return null; + } + + const renderTree = (showFirst: boolean, showSecond: boolean) => ( + <> + {showFirst ? ( + + ) : null} + {showSecond ? ( + + ) : null} + + + ); + + act(() => { + renderer = create(renderTree(true, true)); + }); + expect(observed).toBe(2); + + act(() => { + renderer.update(renderTree(false, true)); + }); + expect(observed).toBe(2); + + act(() => { + renderer.update(renderTree(false, false)); + }); + expect(observed).toBeNull(); + + act(() => renderer.unmount()); + }); +}); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/overlay/active-overlay-presentation.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/overlay/active-overlay-presentation.test.ts new file mode 100644 index 00000000..074756c1 --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/__tests__/overlay/active-overlay-presentation.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "bun:test"; +import type { StackScene } from "../../hooks/navigation/use-stack"; +import { + getFloatOverlayStack, + getFloatOverlayTransitions, +} from "../../components/overlay/helpers/get-active-overlay"; + +const OverlayA = () => null; +const OverlayB = () => null; +const OverlayC = () => null; +const OverlayD = () => null; +const OverlayE = () => null; + +const createScene = ( + key: string, + overlay?: typeof OverlayA, +): StackScene => + ({ + activity: "active", + route: { key, name: key }, + descriptor: { + route: { key, name: key }, + options: { overlay }, + }, + }) as StackScene; + +describe("floating overlay presentation", () => { + it("pairs sparse overlays with the next overlay's style driver", () => { + const scenes = [ + createScene("A", OverlayA), + createScene("B"), + createScene("C", OverlayC), + createScene("D"), + createScene("E", OverlayE), + ]; + + const transitions = getFloatOverlayTransitions( + getFloatOverlayStack(scenes, true), + scenes, + ); + + expect( + transitions.map(({ scene, driverScene }) => [ + scene.route.key, + driverScene.route.key, + ]), + ).toEqual([ + ["A", "C"], + ["C", "E"], + ["E", "E"], + ]); + }); + + it("lets a plain top screen drive the last overlay", () => { + const scenes = [createScene("A", OverlayA), createScene("B")]; + const transitions = getFloatOverlayTransitions( + getFloatOverlayStack(scenes, true), + scenes, + ); + + expect(transitions[0]?.driverScene.route.key).toBe("B"); + }); + + it("when B has no overlay, keeps A as the active overlay", () => { + const scenes = [createScene("A", OverlayA), createScene("B")]; + + expect(getFloatOverlayStack(scenes, true)).toEqual([ + { scene: scenes[0], overlayIndex: 0, activity: "active" }, + ]); + }); + + it("when B starts an overlay, keeps A visible and makes B active", () => { + const scenes = [createScene("A", OverlayA), createScene("B", OverlayB)]; + + expect(getFloatOverlayStack(scenes, true)).toEqual([ + { scene: scenes[0], overlayIndex: 0, activity: "inert" }, + { scene: scenes[1], overlayIndex: 1, activity: "active" }, + ]); + }); + + it("when C starts an overlay, hides A and keeps B visible underneath C", () => { + const scenes = [ + createScene("A", OverlayA), + createScene("B", OverlayB), + createScene("C", OverlayC), + ]; + + expect(getFloatOverlayStack(scenes, true)).toEqual([ + { scene: scenes[0], overlayIndex: 0, activity: "inactive" }, + { scene: scenes[1], overlayIndex: 1, activity: "inert" }, + { scene: scenes[2], overlayIndex: 2, activity: "active" }, + ]); + }); + + it("when C closes back to B, keeps C above B until C leaves", () => { + const scenes = [ + createScene("A", OverlayA), + createScene("B", OverlayB), + createScene("C", OverlayC), + ]; + scenes[2].activity = "closing"; + + expect(getFloatOverlayStack(scenes, true)).toEqual([ + { scene: scenes[0], overlayIndex: 0, activity: "inactive" }, + { scene: scenes[1], overlayIndex: 1, activity: "inert" }, + { scene: scenes[2], overlayIndex: 2, activity: "closing" }, + ]); + }); + + it("with A through E overlays, keeps D visible underneath E", () => { + const scenes = [ + createScene("A", OverlayA), + createScene("B", OverlayB), + createScene("C", OverlayC), + createScene("D", OverlayD), + createScene("E", OverlayE), + ]; + + expect(getFloatOverlayStack(scenes, true)).toEqual([ + { scene: scenes[0], overlayIndex: 0, activity: "inactive" }, + { scene: scenes[1], overlayIndex: 1, activity: "inactive" }, + { scene: scenes[2], overlayIndex: 2, activity: "inactive" }, + { scene: scenes[3], overlayIndex: 3, activity: "inert" }, + { scene: scenes[4], overlayIndex: 4, activity: "active" }, + ]); + }); +}); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-host-lifecycle.test.tsx b/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-host-lifecycle.test.tsx new file mode 100644 index 00000000..49a9672a --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-host-lifecycle.test.tsx @@ -0,0 +1,256 @@ +import { describe, expect, it, mock } from "bun:test"; +import { + createContext, + type ComponentType, + type ReactNode, + useContext, + useEffect, + useState, +} from "react"; +import { + act, + create, + type ReactTestRenderer, +} from "react-test-renderer"; +import { View } from "react-native"; +import type { ScreenAnimationContextValue } from "../../providers/screen/animation/animation.provider"; +import type { ScreenSlotContextValue } from "../../providers/screen/styles/slot.provider"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + +const animationStores = new Map(); +const slotStores = new Map(); +const NavigationContext = createContext(undefined); +const NavigationRouteContext = createContext< + { key: string; name: string } | undefined +>(undefined); +let stackState: { + scenes: unknown[]; + focusedIndex: number; + routeKeys: string[]; + routes: Array<{ key: string; name: string }>; +}; + +const useScreenAnimationStore = (key: string) => + animationStores.get(key) ?? null; + +const useScreenSlots = ( + key: string, + selector?: (store: ScreenSlotContextValue) => Selected, +) => { + const store = slotStores.get(key); + if (!store) { + return null; + } + + return selector ? selector(store) : store; +}; + +mock.module("../../hooks/navigation/use-stack", () => ({ + useStack: () => stackState, +})); + +mock.module("@react-navigation/native", () => ({ + NavigationContext, + NavigationRouteContext, + useRoute: () => { + const route = useContext(NavigationRouteContext); + if (!route) { + throw new Error("Navigation route was not provided"); + } + return route; + }, +})); + +mock.module("../../providers/screen/animation/animation.provider", () => ({ + useScreenAnimationStore, +})); + +mock.module("../../providers/screen/animation", () => ({ + useScreenAnimationStore, +})); + +mock.module("../../providers/screen/styles/slot.provider", () => ({ + useScreenSlots, +})); + +mock.module("../../components/overlay/hooks/use-overlay-slot", () => ({ + useOverlaySlot: () => ({ animatedProps: {}, animatedStyle: {} }), +})); + +const { OverlayHost } = await import( + "../../components/overlay/variations/overlay-host" +); +const { useScreenAnimation } = await import( + "../../providers/screen/animation/use-screen-animation" +); +const { useSlotStyles } = await import( + "../../providers/screen/styles/hooks/slot-resolvers" +); +const Probe = View as ComponentType>; + +const shared = (value: T) => ({ + value, + get: () => value, + set: () => {}, + modify: () => {}, +}); + +const createAnimationStore = (routeKey: string) => + ({ + screenInterpolatorProps: shared({ + current: { + route: { key: routeKey, name: routeKey }, + layouts: { screen: { width: 390, height: 844 } }, + }, + stackProgress: 1, + }), + screenInterpolatorPropsRevision: shared(0), + ancestorScreenAnimationSources: [], + descendantScreenAnimationSources: shared([]), + }) as unknown as ScreenAnimationContextValue; + +const createSlotStore = (opacity = 0.5) => + ({ + interpolatorReady: shared(1), + slotsMap: shared({ + "overlay-probe": { + style: { opacity }, + }, + }), + visibilityBlocked: shared(false), + }) as unknown as ScreenSlotContextValue; + +const createScene = ( + key: string, + overlay?: (props: never) => ReactNode, +) => { + const route = { key, name: key }; + return { + route, + activity: "active", + descriptor: { + route, + navigation: {}, + options: { overlay }, + }, + }; +}; + +describe("OverlayHost lifecycle", () => { + it("keeps the owner component mounted while its driver registers", () => { + animationStores.clear(); + slotStores.clear(); + + let mounts = 0; + let unmounts = 0; + let stateInitializations = 0; + + function StatefulOverlay() { + const [identity] = useState(() => { + stateInitializations += 1; + return stateInitializations; + }); + const animation = useScreenAnimation(); + const animationRouteKey = animation.get().current.route.key; + const overlayStyle = useSlotStyles("overlay-probe"); + + useEffect(() => { + mounts += 1; + return () => { + unmounts += 1; + }; + }, []); + + return ( + + ); + } + + const sceneA = createScene("A", StatefulOverlay as never); + const sceneB = createScene("B"); + animationStores.set("A", createAnimationStore("A")); + slotStores.set("A", createSlotStore()); + stackState = { + scenes: [sceneA], + focusedIndex: 0, + routeKeys: ["A"], + routes: [sceneA.route], + }; + + let renderer: ReactTestRenderer; + act(() => { + renderer = create( + , + ); + }); + + const initialProbe = renderer!.root.findByProps({ + testID: "overlay-probe", + }); + expect(initialProbe.props.animationRouteKey).toBe("A"); + expect(initialProbe.props.overlayIdentity).toBe(1); + expect(initialProbe.props.style).toEqual({ opacity: 0.5 }); + expect(mounts).toBe(1); + + stackState = { + scenes: [sceneA, sceneB], + focusedIndex: 1, + routeKeys: ["A", "B"], + routes: [sceneA.route, sceneB.route], + }; + act(() => { + renderer!.update( + , + ); + }); + + expect(mounts).toBe(1); + expect(unmounts).toBe(0); + expect( + renderer!.root.findByProps({ testID: "overlay-probe" }).props + .overlayIdentity, + ).toBe(1); + + animationStores.set("B", createAnimationStore("B")); + slotStores.set("B", createSlotStore(0.9)); + act(() => { + renderer!.update( + , + ); + }); + + const registeredProbe = renderer!.root.findByProps({ + testID: "overlay-probe", + }); + expect(registeredProbe.props.animationRouteKey).toBe("A"); + expect(registeredProbe.props.overlayIdentity).toBe(1); + expect(registeredProbe.props.style).toEqual({ opacity: 0.5 }); + expect(mounts).toBe(1); + expect(unmounts).toBe(0); + + act(() => renderer!.unmount()); + expect(unmounts).toBe(1); + }); +}); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-interpolator-frame.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-interpolator-frame.test.ts new file mode 100644 index 00000000..b01a2f00 --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-interpolator-frame.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "bun:test"; +import { createOverlayInterpolatorFrame } from "../../components/overlay/helpers/create-overlay-interpolator-frame"; +import type { ScreenInterpolatorFrame } from "../../providers/screen/animation/helpers/pipeline"; + +const createFrame = (key: string, progress: number) => + ({ + current: { + route: { key }, + progress, + transitionProgress: progress, + settled: progress === 1 ? 1 : 0, + layouts: { screen: { width: 390, height: 844 } }, + }, + insets: { top: 0, right: 0, bottom: 0, left: 0 }, + }) as ScreenInterpolatorFrame; + +describe("overlay interpolator frame", () => { + it("presents sparse overlays as adjacent to the destination interpolator", () => { + const frame = createOverlayInterpolatorFrame({ + overlayFrame: createFrame("A", 1), + driverFrame: createFrame("C", 0.25), + }); + + expect(frame.current.route.key).toBe("A"); + expect(frame.next?.route.key).toBe("C"); + expect(frame.progress).toBe(1.25); + expect(frame.active.route.key).toBe("C"); + expect(frame.inactive?.route.key).toBe("A"); + }); + + it("presents the destination overlay as the focused current overlay", () => { + const overlayFrame = createFrame("C", 0.25); + const frame = createOverlayInterpolatorFrame({ + overlayFrame, + driverFrame: overlayFrame, + previousOverlayFrame: createFrame("A", 1), + }); + + expect(frame.previous?.route.key).toBe("A"); + expect(frame.current.route.key).toBe("C"); + expect(frame.next).toBeUndefined(); + expect(frame.progress).toBe(0.25); + expect(frame.focused).toBe(true); + }); +}); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-resource-retention.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-resource-retention.test.ts new file mode 100644 index 00000000..2771c06a --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-resource-retention.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "bun:test"; +import type { FloatOverlayEntry } from "../../components/overlay/helpers/get-active-overlay"; +import { retainReadyOverlayResources } from "../../components/overlay/helpers/retain-ready-overlay-resources"; +import type { ScreenAnimationContextValue } from "../../providers/screen/animation/animation.provider"; +import type { ScreenSlotContextValue } from "../../providers/screen/styles/slot.provider"; + +const createScene = (key: string) => + ({ route: { key, name: key } }) as FloatOverlayEntry["scene"]; + +describe("overlay resource retention", () => { + it("keeps a mounted overlay alive while the incoming driver registers", () => { + const sceneA = createScene("A"); + const sceneB = createScene("B"); + const animationA = {} as ScreenAnimationContextValue; + const animationB = {} as ScreenAnimationContextValue; + const slotsA = {} as ScreenSlotContextValue; + const slotsB = {} as ScreenSlotContextValue; + const mounted = retainReadyOverlayResources( + null, + animationA, + sceneA, + animationA, + slotsA, + ); + + const whileBRegisters = retainReadyOverlayResources( + mounted, + animationA, + sceneB, + null, + null, + ); + const drivenByB = retainReadyOverlayResources( + whileBRegisters, + animationA, + sceneB, + animationB, + slotsB, + ); + + expect(whileBRegisters).toBe(mounted); + expect(drivenByB).toEqual({ + overlayAnimationStore: animationA, + driverScene: sceneB, + driverAnimationStore: animationB, + driverSlots: slotsB, + }); + }); + + it("never drops mounted resources during a transient owner-store gap", () => { + const scene = createScene("A"); + const animation = {} as ScreenAnimationContextValue; + const slots = {} as ScreenSlotContextValue; + const mounted = retainReadyOverlayResources( + null, + animation, + scene, + animation, + slots, + ); + + expect( + retainReadyOverlayResources(mounted, null, scene, animation, slots), + ).toBe(mounted); + }); + + it("waits for complete resources before the first mount", () => { + expect( + retainReadyOverlayResources( + null, + null, + createScene("A"), + null, + null, + ), + ).toBeNull(); + }); +}); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-slot-interpolator.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-slot-interpolator.test.ts new file mode 100644 index 00000000..703d1bfe --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/__tests__/overlay/overlay-slot-interpolator.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test"; +import { runOverlaySlotInterpolator } from "../../components/overlay/helpers/run-overlay-slot-interpolator"; +import type { ScreenInterpolatorFrame } from "../../providers/screen/animation/helpers/pipeline"; +import type { ScreenTransitionAccessor } from "../../types/animation.types"; + +describe("overlay slot interpolator", () => { + it("runs the destination interpolator against overlay adjacency", () => { + const frame = { + current: { route: { key: "A" }, progress: 1 }, + next: { route: { key: "C" }, progress: 0.25 }, + progress: 1.25, + transitionProgress: 1.25, + focused: false, + active: { route: { key: "C" }, progress: 0.25 }, + inactive: { route: { key: "A" }, progress: 1 }, + } as ScreenInterpolatorFrame; + + const slot = runOverlaySlotInterpolator({ + frame, + interpolator: ({ current, next, progress }) => { + "worklet"; + return { + overlay: { + transform: [ + { + translateX: + current.route.key === "A" && next?.route.key === "C" + ? progress + : -1, + }, + ], + }, + }; + }, + transition: (() => null) as ScreenTransitionAccessor, + }); + + expect(slot?.style).toEqual({ transform: [{ translateX: 1.25 }] }); + }); + + it("leaves an overlay unchanged when the driver has no overlay slot", () => { + const frame = { + current: { route: { key: "A" }, progress: 1 }, + next: { route: { key: "B" }, progress: 0.25 }, + } as ScreenInterpolatorFrame; + + expect( + runOverlaySlotInterpolator({ + frame, + interpolator: () => { + "worklet"; + return { content: { opacity: 0.5 } }; + }, + transition: (() => null) as ScreenTransitionAccessor, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/portal/active-handoff-receiver.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/portal/active-handoff-receiver.test.ts index bd2b5e74..7358331e 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/portal/active-handoff-receiver.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/portal/active-handoff-receiver.test.ts @@ -91,6 +91,20 @@ describe("active handoff receiver", () => { ).toBe("player-b"); }); + it("keeps a queued receiver detached until its interpolator is ready", () => { + expect( + resolveHandoffAttachmentCandidate({ + activeReceiverClosing: true, + activeReceiverScreenKey: "player-a", + attachedReceiverScreenKey: "index", + hasActiveCloseFinished: false, + interpolatorReady: false, + pairDestinationScreenKey: "player-b", + previousReceiverScreenKey: "index", + }), + ).toBe("player-a"); + }); + it("moves directly to a newly queued receiver while the current receiver closes", () => { const params = { activeReceiverClosing: true, diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/presented-focus.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/presented-focus.test.ts new file mode 100644 index 00000000..2fba0748 --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/__tests__/presented-focus.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "bun:test"; +import { resolvePresentedIndex } from "../providers/stack/blank-stack-state/helpers/resolve-presented-index"; +import type { BaseStackRoute } from "../types/stack.types"; + +const route = (key: string): BaseStackRoute => ({ key, name: key }); + +describe("presented stack focus", () => { + it("keeps the current route focused while dismissal is undecided", () => { + const routes = [route("welcome"), route("name")]; + + expect(resolvePresentedIndex(routes, "name", new Set())).toBe(1); + }); + + it("focuses the previous route after dismissal commits", () => { + const routes = [route("welcome"), route("name")]; + + expect(resolvePresentedIndex(routes, "name", new Set(["name"]))).toBe(0); + }); + + it("skips multiple committed closing routes", () => { + const routes = [route("A"), route("B"), route("C")]; + + expect(resolvePresentedIndex(routes, "C", new Set(["B", "C"]))).toBe(0); + }); +}); diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/soft-dismiss.test.tsx b/packages/react-native-screen-transitions/src/shared/__tests__/soft-dismiss.test.tsx index 94aad48a..a31c5f24 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/soft-dismiss.test.tsx +++ b/packages/react-native-screen-transitions/src/shared/__tests__/soft-dismiss.test.tsx @@ -10,6 +10,10 @@ import { StackType } from "../types/stack.types"; import { isCloseActionReplay } from "../utils/navigation/close-action-replay"; const route = { key: "soft-dismiss-route", name: "details" }; +const NavigationContext = React.createContext(undefined); +const NavigationRouteContext = React.createContext< + { key: string; name: string } | undefined +>(undefined); const current = { route, options: {}, @@ -53,10 +57,19 @@ let stackType: StackType; let preventedRoutes: Record; mock.module("@react-navigation/native", () => ({ + NavigationContext, + NavigationRouteContext, StackActions: { pop: () => ({ type: "POP" }), }, usePreventRemoveContext: () => ({ preventedRoutes }), + useRoute: () => { + const route = React.useContext(NavigationRouteContext); + if (!route) { + throw new Error("Navigation route was not provided"); + } + return route; + }, })); mock.module("../providers/screen/descriptors", () => ({ @@ -233,6 +246,33 @@ describe("soft dismissal", () => { ).toBe(LifecycleTransitionRequestKind.None); }); + it("lets blank stack commit a programmatic removal immediately", () => { + let prevented = false; + + const Harness = () => { + useCloseTransitionIntent(current as any); + return null; + }; + + act(() => { + create(React.createElement(Harness)); + }); + act(() => { + beforeRemoveListener?.({ + data: { action: { type: "POP" } }, + preventDefault: () => { + prevented = true; + }, + }); + }); + + expect(prevented).toBe(false); + expect(softDismissCount).toBe(0); + expect( + SystemStore.getBag(route.key).pendingLifecycleRequestKind.get(), + ).toBe(LifecycleTransitionRequestKind.None); + }); + it("uses the current native stack for terminal gesture removal", () => { let completeClose: (() => void) | undefined; stackType = StackType.NATIVE; @@ -273,7 +313,9 @@ describe("soft dismissal", () => { ]); }); - it("converts a programmatic removal into a soft dismiss", () => { + it("converts a native programmatic removal into a soft dismiss", () => { + stackType = StackType.NATIVE; + requestStackDismiss = null; const action = { type: "POP", payload: { count: 1 } }; let prevented = false; let completeClose: (() => void) | undefined; @@ -296,7 +338,7 @@ describe("soft dismissal", () => { }); expect(prevented).toBe(true); - expect(softDismissCount).toBe(1); + expect(softDismissCount).toBe(0); expect(dispatchedActions).toEqual([]); expect( SystemStore.getBag(route.key).pendingLifecycleRequestKind.get(), diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/styles/resolve-slot-styles.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/styles/resolve-slot-styles.test.ts index 04b8813c..4f5d8a65 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/styles/resolve-slot-styles.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/styles/resolve-slot-styles.test.ts @@ -128,6 +128,11 @@ describe("resolveSlotStyles", () => { }, ], ancestorStylesMap: { + overlay: { + style: { + opacity: 0.1, + }, + }, card: { style: { opacity: 0.2, @@ -153,10 +158,12 @@ describe("resolveSlotStyles", () => { opacity: 0.7, }); expect(result.resolvedStylesMap.content).toBeUndefined(); + expect(result.resolvedStylesMap.overlay).toBeUndefined(); }); describe("reserved slots", () => { const reservedSlotIds = [ + "overlay", "content", "backdrop", "surface", diff --git a/packages/react-native-screen-transitions/src/shared/__tests__/styles/transition-visual-state.test.ts b/packages/react-native-screen-transitions/src/shared/__tests__/styles/transition-visual-state.test.ts index ea1566cd..9879ba48 100644 --- a/packages/react-native-screen-transitions/src/shared/__tests__/styles/transition-visual-state.test.ts +++ b/packages/react-native-screen-transitions/src/shared/__tests__/styles/transition-visual-state.test.ts @@ -3,6 +3,7 @@ import { hasCloseTransitionFinished, hasOpenTransitionStarted, isOpenTransitionBlocked, + isScreenInterpolatorReady, } from "../../providers/screen/styles/helpers/transition-visual-state"; describe("transition visual state", () => { @@ -88,4 +89,33 @@ describe("transition visual state", () => { }), ).toBe(false); }); + + it("reports readiness for the current screen's interpolator", () => { + const settled = { + hasInterpolator: true, + opening: false, + closing: 0, + pendingLifecycleStartBlockCount: 0, + animationProgress: 1, + }; + + expect(isScreenInterpolatorReady(settled)).toBe(true); + expect( + isScreenInterpolatorReady({ ...settled, hasInterpolator: false }), + ).toBe(false); + expect( + isScreenInterpolatorReady({ + ...settled, + opening: true, + animationProgress: 0, + }), + ).toBe(false); + expect( + isScreenInterpolatorReady({ + ...settled, + closing: 1, + animationProgress: 0, + }), + ).toBe(false); + }); }); diff --git a/packages/react-native-screen-transitions/src/shared/adapters/with-screen-transitions/stack-layout.tsx b/packages/react-native-screen-transitions/src/shared/adapters/with-screen-transitions/stack-layout.tsx index 696dc5fe..b30298de 100644 --- a/packages/react-native-screen-transitions/src/shared/adapters/with-screen-transitions/stack-layout.tsx +++ b/packages/react-native-screen-transitions/src/shared/adapters/with-screen-transitions/stack-layout.tsx @@ -182,7 +182,7 @@ function ScreenTransitionsStackContent({ return ( - {transitionState.shouldShowFloatOverlay ? : null} + {children} diff --git a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-content-portal/hooks/use-boundary-content-portal-attachment.ts b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-content-portal/hooks/use-boundary-content-portal-attachment.ts index 3e8ccc42..8ae289c9 100644 --- a/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-content-portal/hooks/use-boundary-content-portal-attachment.ts +++ b/packages/react-native-screen-transitions/src/shared/components/boundary/portal/components/boundary-content-portal/hooks/use-boundary-content-portal-attachment.ts @@ -24,12 +24,17 @@ interface UseBoundaryContentPortalAttachmentParams { export const useBoundaryContentPortalAttachment = ({ boundaryId, }: UseBoundaryContentPortalAttachmentParams) => { - const { nextInterpolatorReady, slotsMap } = useScreenSlots(); + const { slotsMap } = useScreenSlots(); const currentScreenKey = useDescriptorsStore( (s) => s.derivations.currentScreenKey, ); + const nextScreenKey = useDescriptorsStore((s) => s.derivations.nextScreenKey); const sourcePairKey = useDescriptorsStore((s) => s.derivations.sourcePairKey); + const destinationSlots = useScreenSlots(nextScreenKey ?? currentScreenKey); + const unavailableInterpolatorReady = useSharedValue(0); + const interpolatorReady = + destinationSlots?.interpolatorReady ?? unavailableInterpolatorReady; const activeReceiverScreenKey = useStack(resolveActiveHandoffReceiver); const previousReceiverScreenKey = useStack(resolvePreviousHandoffReceiver); @@ -85,7 +90,7 @@ export const useBoundaryContentPortalAttachment = ({ ? link.destination.screenKey : null; - const interpolatorReady = nextInterpolatorReady.get(); + const isInterpolatorReady = interpolatorReady.get(); const attachedScreenKey = attachedReceiverScreenKey.get(); const nextReceiverScreenKey = resolveHandoffAttachmentCandidate({ @@ -93,7 +98,7 @@ export const useBoundaryContentPortalAttachment = ({ activeReceiverScreenKey, attachedReceiverScreenKey: attachedScreenKey, hasActiveCloseFinished, - interpolatorReady: !!interpolatorReady, + interpolatorReady: !!isInterpolatorReady, pairChangedDuringClose, pairDestinationScreenKey: pairDestination, previousReceiverScreenKey, @@ -110,7 +115,7 @@ export const useBoundaryContentPortalAttachment = ({ const activatingPairDestination = !!pairDestination && - !!interpolatorReady && + !!isInterpolatorReady && nextReceiverScreenKey === pairDestination; const canActivateReceiver = diff --git a/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/create-overlay-interpolator-frame.ts b/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/create-overlay-interpolator-frame.ts new file mode 100644 index 00000000..51b0f368 --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/create-overlay-interpolator-frame.ts @@ -0,0 +1,29 @@ +import { updateDerivations } from "../../../providers/screen/animation/helpers/derivations"; +import type { ScreenInterpolatorFrame } from "../../../providers/screen/animation/helpers/pipeline"; + +export const createOverlayInterpolatorFrame = ({ + overlayFrame, + driverFrame, + previousOverlayFrame, +}: { + overlayFrame: ScreenInterpolatorFrame; + driverFrame: ScreenInterpolatorFrame; + previousOverlayFrame?: ScreenInterpolatorFrame; +}): ScreenInterpolatorFrame => { + "worklet"; + const current = overlayFrame.current; + const driverIsCurrent = driverFrame.current.route.key === current.route.key; + const frame: ScreenInterpolatorFrame = { + ...overlayFrame, + previous: previousOverlayFrame?.current, + current, + next: driverIsCurrent ? undefined : driverFrame.current, + layouts: current.layouts, + }; + + updateDerivations(frame); + frame.stackProgress = frame.progress; + frame.logicallySettled = frame.active.settled; + + return frame; +}; diff --git a/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/get-active-overlay.ts b/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/get-active-overlay.ts index 7bb7ee59..03fa85c5 100644 --- a/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/get-active-overlay.ts +++ b/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/get-active-overlay.ts @@ -1,39 +1,81 @@ import type { StackScene } from "../../../hooks/navigation/use-stack"; import { isOverlayVisible } from "../../../utils/overlay/visibility"; +export type FloatOverlayActivity = "active" | "inert" | "inactive" | "closing"; + +export type FloatOverlayEntry = { + scene: StackScene; + overlayIndex: number; + activity: FloatOverlayActivity; +}; + +export type FloatOverlayTransitionEntry = FloatOverlayEntry & { + driverScene: StackScene; +}; + +type OverlayCandidate = Omit; + +const resolveOverlayActivity = ( + scene: StackScene, + distanceFromTop: number, +): FloatOverlayActivity => { + if (distanceFromTop === 0 && scene.activity === "closing") { + return "closing"; + } + + if (distanceFromTop === 0) { + return "active"; + } + + if (distanceFromTop === 1) { + return "inert"; + } + + return "inactive"; +}; + /** - * Find the active float overlay from scenes. - * Scans from the top of the stack downward to find the first screen - * with a visible overlay. + * Finds every visible floating-overlay checkpoint in stack order. + * + * The newest checkpoint owns the active overlay. Its immediate predecessor + * remains visible and inert; all older checkpoints are retained but inactive. + * This mirrors the screen activity window without making plain screens create + * a new overlay checkpoint. */ -export function getActiveFloatOverlay( +export function getFloatOverlayStack( scenes: StackScene[], - index: number, transitionsAlwaysOn: boolean, -): { scene: StackScene; overlayIndex: number } | null { - if (scenes.length === 0) { - return null; - } - - // When navigating back, closing scenes are kept at the top of the local stack - // while the focused index already points to the destination screen. We need to - // start scanning from the actual top of the stack so the overlay can animate - // out together with its closing screen instead of disappearing immediately. - const startIndex = Math.max(index, scenes.length - 1); +): FloatOverlayEntry[] { + const candidates: OverlayCandidate[] = []; - for (let i = startIndex; i >= 0; i--) { - const scene = scenes[i]; + for (let index = 0; index < scenes.length; index += 1) { + const scene = scenes[index]; const options = scene?.descriptor?.options; - // Skip screens without enableTransitions (native-stack only) if (!transitionsAlwaysOn && !options?.enableTransitions) { continue; } if (isOverlayVisible(options)) { - return { scene, overlayIndex: i }; + candidates.push({ scene, overlayIndex: index }); } } - return null; + const topIndex = candidates.length - 1; + return candidates.map((candidate, index) => ({ + ...candidate, + activity: resolveOverlayActivity(candidate.scene, topIndex - index), + })); +} + +export function getFloatOverlayTransitions( + overlayStack: FloatOverlayEntry[], + scenes: StackScene[], +): FloatOverlayTransitionEntry[] { + const topScene = scenes[scenes.length - 1]; + + return overlayStack.map((entry, index) => ({ + ...entry, + driverScene: overlayStack[index + 1]?.scene ?? topScene ?? entry.scene, + })); } diff --git a/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/retain-ready-overlay-resources.ts b/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/retain-ready-overlay-resources.ts new file mode 100644 index 00000000..43a67831 --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/retain-ready-overlay-resources.ts @@ -0,0 +1,38 @@ +import type { ScreenAnimationContextValue } from "../../../providers/screen/animation/animation.provider"; +import type { ScreenSlotContextValue } from "../../../providers/screen/styles/slot.provider"; +import type { FloatOverlayEntry } from "./get-active-overlay"; + +export type ReadyOverlayResources = { + overlayAnimationStore: ScreenAnimationContextValue; + driverScene: FloatOverlayEntry["scene"]; + driverAnimationStore: ScreenAnimationContextValue; + driverSlots: ScreenSlotContextValue; +}; + +export function retainReadyOverlayResources( + previous: ReadyOverlayResources | null, + overlayAnimationStore: ScreenAnimationContextValue | null, + driverScene: FloatOverlayEntry["scene"], + driverAnimationStore: ScreenAnimationContextValue | null, + driverSlots: ScreenSlotContextValue | null, +): ReadyOverlayResources | null { + if (!overlayAnimationStore || !driverAnimationStore || !driverSlots) { + return previous; + } + + if ( + previous?.overlayAnimationStore === overlayAnimationStore && + previous.driverScene === driverScene && + previous.driverAnimationStore === driverAnimationStore && + previous.driverSlots === driverSlots + ) { + return previous; + } + + return { + overlayAnimationStore, + driverScene, + driverAnimationStore, + driverSlots, + }; +} diff --git a/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/run-overlay-slot-interpolator.ts b/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/run-overlay-slot-interpolator.ts new file mode 100644 index 00000000..054559cc --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/components/overlay/helpers/run-overlay-slot-interpolator.ts @@ -0,0 +1,48 @@ +import type { ScreenInterpolatorFrame } from "../../../providers/screen/animation/helpers/pipeline"; +import { createInterpolatorScope } from "../../../providers/screen/styles/helpers/create-interpolator-scope"; +import { normalizeSlots } from "../../../providers/screen/styles/helpers/normalize-slots"; +import { selectInterpolatorFrame } from "../../../providers/screen/styles/helpers/select-interpolator-frame"; +import type { + NormalizedTransitionSlotStyle, + ScreenStyleInterpolator, + ScreenTransitionAccessor, +} from "../../../types/animation.types"; +import { logger } from "../../../utils/logger"; + +export const runOverlaySlotInterpolator = ({ + frame, + interpolator, + transition, +}: { + frame: ScreenInterpolatorFrame; + interpolator: ScreenStyleInterpolator | undefined; + transition: ScreenTransitionAccessor; +}): NormalizedTransitionSlotStyle | undefined => { + "worklet"; + + if (!interpolator) { + return undefined; + } + + try { + const raw = interpolator( + createInterpolatorScope({ + frame, + selectedFrame: selectInterpolatorFrame(frame, false), + transition, + }), + ); + + if (!raw?.overlay) { + return undefined; + } + + return normalizeSlots({ overlay: raw.overlay }).overlay; + } catch (_) { + if (__DEV__) { + logger.warn("screenStyleInterpolator must be a worklet"); + } + + return undefined; + } +}; diff --git a/packages/react-native-screen-transitions/src/shared/components/overlay/hooks/use-overlay-slot.ts b/packages/react-native-screen-transitions/src/shared/components/overlay/hooks/use-overlay-slot.ts new file mode 100644 index 00000000..f50ff030 --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/components/overlay/hooks/use-overlay-slot.ts @@ -0,0 +1,103 @@ +import { useMemo } from "react"; +import { useWindowDimensions } from "react-native"; +import { + type SharedValue, + useAnimatedProps, + useAnimatedStyle, + useDerivedValue, +} from "react-native-reanimated"; +import { NO_PROPS, NO_STYLES } from "../../../constants"; +import type { ScreenAnimationContextValue } from "../../../providers/screen/animation/animation.provider"; +import { useBuildTransitionAccessor } from "../../../providers/screen/animation/helpers/accessors/use-build-transition-accessor"; +import { collectInterpolatorSharedValues } from "../../../providers/screen/styles/helpers/collect-interpolator-shared-values"; +import type { + NormalizedTransitionSlotStyle, + ScreenStyleInterpolator, +} from "../../../types/animation.types"; +import { getVisibilityBlockOffset } from "../../../utils/visibility-block-offset"; +import { createOverlayInterpolatorFrame } from "../helpers/create-overlay-interpolator-frame"; +import { runOverlaySlotInterpolator } from "../helpers/run-overlay-slot-interpolator"; + +export const useOverlaySlot = ({ + overlayAnimationStore, + driverAnimationStore, + previousOverlayAnimationStore, + driverInterpolator, + interpolatorReady, + isIncoming, +}: { + overlayAnimationStore: ScreenAnimationContextValue; + driverAnimationStore: ScreenAnimationContextValue; + previousOverlayAnimationStore?: ScreenAnimationContextValue; + driverInterpolator: ScreenStyleInterpolator | undefined; + interpolatorReady: SharedValue; + isIncoming: boolean; +}) => { + const { height } = useWindowDimensions(); + const transition = useBuildTransitionAccessor(driverAnimationStore); + + const interpolatorSharedValues = useMemo( + () => collectInterpolatorSharedValues([driverInterpolator]), + [driverInterpolator], + ); + + const overlaySlot = useDerivedValue< + NormalizedTransitionSlotStyle | undefined + >(() => { + "worklet"; + overlayAnimationStore.screenInterpolatorPropsRevision.get(); + driverAnimationStore.screenInterpolatorPropsRevision.get(); + previousOverlayAnimationStore?.screenInterpolatorPropsRevision.get(); + + for (let index = 0; index < interpolatorSharedValues.length; index++) { + interpolatorSharedValues[index]?.get(); + } + + const frame = createOverlayInterpolatorFrame({ + overlayFrame: overlayAnimationStore.screenInterpolatorProps.get(), + driverFrame: driverAnimationStore.screenInterpolatorProps.get(), + previousOverlayFrame: + previousOverlayAnimationStore?.screenInterpolatorProps.get(), + }); + + return runOverlaySlotInterpolator({ + frame, + interpolator: driverInterpolator, + transition, + }); + }); + + const animatedStyle = useAnimatedStyle(() => { + "worklet"; + const slot = overlaySlot.get(); + if (!slot) { + return NO_STYLES; + } + + if (isIncoming && !interpolatorReady.get()) { + return { + transform: [{ translateY: getVisibilityBlockOffset(height) }], + }; + } + + return slot.style ?? NO_STYLES; + }); + + const animatedProps = useAnimatedProps(() => { + "worklet"; + const slotProps = overlaySlot.get()?.props; + if (!slotProps) { + return NO_PROPS; + } + + const { + pointerEvents: _pointerEvents, + teleport: _teleport, + ...props + } = slotProps; + + return props; + }); + + return { animatedProps, animatedStyle }; +}; diff --git a/packages/react-native-screen-transitions/src/shared/components/overlay/variations/float-overlay.tsx b/packages/react-native-screen-transitions/src/shared/components/overlay/variations/float-overlay.tsx index 3311ae14..21ad10ba 100644 --- a/packages/react-native-screen-transitions/src/shared/components/overlay/variations/float-overlay.tsx +++ b/packages/react-native-screen-transitions/src/shared/components/overlay/variations/float-overlay.tsx @@ -1,14 +1,9 @@ -import { useMemo } from "react"; -import { snapDescriptorToIndex } from "../../../animation/snap-to"; import { useStack } from "../../../hooks/navigation/use-stack"; -import { ScreenAnimationProvider } from "../../../providers/screen/animation"; -import type { BaseDescriptor } from "../../../providers/screen/descriptors"; -import { DescriptorsProvider } from "../../../providers/screen/descriptors"; -import { ScreenOptionsProvider } from "../../../providers/screen/options"; -import { ScreenSlotProvider } from "../../../providers/screen/styles"; -import type { OverlayScreenState } from "../../../types/overlay.types"; -import { getActiveFloatOverlay } from "../helpers/get-active-overlay"; +import { + getFloatOverlayStack, + getFloatOverlayTransitions, +} from "../helpers/get-active-overlay"; import { OverlayHost } from "./overlay-host"; /** @@ -16,65 +11,28 @@ import { OverlayHost } from "./overlay-host"; * Gets routes and descriptors from stack context. */ export function FloatOverlay() { - const { scenes, focusedIndex, flags, routes, routeKeys } = useStack(); + const { scenes, flags } = useStack(); - const activeOverlay = useMemo( - () => - getActiveFloatOverlay(scenes, focusedIndex, flags.TRANSITIONS_ALWAYS_ON), - [scenes, focusedIndex, flags.TRANSITIONS_ALWAYS_ON], + const overlayStack = getFloatOverlayStack( + scenes, + flags.TRANSITIONS_ALWAYS_ON, ); - - const overlayData = useMemo(() => { - if (!activeOverlay) return null; - - const { scene, overlayIndex } = activeOverlay; - const previous = scenes[overlayIndex - 1]?.descriptor; - const current = scene.descriptor; - const next = scenes[overlayIndex + 1]?.descriptor; - const focusedScene = scenes[focusedIndex] ?? scenes[scenes.length - 1]; - const focusedDescriptor = focusedScene?.descriptor; - - const overlayScreenState: OverlayScreenState = - { - index: routeKeys.indexOf(current.route.key), - options: focusedDescriptor?.options ?? {}, - routes, - focusedRoute: focusedScene?.route ?? current.route, - focusedIndex, - meta: focusedDescriptor?.options?.meta, - navigation: current.navigation, - snapTo: (index: number) => { - snapDescriptorToIndex(current, index); - }, - }; - - return { - scene, - previous, - current, - next, - overlayScreenState, - }; - }, [activeOverlay, scenes, focusedIndex, routeKeys, routes]); - - if (!overlayData) { + if (overlayStack.length === 0) { return null; } - const { scene, previous, current, next, overlayScreenState } = overlayData; - - return ( - - - - - - - - - + const overlayTransitions = getFloatOverlayTransitions(overlayStack, scenes); + + return overlayTransitions.map( + ({ scene, activity, driverScene }, layerIndex) => ( + + ), ); } diff --git a/packages/react-native-screen-transitions/src/shared/components/overlay/variations/overlay-host.tsx b/packages/react-native-screen-transitions/src/shared/components/overlay/variations/overlay-host.tsx index b861fba0..49466197 100644 --- a/packages/react-native-screen-transitions/src/shared/components/overlay/variations/overlay-host.tsx +++ b/packages/react-native-screen-transitions/src/shared/components/overlay/variations/overlay-host.tsx @@ -2,53 +2,160 @@ import { NavigationContext, NavigationRouteContext, } from "@react-navigation/native"; -import { memo, useMemo } from "react"; -import { Animated, StyleSheet, View } from "react-native"; -import { useDerivedValue } from "react-native-reanimated"; -import type { StackScene } from "../../../hooks/navigation/use-stack"; -import { useScreenAnimation } from "../../../providers/screen/animation"; -import type { BaseDescriptor } from "../../../providers/screen/descriptors"; +import { memo, useMemo, useRef } from "react"; +import { StyleSheet, View } from "react-native"; +import Animated, { useDerivedValue } from "react-native-reanimated"; +import { snapDescriptorToIndex } from "../../../animation/snap-to"; +import { useStack } from "../../../hooks/navigation/use-stack"; +import { useScreenAnimationStore } from "../../../providers/screen/animation"; +import type { ScreenAnimationContextValue } from "../../../providers/screen/animation/animation.provider"; +import { + type ScreenSlotContextValue, + useScreenSlots, +} from "../../../providers/screen/styles/slot.provider"; +import type { OverlayProps } from "../../../types/overlay.types"; import type { - OverlayProps, - OverlayScreenState, -} from "../../../types/overlay.types"; + FloatOverlayActivity, + FloatOverlayEntry, +} from "../helpers/get-active-overlay"; +import { + type ReadyOverlayResources, + retainReadyOverlayResources, +} from "../helpers/retain-ready-overlay-resources"; +import { useOverlaySlot } from "../hooks/use-overlay-slot"; type OverlayHostProps = { - scene: StackScene; - overlayScreenState: OverlayScreenState; + scene: FloatOverlayEntry["scene"]; + driverScene: FloatOverlayEntry["scene"]; + previousOverlayScene?: FloatOverlayEntry["scene"]; + activity: FloatOverlayActivity; + layerIndex: number; }; export const OverlayHost = memo(function OverlayHost({ scene, - overlayScreenState, + driverScene, + previousOverlayScene, + activity, + layerIndex, }: OverlayHostProps) { - const OverlayComponent = scene.descriptor.options.overlay; + const overlayAnimationStore = useScreenAnimationStore(scene.route.key); + const driverAnimationStore = useScreenAnimationStore(driverScene.route.key); + const previousOverlayAnimationStore = useScreenAnimationStore( + previousOverlayScene?.route.key ?? scene.route.key, + ); + const driverSlots = useScreenSlots(driverScene.route.key); + const overlayComponentRef = useRef(scene.descriptor.options.overlay); + const OverlayComponent = overlayComponentRef.current; + const readyResourcesRef = useRef(null); + readyResourcesRef.current = retainReadyOverlayResources( + readyResourcesRef.current, + overlayAnimationStore, + driverScene, + driverAnimationStore, + driverSlots, + ); + const readyResources = readyResourcesRef.current; + + if (!OverlayComponent || !readyResources) { + return null; + } + + return ( + + ); +}); + +type ReadyOverlayHostProps = OverlayHostProps & { + overlayAnimationStore: ScreenAnimationContextValue; + driverAnimationStore: ScreenAnimationContextValue; + previousOverlayAnimationStore?: ScreenAnimationContextValue | null; + driverSlots: ScreenSlotContextValue; + OverlayComponent: NonNullable< + FloatOverlayEntry["scene"]["descriptor"]["options"]["overlay"] + >; +}; - const screenAnimation = useScreenAnimation(); +function ReadyOverlayHost({ + scene, + driverScene, + activity, + layerIndex, + overlayAnimationStore, + driverAnimationStore, + previousOverlayAnimationStore, + driverSlots, + OverlayComponent, +}: ReadyOverlayHostProps) { + const { scenes, focusedIndex, routeKeys, routes } = useStack(); + const descriptor = scene.descriptor; + const focusedScene = scenes[focusedIndex] ?? scenes[scenes.length - 1]; + const focusedDescriptor = focusedScene?.descriptor; + const { animatedProps, animatedStyle } = useOverlaySlot({ + overlayAnimationStore, + driverAnimationStore, + previousOverlayAnimationStore: previousOverlayAnimationStore ?? undefined, + driverInterpolator: driverScene.descriptor.options.screenStyleInterpolator, + interpolatorReady: driverSlots.interpolatorReady, + isIncoming: scene.route.key === driverScene.route.key, + }); const relativeProgress = useDerivedValue(() => { "worklet"; - return screenAnimation.get().stackProgress; + overlayAnimationStore.screenInterpolatorPropsRevision.get(); + return overlayAnimationStore.screenInterpolatorProps.get().stackProgress; }); - const overlayProps: OverlayProps = useMemo( + const overlayProps: OverlayProps = useMemo( () => ({ - ...overlayScreenState, + route: scene.route, + index: routeKeys.indexOf(scene.route.key), + options: focusedDescriptor?.options ?? {}, + routes, + focusedRoute: focusedScene?.route ?? scene.route, + focusedIndex, + meta: focusedDescriptor?.options?.meta, + navigation: scene.descriptor.navigation, + snapTo: (index: number) => { + snapDescriptorToIndex(scene.descriptor, index); + }, progress: relativeProgress, }), - [relativeProgress, overlayScreenState], + [ + scene, + focusedDescriptor?.options, + focusedIndex, + focusedScene?.route, + relativeProgress, + routeKeys, + routes, + ], ); - - if (!OverlayComponent) { - return null; - } - return ( - - + + ); -}); +} const styles = StyleSheet.create({ overlay: { diff --git a/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-close-transition-intent.ts b/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-close-transition-intent.ts index e371e89a..b959fb09 100644 --- a/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-close-transition-intent.ts +++ b/packages/react-native-screen-transitions/src/shared/components/screen-lifecycle/hooks/use-close-transition-intent.ts @@ -105,8 +105,12 @@ export function useCloseTransitionIntent(current: BaseDescriptor): { // biome-ignore lint/correctness/useExhaustiveDependencies: navigation listener should only rebind when the navigator instance changes useLayoutEffect(() => { + if (stackType !== StackType.NATIVE) { + return; + } + return current.navigation.addListener?.("beforeRemove", handleBeforeRemove); - }, [current.navigation]); + }, [current.navigation, stackType]); return { completeClose }; } diff --git a/packages/react-native-screen-transitions/src/shared/index.ts b/packages/react-native-screen-transitions/src/shared/index.ts index b35f856c..9472f79e 100644 --- a/packages/react-native-screen-transitions/src/shared/index.ts +++ b/packages/react-native-screen-transitions/src/shared/index.ts @@ -71,6 +71,7 @@ export type { BoundsNavigationZoomStyle, GestureHandoffValues, InactiveBehavior, + OverlayComponent, OverlayProps, RawGestureValues, ScreenBackdropComponent, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/animation.provider.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/animation.provider.tsx index 2e94d7d2..bc80fb30 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/animation.provider.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/animation.provider.tsx @@ -2,6 +2,7 @@ import { type ReactNode, useCallback, useLayoutEffect, useMemo } from "react"; import { useSharedValue } from "react-native-reanimated"; import { createBoundsAccessor } from "../../../utils/bounds"; import createProvider from "../../../utils/create-provider"; +import { useDescriptorsStore } from "../descriptors"; import { useScreenAnimationPipeline } from "./helpers/pipeline"; import type { RegisterScreenAnimationDescendant, @@ -25,25 +26,36 @@ export type ScreenAnimationContextValue = ReturnType< }; export type ScreenAnimationContextResult = { + key: string; value: ScreenAnimationContextValue; }; export const { ScreenAnimationProvider, useScreenAnimationStore } = createProvider("ScreenAnimation", { guarded: true, + global: true, })( (_props, { useParentStore }): ScreenAnimationContextResult => { - const parentContext = useParentStore(); - const parentScreenInterpolatorProps = - parentContext?.screenInterpolatorProps; - const parentScreenInterpolatorPropsRevision = - parentContext?.screenInterpolatorPropsRevision; - const parentAncestorScreenAnimationSources = - parentContext?.ancestorScreenAnimationSources; - const parentRegisterDescendantScreenAnimationSource = - parentContext?.registerDescendantScreenAnimationSource; - const parentAncestorDescendantScreenAnimationRegistrars = - parentContext?.ancestorDescendantScreenAnimationRegistrars; + const currentScreenKey = useDescriptorsStore( + (store) => store.derivations.currentScreenKey, + ); + const parentScreenInterpolatorProps = useParentStore( + (parentContext) => parentContext?.screenInterpolatorProps, + ); + const parentScreenInterpolatorPropsRevision = useParentStore( + (parentContext) => parentContext?.screenInterpolatorPropsRevision, + ); + const parentAncestorScreenAnimationSources = useParentStore( + (parentContext) => parentContext?.ancestorScreenAnimationSources, + ); + const parentRegisterDescendantScreenAnimationSource = useParentStore( + (parentContext) => + parentContext?.registerDescendantScreenAnimationSource, + ); + const parentAncestorDescendantScreenAnimationRegistrars = useParentStore( + (parentContext) => + parentContext?.ancestorDescendantScreenAnimationRegistrars, + ); const { screenInterpolatorProps, @@ -192,6 +204,7 @@ export const { ScreenAnimationProvider, useScreenAnimationStore } = ]); return { + key: currentScreenKey, value: { screenInterpolatorProps, screenInterpolatorPropsRevision, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/accessors/use-build-transition-accessor.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/accessors/use-build-transition-accessor.ts index 33f5574f..d7d8fc1b 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/accessors/use-build-transition-accessor.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/helpers/accessors/use-build-transition-accessor.ts @@ -4,7 +4,7 @@ import type { ScreenTransitionTarget, } from "../../../../../types/animation.types"; import { createBoundsAccessor } from "../../../../../utils/bounds"; -import { useScreenAnimationStore } from "../../animation.provider"; +import type { ScreenAnimationContextValue } from "../../animation.provider"; import type { ScreenAnimationDescendantSources, ScreenAnimationSource, @@ -15,6 +15,14 @@ type TransitionSourceIndex = number; export type TransitionAccessorSource = ScreenAnimationTransitionSource; +type TransitionAccessorStore = Pick< + ScreenAnimationContextValue, + | "screenInterpolatorProps" + | "screenInterpolatorPropsRevision" + | "ancestorScreenAnimationSources" + | "descendantScreenAnimationSources" +>; + const resolveTargetIndex = ( target: ScreenTransitionTarget | undefined, currentIndex: TransitionSourceIndex, @@ -118,14 +126,12 @@ const buildSourceBoundsAccessor = (source: ScreenAnimationSource) => { }); }; -export const useBuildTransitionAccessor = () => { - const { - screenInterpolatorProps, - screenInterpolatorPropsRevision, - ancestorScreenAnimationSources, - descendantScreenAnimationSources, - } = useScreenAnimationStore(); - +export const useBuildTransitionAccessor = ({ + screenInterpolatorProps, + screenInterpolatorPropsRevision, + ancestorScreenAnimationSources, + descendantScreenAnimationSources, +}: TransitionAccessorStore) => { return useMemo(() => { const selfSource = { screenInterpolatorProps, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/use-screen-animation.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/use-screen-animation.tsx index c00feb97..55e704ec 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/animation/use-screen-animation.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/animation/use-screen-animation.tsx @@ -1,3 +1,4 @@ +import { useRoute } from "@react-navigation/native"; import { type DerivedValue, useDerivedValue } from "react-native-reanimated"; import type { ScreenInterpolationProps, @@ -33,12 +34,21 @@ export function useScreenAnimation( ): | DerivedValue | DerivedValue { + const route = useRoute(); + const screenAnimationStore = useScreenAnimationStore(route.key); + + if (!screenAnimationStore) { + throw new Error( + `ScreenAnimation store for route "${route.key}" was not found`, + ); + } + const { screenInterpolatorPropsRevision, ancestorScreenAnimationSources, descendantScreenAnimationSources, - } = useScreenAnimationStore(); - const transition = useBuildTransitionAccessor(); + } = screenAnimationStore; + const transition = useBuildTransitionAccessor(screenAnimationStore); const transitionTarget = normalizeScreenAnimationTarget( target, ancestorScreenAnimationSources.length, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/gestures.provider.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/gestures.provider.tsx index 4f207aa2..230fa43b 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/gestures.provider.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/gestures.provider.tsx @@ -1,11 +1,11 @@ -import { useMemo } from "react"; +import { Fragment, useMemo } from "react"; import { Gesture } from "react-native-gesture-handler"; import { useSharedValue } from "react-native-reanimated"; import { ScrollStore } from "../../../stores/scroll.store"; import createProvider from "../../../utils/create-provider"; import { useDescriptorsStore } from "../descriptors"; import { useScreenGestureConfig } from "./hooks/use-screen-gesture-config"; -import { useWalkUpAndRegisterShadowingClaims } from "./ownership/use-walk-up-and-register-shadowing-claims"; +import { GestureOwnershipBridge } from "./ownership/gesture-ownership-bridge"; import { useBuildPanGesture } from "./pan/use-build-pan-gesture"; import { useBuildPinchGesture } from "./pinch/use-build-pinch-gesture"; import { @@ -28,11 +28,16 @@ export const { ScreenGestureProvider, useScreenGestureStore: useGestureStore } = { children }, { useParentStore }, ): { value: GestureContextType; children: React.ReactNode } => { - const gestureContext = useParentStore(); - const gestureConfig = useScreenGestureConfig(); const currentScreenKey = useDescriptorsStore( (store) => store.derivations.currentScreenKey, ); + const isTopMostScreen = useDescriptorsStore( + (store) => store.derivations.isTopMostScreen, + ); + const gestureContext = useParentStore((parentContext) => + isTopMostScreen ? parentContext : null, + ); + const gestureConfig = useScreenGestureConfig(gestureContext); const scrollState = ScrollStore.getValue( currentScreenKey, @@ -89,13 +94,19 @@ export const { ScreenGestureProvider, useScreenGestureStore: useGestureStore } = ], ); - useWalkUpAndRegisterShadowingClaims( - gestureConfig.participation.claimedDirections, + const content = useMemo( + () => ( + + + {children} + + ), + [children], ); return { value, - children, + children: content, }; }, ); diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/hooks/use-screen-gesture-config.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/hooks/use-screen-gesture-config.ts index ef6f8b73..f7e359cd 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/hooks/use-screen-gesture-config.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/hooks/use-screen-gesture-config.ts @@ -1,12 +1,12 @@ import { usePreventRemoveContext } from "@react-navigation/native"; import { useMemo } from "react"; import { useDescriptorsStore } from "../../descriptors"; -import { useGestureStore } from "../gestures.provider"; import { resolveScreenGestureConfig } from "../shared/policy"; -import type { ScreenGestureConfig } from "../types"; +import type { GestureContextType, ScreenGestureConfig } from "../types"; -export function useScreenGestureConfig(): ScreenGestureConfig { - const gestureContext = useGestureStore(); +export function useScreenGestureConfig( + gestureContext: GestureContextType | null, +): ScreenGestureConfig { const options = useDescriptorsStore((store) => store.options); const isFirstKey = useDescriptorsStore( (store) => store.derivations.isFirstKey, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/use-walk-up-and-register-shadowing-claims.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/gesture-ownership-bridge.tsx similarity index 65% rename from packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/use-walk-up-and-register-shadowing-claims.ts rename to packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/gesture-ownership-bridge.tsx index 7af2dab0..fac30723 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/use-walk-up-and-register-shadowing-claims.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/gestures/ownership/gesture-ownership-bridge.tsx @@ -30,9 +30,9 @@ const findShadowedDirections = ( ) => { const shadowedDirections: Direction[] = []; - for (const dir of DIRECTIONS) { - if (claimedDirections[dir] && ancestorDirections[dir]) { - shadowedDirections.push(dir); + for (const direction of DIRECTIONS) { + if (claimedDirections[direction] && ancestorDirections[direction]) { + shadowedDirections.push(direction); } } @@ -70,8 +70,8 @@ const registerShadowingClaims = ( for (const { ancestor, directions } of shadowedAncestors) { const newClaims = { ...ancestor.childDirectionClaims.get() }; - for (const dir of directions) { - newClaims[dir] = { routeKey: currentScreenKey, isDismissing }; + for (const direction of directions) { + newClaims[direction] = { routeKey: currentScreenKey, isDismissing }; } ancestor.childDirectionClaims.set(newClaims); } @@ -86,10 +86,9 @@ const clearShadowingClaims = ( const newClaims = { ...currentClaims }; let needsUpdate = false; - for (const dir of directions) { - // Only remove claims written by this screen; a newer descendant may own it now. - if (currentClaims[dir]?.routeKey === currentScreenKey) { - newClaims[dir] = null; + for (const direction of directions) { + if (currentClaims[direction]?.routeKey === currentScreenKey) { + newClaims[direction] = null; needsUpdate = true; } } @@ -121,51 +120,32 @@ const getDescriptorClaimedDirections = ( }).participation.claimedDirections; }; -/** - * Registers this screen with ancestors that claim the same direction. - * - * Example, when C is top-most: - * A claims vertical - * └─ B claims horizontal - * └─ C claims vertical - * - * C walks up the tree and writes itself into A.childDirectionClaims.vertical. - * B is skipped because it does not claim vertical. - */ -export function useWalkUpAndRegisterShadowingClaims( - claimedDirections: ClaimedDirections, -): void { - const parentContext = useGestureStore(); +const requireGestureContext = ( + gestureContext: GestureContextType | null, +): GestureContextType => { + if (!gestureContext) { + throw new Error( + "GestureOwnershipBridge must be rendered within a ScreenGestureProvider", + ); + } + + return gestureContext; +}; + +function ActiveGestureOwnershipBridge() { + const gestureContext = requireGestureContext(useGestureStore()); const previous = useDescriptorsStore((store) => store.previous); - const isTopMostScreen = useDescriptorsStore( - (store) => store.derivations.isTopMostScreen, - ); - const currentScreenKey = useDescriptorsStore( - (store) => store.derivations.currentScreenKey, - ); const isCurrentScreenClosing = useBlankStackStore( - (store) => store?.scenesByKey[currentScreenKey]?.activity === "closing", + (store) => + store?.scenesByKey[gestureContext.routeKey]?.activity === "closing", ); - - /* - * We want to calculate effective claimed directions as claimedDirections is not enough for us. - * What this solves is lingering claimed directions when a screen is closing. - * - * Overlay claims horizontal - * └─ A has no local horizontal claim - * └─ B claims horizontal (closing) - * └─ C claims horizontal (closing) - * - * Closing screens are still mounted, so they can still block parent gestures. - * But they cannot receive touches anymore. - * - * So when B/C are closing and the user is really touching A, B/C should not - * keep blocking the parent with their own gesture config. - * - * A closing screen should act like the visible screen underneath it: - * - if the screen underneath claims the gesture, keep blocking the parent - * - if the screen underneath does not claim it, let the parent handle it - */ + const { + claimedDirections, + gestureContext: parentContext, + routeKey: currentScreenKey, + } = gestureContext; + // A retained closing screen cannot receive touches, so its ownership must + // mirror the visible screen underneath instead of blocking an ancestor. const effectiveClaimedDirections = useMemo( () => resolveShadowingClaimDirections({ @@ -178,14 +158,13 @@ export function useWalkUpAndRegisterShadowingClaims( }), [isCurrentScreenClosing, claimedDirections, previous, parentContext], ); - const shadowedAncestors = useMemo( () => findShadowedAncestors(parentContext, effectiveClaimedDirections), [parentContext, effectiveClaimedDirections], ); useLayoutEffect(() => { - if (!isTopMostScreen || !shadowedAncestors.length) { + if (!shadowedAncestors.length) { return; } @@ -194,5 +173,16 @@ export function useWalkUpAndRegisterShadowingClaims( return () => { clearShadowingClaims(shadowedAncestors, currentScreenKey); }; - }, [shadowedAncestors, currentScreenKey, isTopMostScreen]); + }, [shadowedAncestors, currentScreenKey]); + + return null; +} + +/** Keeps gesture ownership subscriptions attached only to participating screens. */ +export function GestureOwnershipBridge() { + const isTopMostScreen = useDescriptorsStore( + (store) => store.derivations.isTopMostScreen, + ); + + return isTopMostScreen ? : null; } diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/components/floating-overlay-layer.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/components/floating-overlay-layer.tsx deleted file mode 100644 index 204bb2a2..00000000 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/components/floating-overlay-layer.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { memo } from "react"; -import { StyleSheet, View } from "react-native"; - -interface FloatingOverlayLayerProps { - children: React.ReactNode; - enabled?: boolean; -} -export const FloatingOverlayLayer = memo(function FloatingOverlayLayer({ - children, - enabled, -}: FloatingOverlayLayerProps) { - if (!enabled) { - return children; - } - return ( - - {children} - - ); -}); - -const styles = StyleSheet.create({ - float: { - zIndex: 999, - }, -}); diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/constants.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/constants.ts index 9b60b747..c4fc736d 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/constants.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/constants.ts @@ -65,6 +65,7 @@ export const PROP_RESET_VALUES: Record = { }; const RESERVED_STYLE_SLOT_IDS = { + overlay: true, content: true, backdrop: true, surface: true, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/helpers/transition-visual-state.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/helpers/transition-visual-state.ts index 432be36e..62e50ad3 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/helpers/transition-visual-state.ts +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/helpers/transition-visual-state.ts @@ -42,3 +42,29 @@ export const hasCloseTransitionFinished = ({ "worklet"; return !!closing && animationProgress === 0; }; + +export const isScreenInterpolatorReady = ({ + hasInterpolator, + opening, + closing, + pendingLifecycleStartBlockCount, + animationProgress, +}: { + hasInterpolator: boolean; + opening: boolean; + closing: number; + pendingLifecycleStartBlockCount: number; + animationProgress: number; +}) => { + "worklet"; + + return ( + hasInterpolator && + !isOpenTransitionBlocked({ + opening, + pendingLifecycleStartBlockCount, + animationProgress, + }) && + !hasCloseTransitionFinished({ closing, animationProgress }) + ); +}; diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/slot-resolvers.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/slot-resolvers.tsx index 37541785..686e32a2 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/slot-resolvers.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/slot-resolvers.tsx @@ -1,3 +1,4 @@ +import { useRoute } from "@react-navigation/native"; import { useMemo } from "react"; import { useAnimatedProps, useAnimatedStyle } from "react-native-reanimated"; import { NO_PROPS, NO_STYLES } from "../../../../constants"; @@ -7,8 +8,19 @@ import { } from "../helpers/compose-slot-style"; import { useScreenSlots } from "../slot.provider"; +const useCurrentScreenSlotsMap = () => { + const route = useRoute(); + const slotsMap = useScreenSlots(route.key, (store) => store.slotsMap); + + if (!slotsMap) { + throw new Error(`ScreenSlot store for route "${route.key}" was not found`); + } + + return slotsMap; +}; + export const useSlotStyles = (slotId: string | undefined) => { - const { slotsMap } = useScreenSlots(); + const slotsMap = useCurrentScreenSlotsMap(); return useAnimatedStyle(() => { const slot = slotId ? slotsMap.get()[slotId] : undefined; @@ -26,7 +38,7 @@ export const useComposedSlotStyles = ( slotId: string | undefined, style: unknown, ) => { - const { slotsMap } = useScreenSlots(); + const slotsMap = useCurrentScreenSlotsMap(); const localTransform = useMemo( () => getLocalTransformForSlotComposition(style), [style], @@ -50,7 +62,7 @@ export const useComposedSlotStyles = ( * target takes the full associated style. Yields `NO_STYLES` when neither is set. */ export const useSlotStackingStyles = (slotId: string | undefined) => { - const { slotsMap } = useScreenSlots(); + const slotsMap = useCurrentScreenSlotsMap(); return useAnimatedStyle(() => { const baseStyle = slotId ? slotsMap.get()[slotId]?.style : undefined; @@ -66,7 +78,7 @@ export const useSlotStackingStyles = (slotId: string | undefined) => { }; export const useSlotLayoutStyles = (slotId: string | undefined) => { - const { slotsMap } = useScreenSlots(); + const slotsMap = useCurrentScreenSlotsMap(); return useAnimatedStyle(() => { const baseStyle = slotId ? slotsMap.get()[slotId]?.style : undefined; @@ -103,7 +115,7 @@ export const useSlotLayoutStyles = (slotId: string | undefined) => { }; export const useSlotProps = (slotId: string | undefined) => { - const { slotsMap } = useScreenSlots(); + const slotsMap = useCurrentScreenSlotsMap(); return useAnimatedProps(() => { return (slotId ? slotsMap.get()[slotId]?.props : undefined) ?? NO_PROPS; diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/use-interpolated-style-maps.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/use-interpolated-style-maps.tsx index 90f803e5..cac58fc8 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/use-interpolated-style-maps.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/hooks/use-interpolated-style-maps.tsx @@ -40,6 +40,7 @@ import { stripInterpolatorOptions } from "../helpers/strip-interpolator-options" import { hasCloseTransitionFinished, isOpenTransitionBlocked, + isScreenInterpolatorReady, } from "../helpers/transition-visual-state"; import { resolveInitialDestinationStyleGate } from "../helpers/visibility-gate"; @@ -144,17 +145,59 @@ export const useInterpolatedStylesMap = ({ (s) => s.derivations.destinationPairKey, ); const screenOptions = useScreenOptionsStore(); - const { + const screenInterpolatorProps = useScreenAnimationStore( + (store) => store.screenInterpolatorProps, + ); + const screenInterpolatorPropsRevision = useScreenAnimationStore( + (store) => store.screenInterpolatorPropsRevision, + ); + const selectedInterpolatorOptions = useScreenAnimationStore( + (store) => store.selectedInterpolatorOptions, + ); + const nextInterpolator = useScreenAnimationStore( + (store) => store.nextInterpolator, + ); + const currentInterpolator = useScreenAnimationStore( + (store) => store.currentInterpolator, + ); + const ancestorScreenAnimationSources = useScreenAnimationStore( + (store) => store.ancestorScreenAnimationSources, + ); + const descendantScreenAnimationSources = useScreenAnimationStore( + (store) => store.descendantScreenAnimationSources, + ); + const transition = useBuildTransitionAccessor({ screenInterpolatorProps, screenInterpolatorPropsRevision, - selectedInterpolatorOptions, - nextInterpolator, - currentInterpolator, ancestorScreenAnimationSources, descendantScreenAnimationSources, - } = useScreenAnimationStore(); - const transition = useBuildTransitionAccessor(); - const nextInterpolatorReady = useSharedValue(0); + }); + const hasCurrentInterpolator = !!currentInterpolator; + const { closing: currentClosing, entering: currentEntering } = + AnimationStore.getBag(currentScreenKey); + const { + animationProgress: currentAnimationProgress, + pendingLifecycleRequestKind: currentPendingLifecycleRequestKind, + pendingLifecycleStartBlockCount: currentPendingLifecycleStartBlockCount, + } = SystemStore.getBag(currentScreenKey); + const interpolatorReady = useDerivedValue(() => { + "worklet"; + const isPendingOpen = + currentPendingLifecycleRequestKind.get() === + LifecycleTransitionRequestKind.Open; + const opening = isPendingOpen || !!currentEntering.get(); + + return isScreenInterpolatorReady({ + hasInterpolator: hasCurrentInterpolator, + opening, + closing: currentClosing.get(), + pendingLifecycleStartBlockCount: + currentPendingLifecycleStartBlockCount.get(), + animationProgress: currentAnimationProgress.get(), + }) + ? 1 + : 0; + }); // In some cases, a user may want to use external shared values to drive animations in the interpoaltor. // We can now support this by collecting those shared values and reading them here to trigger an update. @@ -249,10 +292,6 @@ export const useInterpolatedStylesMap = ({ !nextInterpolator || isOpeningBlocked || hasCloseFinished; - const nextReady = currentOwnsInterpolator ? 0 : 1; - - nextInterpolatorReady.set(nextReady); - const interpolatorOptionsOwner = currentOwnsInterpolator ? "current" : "next"; @@ -328,7 +367,7 @@ export const useInterpolatedStylesMap = ({ }); return { + interpolatorReady, localStylesMaps, - nextInterpolatorReady, }; }; diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/slot.provider.tsx b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/slot.provider.tsx index 8d1b5f74..b6b40ef3 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/slot.provider.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/slot.provider.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useLayoutEffect, useMemo } from "react"; +import { type ReactNode, useMemo } from "react"; import { StyleSheet } from "react-native"; import Animated, { type SharedValue } from "react-native-reanimated"; import type { @@ -8,22 +8,16 @@ import type { import type { NormalizedTransitionInterpolatedStyle } from "../../../types/animation.types"; import createProvider from "../../../utils/create-provider"; import { useDescriptorsStore } from "../descriptors"; -import { FloatingOverlayLayer } from "./components/floating-overlay-layer"; -import type { LocalStyleLayers } from "./helpers/resolve-slot-styles"; import { useInterpolatedStylesMap } from "./hooks/use-interpolated-style-maps"; import { useMaybeBlockVisibility } from "./hooks/use-maybe-block-visibility"; import { useResolvedStylesMap } from "./hooks/use-resolved-slot-style-map"; -import { - registerScreenSlots, - unregisterScreenSlots, -} from "./stores/slot-references.store"; type Props = { children: ReactNode; - isFloatingOverlay?: boolean; }; export type ScreenSlotName = + | "overlay" | "content" | "backdrop" | "surface" @@ -31,8 +25,7 @@ export type ScreenSlotName = | typeof NAVIGATION_MASK_ELEMENT_STYLE_ID; export type ScreenSlotContextValue = { - localStylesMaps: SharedValue; - nextInterpolatorReady: SharedValue; + interpolatorReady: SharedValue; slotsMap: SharedValue; visibilityBlocked: SharedValue; }; @@ -40,69 +33,52 @@ export type ScreenSlotContextValue = { export const { ScreenSlotProvider, useScreenSlotStore: useScreenSlots } = createProvider("ScreenSlot", { guarded: true, - })( - ({ children, isFloatingOverlay }, { useParentStore }) => { - const parentContext = useParentStore(); - const currentScreenKey = useDescriptorsStore( - (s) => s.derivations.currentScreenKey, - ); + global: true, + })(({ children }, { useParentStore }) => { + const ancestorStylesMap = useParentStore( + (parentContext) => parentContext?.slotsMap, + ); + const currentScreenKey = useDescriptorsStore( + (store) => store.derivations.currentScreenKey, + ); + const { animatedStyle, animatedProps, shouldBlockVisibility } = + useMaybeBlockVisibility(); - const { animatedStyle, animatedProps, shouldBlockVisibility } = - useMaybeBlockVisibility(isFloatingOverlay); + const { interpolatorReady, localStylesMaps } = useInterpolatedStylesMap({ + enabled: true, + visibilityBlocked: shouldBlockVisibility, + }); - const { localStylesMaps, nextInterpolatorReady } = - useInterpolatedStylesMap({ - enabled: !isFloatingOverlay, - visibilityBlocked: shouldBlockVisibility, - }); + const slotsMap = useResolvedStylesMap({ + localStylesMaps, + ancestorStylesMap, + }); + const value = useMemo( + () => ({ + interpolatorReady, + slotsMap, + visibilityBlocked: shouldBlockVisibility, + }), + [interpolatorReady, shouldBlockVisibility, slotsMap], + ); + const content = useMemo( + () => ( + + {children} + + ), + [children, animatedStyle, animatedProps], + ); - const slotsMap = useResolvedStylesMap({ - localStylesMaps, - ancestorStylesMap: parentContext?.slotsMap, - }); - const value = useMemo( - () => ({ - localStylesMaps, - nextInterpolatorReady, - slotsMap, - visibilityBlocked: shouldBlockVisibility, - }), - [ - localStylesMaps, - nextInterpolatorReady, - shouldBlockVisibility, - slotsMap, - ], - ); - - useLayoutEffect(() => { - registerScreenSlots(currentScreenKey, value); - - return () => { - unregisterScreenSlots(currentScreenKey, value); - }; - }, [currentScreenKey, value]); - - const content = useMemo( - () => ( - - - {children} - - - ), - [children, animatedStyle, isFloatingOverlay, animatedProps], - ); - - return { - value, - children: content, - }; - }, - ); + return { + key: currentScreenKey, + value, + children: content, + }; + }); const styles = StyleSheet.create({ container: { flex: 1 }, diff --git a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/stores/slot-references.store.ts b/packages/react-native-screen-transitions/src/shared/providers/screen/styles/stores/slot-references.store.ts deleted file mode 100644 index 5b9c3fbb..00000000 --- a/packages/react-native-screen-transitions/src/shared/providers/screen/styles/stores/slot-references.store.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { useSyncExternalStore } from "react"; -import type { ScreenKey } from "../../../../stores/bounds/types"; -import type { ScreenSlotContextValue } from "../slot.provider"; - -/** - * Screen-owned slot reference registry. - * - * This is important for portals: a teleported component can render outside the - * screen provider that owns its active transition, but it still needs references - * to that provider's shared values. This store does not copy or own styles; it - * only exposes the existing ScreenSlotProvider value by screen key. - */ -type ScreenSlotsSnapshot = Record; - -const listeners = new Set<() => void>(); -const slotsByScreenKey = new Map(); - -let snapshot: ScreenSlotsSnapshot = {}; - -const emit = () => { - snapshot = Object.fromEntries(slotsByScreenKey); - - for (const listener of listeners) { - listener(); - } -}; - -export const registerScreenSlots = ( - screenKey: ScreenKey, - slots: ScreenSlotContextValue, -) => { - const previous = slotsByScreenKey.get(screenKey); - if ( - previous?.localStylesMaps === slots.localStylesMaps && - previous.nextInterpolatorReady === slots.nextInterpolatorReady && - previous.slotsMap === slots.slotsMap && - previous.visibilityBlocked === slots.visibilityBlocked - ) { - return; - } - - slotsByScreenKey.set(screenKey, slots); - emit(); -}; - -export const unregisterScreenSlots = ( - screenKey: ScreenKey, - slots: ScreenSlotContextValue, -) => { - if (slotsByScreenKey.get(screenKey) !== slots) { - return; - } - - slotsByScreenKey.delete(screenKey); - emit(); -}; - -const subscribe = (listener: () => void) => { - listeners.add(listener); - - return () => { - listeners.delete(listener); - }; -}; - -export const useRegisteredScreenSlots = (screenKey?: ScreenKey | null) => { - return useSyncExternalStore( - subscribe, - () => (screenKey ? (snapshot[screenKey] ?? null) : null), - () => null, - ); -}; diff --git a/packages/react-native-screen-transitions/src/shared/providers/stack/blank-stack-state/helpers/resolve-presented-index.ts b/packages/react-native-screen-transitions/src/shared/providers/stack/blank-stack-state/helpers/resolve-presented-index.ts new file mode 100644 index 00000000..71da4f60 --- /dev/null +++ b/packages/react-native-screen-transitions/src/shared/providers/stack/blank-stack-state/helpers/resolve-presented-index.ts @@ -0,0 +1,22 @@ +import type { BaseStackRoute } from "../../../../types/stack.types"; + +export function resolvePresentedIndex( + routes: BaseStackRoute[], + focusedRouteKey: string | undefined, + closingRouteKeys: ReadonlySet, +): number { + const focusedIndex = focusedRouteKey + ? routes.findIndex((route) => route.key === focusedRouteKey) + : routes.length - 1; + const resolvedFocusedIndex = + focusedIndex >= 0 ? focusedIndex : routes.length - 1; + + for (let index = resolvedFocusedIndex; index >= 0; index -= 1) { + const route = routes[index]; + if (route && !closingRouteKeys.has(route.key)) { + return index; + } + } + + return resolvedFocusedIndex; +} diff --git a/packages/react-native-screen-transitions/src/shared/providers/stack/blank-stack.provider.tsx b/packages/react-native-screen-transitions/src/shared/providers/stack/blank-stack.provider.tsx index df726839..d1c9943e 100644 --- a/packages/react-native-screen-transitions/src/shared/providers/stack/blank-stack.provider.tsx +++ b/packages/react-native-screen-transitions/src/shared/providers/stack/blank-stack.provider.tsx @@ -15,6 +15,7 @@ import type { } from "../../types/stack.types"; import createProvider from "../../utils/create-provider"; import { useBlankStackState } from "./blank-stack-state"; +import { resolvePresentedIndex } from "./blank-stack-state/helpers/resolve-presented-index"; import { useStackCoreStore } from "./core.provider"; type InternalBlankStackProviderProps = BlankStackProviderProps< @@ -58,7 +59,11 @@ function BlankStackProvider({ state: stackState, }); const navigatorKey = stackState.key; - const focusedIndex = stackState.index; + const focusedIndex = resolvePresentedIndex( + state.routes, + state.focusedRouteKey, + state.closingRouteKeys, + ); const scenesByKey = useMemo( () => createScenesByKey(state.scenes), [state.scenes], diff --git a/packages/react-native-screen-transitions/src/shared/typecheck/public-api.typecheck.ts b/packages/react-native-screen-transitions/src/shared/typecheck/public-api.typecheck.ts index 1a47cf22..9015d668 100644 --- a/packages/react-native-screen-transitions/src/shared/typecheck/public-api.typecheck.ts +++ b/packages/react-native-screen-transitions/src/shared/typecheck/public-api.typecheck.ts @@ -7,9 +7,12 @@ import type { DerivedValue, SharedValue } from "react-native-reanimated"; import { type BlankStackFactoryOptions, type BlankStackNavigationOptions, + type BlankStackOverlayProps, type BlankStackScreenProps, createBlankStackNavigator, } from "../../blank-stack"; +import type { ComponentStackOverlayProps } from "../../component-stack"; +import type { NativeStackOverlayProps as LegacyNativeStackOverlayProps } from "../../native-stack"; import type Transition from ".."; import type { BoundsMotion, @@ -24,6 +27,7 @@ import type { ScreenContentComponentProps, ScreenGestureTarget, ScreenInterpolationProps, + ScreenStyleInterpolator, ScreenTransitionConfig, ScreenTransitionDepthTarget, ScreenTransitionTarget, @@ -466,6 +470,81 @@ const blankStackFactoryOptions: BlankStackFactoryOptions = { }; const blankStackNavigationOptions: BlankStackNavigationOptions = {}; +type OnboardingParamList = { + Welcome: undefined; + Profile: { referralCode?: string }; +}; + +declare const welcomeOverlayProps: BlankStackOverlayProps< + OnboardingParamList, + "Welcome" +>; + +function WelcomeOverlay( + _props: BlankStackOverlayProps, +) { + return null; +} + +const welcomeOwnerRouteName: "Welcome" = welcomeOverlayProps.route.name; +const focusedOnboardingRouteName: "Welcome" | "Profile" = + welcomeOverlayProps.focusedRoute.name; +if (welcomeOverlayProps.focusedRoute.name === "Profile") { + const referralCode: string | undefined = + welcomeOverlayProps.focusedRoute.params.referralCode; + void referralCode; +} +welcomeOverlayProps.navigation.navigate("Profile", { referralCode: "dorsia" }); +// @ts-expect-error Unknown routes are rejected by the navigator param list. +welcomeOverlayProps.navigation.navigate("Missing"); + +const onboardingOptions: BlankStackNavigationOptions = { + meta: { + title: "Welcome to Dorsia", + cta: { label: "Continue" }, + }, + overlay: WelcomeOverlay, +}; + +const onboardingInterpolator: ScreenStyleInterpolator = ({ current }) => { + "worklet"; + const title: unknown = current.meta?.title; + void title; + return {}; +}; + +declare const legacyNativeOverlayProps: LegacyNativeStackOverlayProps< + OnboardingParamList, + "Profile" +>; +declare const componentOverlayProps: ComponentStackOverlayProps< + OnboardingParamList, + "Profile" +>; +const legacyNativeReferralCode: string | undefined = + legacyNativeOverlayProps.route.params.referralCode; +const componentOverlayMeta: Record | undefined = + componentOverlayProps.meta; + +// @ts-expect-error Navigator options do not carry an application metadata schema. +declare const invalidTypedBlankStackOptions: BlankStackNavigationOptions<{ + title: string; +}>; +// @ts-expect-error Interpolator metadata is intentionally accessed as runtime data. +declare const invalidTypedInterpolator: ScreenStyleInterpolator<{ + title: string; +}>; + +void welcomeOwnerRouteName; +void focusedOnboardingRouteName; +void onboardingOptions; +void onboardingInterpolator; +void WelcomeOverlay; +void legacyNativeReferralCode; +void componentOverlayMeta; +void invalidTypedBlankStackOptions; +void invalidTypedInterpolator; + type StaticBlankStackParamList = { Home: undefined; Details: { id: string }; diff --git a/packages/react-native-screen-transitions/src/shared/types/animation.types.ts b/packages/react-native-screen-transitions/src/shared/types/animation.types.ts index 5f0509af..2321d2f3 100644 --- a/packages/react-native-screen-transitions/src/shared/types/animation.types.ts +++ b/packages/react-native-screen-transitions/src/shared/types/animation.types.ts @@ -384,6 +384,14 @@ export type TransitionInterpolatedStyle = { * This reserved key is stripped before style slot normalization. */ options?: TransitionInterpolatorOptions; + /** + * Animated style and props for the route-owned floating overlay. + * + * Visible overlays form their own adjacency chain. A route that returns this + * slot controls the previous visible overlay and, when it owns an overlay, + * its entering overlay. + */ + overlay?: TransitionSlotStyle; /** Animated style and props for the main screen content view. */ content?: TransitionSlotStyle; /** Animated style and props for the backdrop layer between screens. */ diff --git a/packages/react-native-screen-transitions/src/shared/types/index.ts b/packages/react-native-screen-transitions/src/shared/types/index.ts index 623b53bf..6e67cb5d 100644 --- a/packages/react-native-screen-transitions/src/shared/types/index.ts +++ b/packages/react-native-screen-transitions/src/shared/types/index.ts @@ -51,7 +51,7 @@ export type { ScrollMetadataState, SideActivation, } from "./gesture.types"; -export type { OverlayProps } from "./overlay.types"; +export type { OverlayComponent, OverlayProps } from "./overlay.types"; export type { GestureTracking, InactiveBehavior, diff --git a/packages/react-native-screen-transitions/src/shared/types/overlay.types.ts b/packages/react-native-screen-transitions/src/shared/types/overlay.types.ts index 6bd76961..5221f76a 100644 --- a/packages/react-native-screen-transitions/src/shared/types/overlay.types.ts +++ b/packages/react-native-screen-transitions/src/shared/types/overlay.types.ts @@ -1,28 +1,44 @@ -import type { Route } from "@react-navigation/native"; +import type { + ParamListBase, + ParamListRoute, + RouteProp, +} from "@react-navigation/native"; +import type { ReactNode } from "react"; import type { DerivedValue } from "react-native-reanimated"; import type { ScreenTransitionConfig } from "./screen.types"; /** - * Props passed to overlay components. - * Generic over the navigation type since different stacks have different navigation props. + * Overlay state without the animated progress value. */ +export type OverlayScreenState< + TNavigation = unknown, + TParamList extends ParamListBase = ParamListBase, + TOwnerRouteName extends keyof TParamList = keyof TParamList, +> = Omit, "progress"> & { + snapTo: (index: number) => void; +}; + /** - * Overlay screen state passed to overlay host for rendering. - * Generic over navigation type — defaults to `unknown` for flexibility. + * Props passed to a floating overlay component. + * + * `route` and `index` identify the screen that owns the overlay. Focused + * values may describe a later screen while that overlay remains visible. */ -export type OverlayScreenState = Omit< - OverlayProps, - "progress" -> & { +export type OverlayProps< + TNavigation = unknown, + TParamList extends ParamListBase = ParamListBase, + TOwnerRouteName extends keyof TParamList = keyof TParamList, +> = { + /** Route that declared this overlay. */ + route: RouteProp; + + /** Index of the route that declared this overlay. */ index: number; - snapTo: (index: number) => void; -}; -export type OverlayProps = { /** * Route of the currently focused screen in the stack. */ - focusedRoute: Route; + focusedRoute: ParamListRoute; /** * Index of the focused route in the stack. @@ -32,7 +48,7 @@ export type OverlayProps = { /** * All routes currently in the stack. */ - routes: Route[]; + routes: ParamListRoute[]; /** * Custom metadata from the focused screen's options. @@ -55,3 +71,14 @@ export type OverlayProps = { */ progress: DerivedValue; }; + +/** + * Component rendered for a floating screen overlay. + * + * The callback is bivariant because React Navigation stores overlay components + * in navigator-wide screen options while applications annotate them for one + * specific route. + */ +export type OverlayComponent = { + bivarianceHack(props: OverlayProps): ReactNode; +}["bivarianceHack"]; diff --git a/packages/react-native-screen-transitions/src/shared/types/screen.types.ts b/packages/react-native-screen-transitions/src/shared/types/screen.types.ts index 0da609b8..670c1e8b 100644 --- a/packages/react-native-screen-transitions/src/shared/types/screen.types.ts +++ b/packages/react-native-screen-transitions/src/shared/types/screen.types.ts @@ -11,7 +11,7 @@ import type { GestureProgressMode, ScrollMetadataState, } from "./gesture.types"; -import type { OverlayProps } from "./overlay.types"; +import type { OverlayComponent } from "./overlay.types"; export type Layout = { width: number; @@ -336,7 +336,7 @@ export type ScreenTransitionConfig = { /** * Function that returns a React Element to display as an overlay. */ - overlay?: (props: OverlayProps) => React.ReactNode; + overlay?: OverlayComponent; /** * Whether to show the overlay. The overlay is shown by default when `overlay` is provided. diff --git a/packages/react-native-screen-transitions/src/shared/utils/create-provider.tsx b/packages/react-native-screen-transitions/src/shared/utils/create-provider.tsx index c01b68cb..df55a147 100644 --- a/packages/react-native-screen-transitions/src/shared/utils/create-provider.tsx +++ b/packages/react-native-screen-transitions/src/shared/utils/create-provider.tsx @@ -14,6 +14,10 @@ * - `children`: passed-through children keep their identity across * provider-local renders. Factories that wrap children should use a * module-level memoized component for the wrapper. + * + * Providers created with `global: true` return a `key` from their factory. + * Their generated store hook accepts that key to subscribe to the original + * provider store from outside its React context. */ import { createContext, @@ -41,6 +45,27 @@ type ProviderStoreHook = { ): Selected; }; +type GlobalProviderStoreHook = { + (key: string): ContextValue | null; + ( + key: string, + selector: (value: ContextValue) => Selected, + ): Selected | null; +}; + +type ResolvedProviderStoreHook< + ContextValue, + Guarded extends boolean, + Global extends boolean, +> = ProviderStoreHook & + (Global extends true ? GlobalProviderStoreHook : unknown); + +type ProviderFactoryResult = { + value?: ContextValue; + enabled?: boolean; + children?: ReactNode; +} & (Global extends true ? { key: string } : { key?: never }); + export type ProviderFactoryInternals = { useParentStore: ProviderStoreHook; }; @@ -56,6 +81,12 @@ interface MutableProviderStoreApi setSnapshot: (snapshot: ContextValue | null) => boolean; } +interface ProviderStoreRegistry { + getStore: (key: string) => ProviderStoreApi | null; + register: (key: string, store: ProviderStoreApi) => () => void; + subscribe: (key: string, listener: () => void) => () => void; +} + const NullProviderStore: ProviderStoreApi = { getSnapshot: () => null, subscribe: () => () => {}, @@ -127,40 +158,186 @@ const createProviderStore = ( }; }; +const createProviderStoreRegistry = < + ContextValue, +>(): ProviderStoreRegistry => { + type Registration = { + store: ProviderStoreApi; + }; + + const listenersByKey = new Map void>>(); + const registrationsByKey = new Map(); + + const getStore = (key: string) => { + const registrations = registrationsByKey.get(key); + return registrations?.[registrations.length - 1]?.store ?? null; + }; + + const notify = (key: string) => { + for (const listener of listenersByKey.get(key) ?? []) { + listener(); + } + }; + + return { + getStore, + register: (key, store) => { + const registration: Registration = { store }; + const registrations = registrationsByKey.get(key) ?? []; + registrationsByKey.set(key, [...registrations, registration]); + notify(key); + + return () => { + const currentRegistrations = registrationsByKey.get(key); + if (!currentRegistrations?.includes(registration)) { + return; + } + + const previousStore = getStore(key); + const nextRegistrations = currentRegistrations.filter( + (currentRegistration) => currentRegistration !== registration, + ); + + if (nextRegistrations.length === 0) { + registrationsByKey.delete(key); + } else { + registrationsByKey.set(key, nextRegistrations); + } + + if (getStore(key) !== previousStore) { + notify(key); + } + }; + }, + subscribe: (key, listener) => { + const listeners = listenersByKey.get(key) ?? new Set<() => void>(); + listeners.add(listener); + listenersByKey.set(key, listeners); + + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + listenersByKey.delete(key); + } + }; + }, + }; +}; + export default function createProvider< ProviderName extends string, Guarded extends boolean = true, ->(name: ProviderName, options?: { guarded?: Guarded }) { + Global extends boolean = false, +>(name: ProviderName, options?: { guarded?: Guarded; global?: Global }) { return ( factory: ( props: ProviderProps, internals: ProviderFactoryInternals, - ) => { - value?: ContextValue; - enabled?: boolean; - children?: ReactNode; - }, + ) => ProviderFactoryResult, ) => { - const { guarded = true } = options ?? {}; + const { guarded = true, global = false } = options ?? {}; const providerDisplayName = `${name}Provider`; + const globalRegistry = global + ? createProviderStoreRegistry() + : null; + const keyByStore = global + ? new WeakMap, string>() + : null; const StoreContext = createContext | null>( null, ); StoreContext.displayName = `${name}Store`; - const createStoreHook = (strict: boolean) => { + const useRegisteredStore = ( + key: string | null, + fallbackStore: ProviderStoreApi | null = null, + ) => { + const subscribe = useCallback( + (listener: () => void) => { + if (key === null || !globalRegistry) { + return () => {}; + } + + return globalRegistry.subscribe(key, listener); + }, + [key], + ); + const getSnapshot = useCallback( + () => + key !== null && globalRegistry + ? (globalRegistry.getStore(key) ?? fallbackStore) + : null, + [key, fallbackStore], + ); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + }; + + const createStoreHook = (strict: boolean, allowGlobalLookup: boolean) => { + if (!allowGlobalLookup || !globalRegistry) { + return ( + selector?: (value: ContextValue | null) => Selected, + ): Selected | ContextValue | null => { + const store = useContext(StoreContext); + const resolvedStore = + store ?? (NullProviderStore as ProviderStoreApi); + const selectorRef = useRef(selector); + selectorRef.current = selector; + + const getSelectedSnapshot = useCallback(() => { + if (strict && store === null) { + throw new Error( + `${name} store must be used within a ${name}Provider`, + ); + } + + const snapshot = resolvedStore.getSnapshot(); + + if (strict && snapshot === null) { + throw new Error( + `${name} store must be used within an enabled ${name}Provider`, + ); + } + + return selectorRef.current + ? selectorRef.current(snapshot) + : snapshot; + }, [resolvedStore, store]); + + return useSyncExternalStore( + resolvedStore.subscribe, + getSelectedSnapshot, + getSelectedSnapshot, + ); + }; + } + return ( - selector?: (value: ContextValue | null) => Selected, + selectorOrKey?: string | ((value: ContextValue | null) => Selected), + globalSelector?: (value: ContextValue) => Selected, ): Selected | ContextValue | null => { - const store = useContext(StoreContext); + const isGlobalLookup = typeof selectorOrKey === "string"; + const key = isGlobalLookup ? selectorOrKey : null; + const selector = (isGlobalLookup ? globalSelector : selectorOrKey) as + | ((value: ContextValue | null) => Selected) + | undefined; + const contextStore = useContext(StoreContext); + const matchingContextStore = + key !== null && + contextStore !== null && + keyByStore?.get(contextStore) === key + ? contextStore + : null; + const registeredStore = useRegisteredStore(key, matchingContextStore); + const store = isGlobalLookup ? registeredStore : contextStore; const resolvedStore = store ?? (NullProviderStore as ProviderStoreApi); - const selectorRef = useRef(selector); + const selectorRef = useRef(selector); selectorRef.current = selector; const getSelectedSnapshot = useCallback(() => { - if (strict && store === null) { + if (!isGlobalLookup && strict && store === null) { throw new Error( `${name} store must be used within a ${name}Provider`, ); @@ -168,14 +345,20 @@ export default function createProvider< const snapshot = resolvedStore.getSnapshot(); - if (strict && snapshot === null) { + if (isGlobalLookup && snapshot === null) { + return null; + } + + if (!isGlobalLookup && strict && snapshot === null) { throw new Error( `${name} store must be used within an enabled ${name}Provider`, ); } - return selectorRef.current ? selectorRef.current(snapshot) : snapshot; - }, [resolvedStore, store]); + return typeof selectorRef.current === "function" + ? selectorRef.current(snapshot) + : snapshot; + }, [isGlobalLookup, resolvedStore, store]); return useSyncExternalStore( resolvedStore.subscribe, @@ -185,9 +368,9 @@ export default function createProvider< }; }; - const useStoreSelector = createStoreHook(guarded); + const useStoreSelector = createStoreHook(guarded, global); const factoryInternals: ProviderFactoryInternals = { - useParentStore: createStoreHook(false) as ProviderStoreHook< + useParentStore: createStoreHook(false, false) as ProviderStoreHook< ContextValue, false >, @@ -197,6 +380,7 @@ export default function createProvider< const { children = (props as { children?: ReactNode }).children, enabled = true, + key, value, } = factory(props, factoryInternals); @@ -216,6 +400,23 @@ export default function createProvider< storeRef.current = createProviderStore(snapshotValue); } const store = storeRef.current; + if (keyByStore && typeof key === "string") { + keyByStore.set(store, key); + } + + useLayoutEffect(() => { + if (!globalRegistry) { + return; + } + + if (typeof key !== "string") { + throw new Error( + `${name}Provider must return a key when global mode is enabled`, + ); + } + + return globalRegistry.register(key, store); + }, [key, store]); pendingNotifyRef.current = store.setSnapshot(snapshotValue) || pendingNotifyRef.current; @@ -241,9 +442,10 @@ export default function createProvider< } as { [P in ProviderName as `${P}Provider`]: React.FC; } & { - [P in ProviderName as `use${P}Store`]: ProviderStoreHook< + [P in ProviderName as `use${P}Store`]: ResolvedProviderStoreHook< ContextValue, - Guarded + Guarded, + Global >; }; }; diff --git a/test/setup.ts b/test/setup.ts index 6c4b418a..47545ccc 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -54,6 +54,7 @@ globalThis.resetMutableRegistry = () => { }; mock.module("react-native", () => ({ + View: "View", Platform: { OS: "ios", select: (obj: { ios?: T; android?: T; default?: T }) => @@ -115,6 +116,8 @@ mock.module("react-native-reanimated", () => ({ useDerivedValue: (factory: () => T) => ({ get: factory, }), + useAnimatedStyle: (factory: () => T) => factory(), + useAnimatedProps: (factory: () => T) => factory(), useAnimatedReaction: ( prepare: () => unknown, react: (value: unknown, previousValue: unknown) => void,