From 1dc38651591248bcd5c3bcf9d88886894edf854f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 31 Aug 2026 14:21:50 +0200 Subject: [PATCH] fix: render grab controls in the resolved owner's window --- .changeset/shiny-moons-shout.md | 5 + src/react-native/grab-controls.tsx | 175 ++++++++++++++++++++++ src/react-native/grab-root-controls.tsx | 121 --------------- src/react-native/grab-root.tsx | 27 ++-- src/react-native/grab-selection-owner.tsx | 6 + 5 files changed, 200 insertions(+), 134 deletions(-) create mode 100644 .changeset/shiny-moons-shout.md create mode 100644 src/react-native/grab-controls.tsx delete mode 100644 src/react-native/grab-root-controls.tsx diff --git a/.changeset/shiny-moons-shout.md b/.changeset/shiny-moons-shout.md new file mode 100644 index 0000000..bd01075 --- /dev/null +++ b/.changeset/shiny-moons-shout.md @@ -0,0 +1,5 @@ +--- +"react-native-grab": patch +--- + +Render the grab controls in the window of the owner that currently resolves selection, so they stay visible above natively presented surfaces. Previously the controls were always rendered by `ReactNativeGrabRoot`, which left them stranded behind sheets on Android: a sheet is presented in its own window, and no `zIndex` inside the main window can paint over it. The control bar is also placed against the measured size of its container rather than the window's, since that container is a sheet rather than the whole screen whenever a surface owns selection. diff --git a/src/react-native/grab-controls.tsx b/src/react-native/grab-controls.tsx new file mode 100644 index 0000000..75c19c1 --- /dev/null +++ b/src/react-native/grab-controls.tsx @@ -0,0 +1,175 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Animated, PanResponder, StyleSheet, View, type LayoutChangeEvent } from "react-native"; +import { FullScreenOverlay } from "./full-screen-overlay"; +import { enableGrabbing, toggleGrabMenu, useGrabControllerState } from "./grab-controller"; +import { GrabControlBar } from "./grab-control-bar"; + +const BAR_HEIGHT = 36; +const BAR_WIDTH = 108; +const BAR_TOP_INSET = 72; + +type ControlsBounds = { + width: number; + height: number; +}; + +const clamp = (value: number, min: number, max: number) => { + return Math.min(Math.max(value, min), max); +}; + +const getInitialBarPosition = (bounds: ControlsBounds) => { + return { + x: clamp((bounds.width - BAR_WIDTH) / 2, 0, Math.max(0, bounds.width - BAR_WIDTH)), + y: clamp(BAR_TOP_INSET, 0, Math.max(0, bounds.height - BAR_HEIGHT)), + }; +}; + +/** + * Rendered by the owner that currently resolves selection, so the controls live in + * the same native window as the content they act on. A natively presented surface + * gets its own window, and no z-index inside the main window can paint over it - + * on iOS `FullScreenOverlay` lifts the bar above every window, but Android has no + * equivalent, which left the bar stranded behind presented sheets. + * + * The bar is placed against the measured size of its container rather than the + * window's, since that container is a sheet rather than the whole screen whenever + * a surface owns selection. + */ +export const GrabOwnerControls = () => { + const state = useGrabControllerState(); + const controlBarPosition = useRef(new Animated.ValueXY({ x: 0, y: 0 })).current; + const boundsRef = useRef(null); + const [hasBounds, setHasBounds] = useState(false); + const shouldResetControlBarPositionRef = useRef(false); + + const isControlBarVisible = + hasBounds && + state.isMenuVisible && + state.selectionSessionOwnerId === null && + state.selectedOwnerId === null; + + // Hiding the controls returns them to their initial spot the next time they are + // shown; pausing them for a selection session deliberately does not. + useEffect(() => { + if (!state.isMenuVisible) { + shouldResetControlBarPositionRef.current = true; + } + }, [state.isMenuVisible]); + + const clampControlBarToBounds = useCallback(() => { + controlBarPosition.flattenOffset(); + controlBarPosition.stopAnimation((value) => { + const bounds = boundsRef.current; + if (!bounds) { + return; + } + + controlBarPosition.setValue({ + x: clamp(value.x, 0, Math.max(0, bounds.width - BAR_WIDTH)), + y: clamp(value.y, 0, Math.max(0, bounds.height - BAR_HEIGHT)), + }); + }); + }, [controlBarPosition]); + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + const previousBounds = boundsRef.current; + + if (previousBounds?.width === width && previousBounds.height === height) { + return; + } + + boundsRef.current = { width, height }; + + if (!previousBounds) { + controlBarPosition.setValue(getInitialBarPosition(boundsRef.current)); + setHasBounds(true); + return; + } + + clampControlBarToBounds(); + }, + [clampControlBarToBounds, controlBarPosition], + ); + + const resetControlBarPosition = useCallback(() => { + const bounds = boundsRef.current; + if (!shouldResetControlBarPositionRef.current || !bounds) { + return; + } + + shouldResetControlBarPositionRef.current = false; + controlBarPosition.setValue(getInitialBarPosition(bounds)); + }, [controlBarPosition]); + + const dragHandlePanResponder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: (_, gestureState) => + Math.abs(gestureState.dx) > 2 || Math.abs(gestureState.dy) > 2, + onPanResponderGrant: () => { + controlBarPosition.stopAnimation((value) => { + controlBarPosition.setOffset(value); + controlBarPosition.setValue({ x: 0, y: 0 }); + }); + }, + onPanResponderMove: Animated.event( + [null, { dx: controlBarPosition.x, dy: controlBarPosition.y }], + { useNativeDriver: false }, + ), + onPanResponderRelease: () => clampControlBarToBounds(), + onPanResponderTerminate: () => clampControlBarToBounds(), + }), + ).current; + + const containerStyle = useMemo( + () => [ + styles.controlBar, + { + transform: controlBarPosition.getTranslateTransform(), + }, + ], + [controlBarPosition], + ); + + return ( + // The anchor is absolutely positioned so that mounting the controls inside an + // owner cannot disturb its layout: an owner is free to be a `gap`-spaced flex + // container, and absolutely positioned children are not flex items. + + + {/* Measured rather than the anchor, because `FullScreenOverlay` lifts this + out to window size on iOS while it stays owner-sized elsewhere. */} + + + + + + ); +}; + +const styles = StyleSheet.create({ + overlayAnchor: { + ...StyleSheet.absoluteFillObject, + zIndex: 9999, + }, + overlayRoot: { + ...StyleSheet.absoluteFillObject, + zIndex: 9999, + }, + controlBar: { + position: "absolute", + top: 0, + left: 0, + zIndex: 2, + }, +}); diff --git a/src/react-native/grab-root-controls.tsx b/src/react-native/grab-root-controls.tsx deleted file mode 100644 index 9039ec6..0000000 --- a/src/react-native/grab-root-controls.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { useCallback, useMemo, useRef } from "react"; -import { Animated, Dimensions, PanResponder, StyleSheet, View } from "react-native"; -import { useDevMenu } from "./dev-menu"; -import { FullScreenOverlay } from "./full-screen-overlay"; -import { enableGrabbing, toggleGrabMenu, useGrabControllerState } from "./grab-controller"; -import { GrabControlBar } from "./grab-control-bar"; - -const BAR_HEIGHT = 36; -const BAR_WIDTH = 108; -const INITIAL_BAR_POSITION = { - x: (Dimensions.get("window").width - BAR_WIDTH) / 2, - y: 72, -}; - -const clamp = (value: number, min: number, max: number) => { - return Math.min(Math.max(value, min), max); -}; - -export const ReactNativeGrabRootControls = () => { - const state = useGrabControllerState(); - const controlBarPosition = useRef(new Animated.ValueXY(INITIAL_BAR_POSITION)).current; - const shouldResetControlBarPositionRef = useRef(false); - - const isControlBarVisible = - state.isMenuVisible && state.selectionSessionOwnerId === null && state.selectedOwnerId === null; - - const toggleMenuVisibility = useCallback(() => { - shouldResetControlBarPositionRef.current = state.isMenuVisible; - toggleGrabMenu(); - }, [state.isMenuVisible]); - - const resetControlBarPosition = useCallback(() => { - if (!shouldResetControlBarPositionRef.current) { - return; - } - - shouldResetControlBarPositionRef.current = false; - controlBarPosition.setValue(INITIAL_BAR_POSITION); - }, [controlBarPosition]); - - useDevMenu(toggleMenuVisibility); - - const dragHandlePanResponder = useRef( - PanResponder.create({ - onStartShouldSetPanResponder: () => true, - onMoveShouldSetPanResponder: (_, gestureState) => - Math.abs(gestureState.dx) > 2 || Math.abs(gestureState.dy) > 2, - onPanResponderGrant: () => { - controlBarPosition.stopAnimation((value) => { - controlBarPosition.setOffset(value); - controlBarPosition.setValue({ x: 0, y: 0 }); - }); - }, - onPanResponderMove: Animated.event( - [null, { dx: controlBarPosition.x, dy: controlBarPosition.y }], - { useNativeDriver: false }, - ), - onPanResponderRelease: () => { - controlBarPosition.flattenOffset(); - controlBarPosition.stopAnimation((value) => { - const { width, height } = Dimensions.get("window"); - - controlBarPosition.setValue({ - x: clamp(value.x, 0, Math.max(0, width - BAR_WIDTH)), - y: clamp(value.y, 0, Math.max(0, height - BAR_HEIGHT)), - }); - }); - }, - onPanResponderTerminate: () => { - controlBarPosition.flattenOffset(); - controlBarPosition.stopAnimation((value) => { - const { width, height } = Dimensions.get("window"); - - controlBarPosition.setValue({ - x: clamp(value.x, 0, Math.max(0, width - BAR_WIDTH)), - y: clamp(value.y, 0, Math.max(0, height - BAR_HEIGHT)), - }); - }); - }, - }), - ).current; - - const containerStyle = useMemo( - () => [ - styles.controlBar, - { - transform: controlBarPosition.getTranslateTransform(), - }, - ], - [controlBarPosition], - ); - - return ( - - - - - - ); -}; - -const styles = StyleSheet.create({ - overlayRoot: { - ...StyleSheet.absoluteFillObject, - zIndex: 9999, - }, - controlBar: { - position: "absolute", - top: 0, - left: 0, - zIndex: 2, - }, -}); diff --git a/src/react-native/grab-root.tsx b/src/react-native/grab-root.tsx index 3d0e51a..c82b15b 100644 --- a/src/react-native/grab-root.tsx +++ b/src/react-native/grab-root.tsx @@ -1,5 +1,6 @@ import type { ViewProps } from "react-native"; -import { ReactNativeGrabRootControls } from "./grab-root-controls"; +import { useDevMenu } from "./dev-menu"; +import { toggleGrabMenu } from "./grab-controller"; import { grabSelectionOwnerFillStyle, GrabSelectionOwnerView, @@ -11,18 +12,18 @@ export type ReactNativeGrabRootProps = ViewProps; export const ReactNativeGrabRoot = ({ children, style, ...props }: ReactNativeGrabRootProps) => { const { ownerId, ownerRef } = useGrabSelectionOwner("root"); - return ( - <> - - {children} - + // Registered from the root rather than from the resolved owner: the dev menu + // item has to outlive selection moving between owners. + useDevMenu(toggleGrabMenu); - - + return ( + + {children} + ); }; diff --git a/src/react-native/grab-selection-owner.tsx b/src/react-native/grab-selection-owner.tsx index 36c7c2b..833f1b7 100644 --- a/src/react-native/grab-selection-owner.tsx +++ b/src/react-native/grab-selection-owner.tsx @@ -4,8 +4,10 @@ import { createGrabSelectionOwnerId, registerGrabSelectionOwner, unregisterGrabSelectionOwner, + useIsResolvedGrabSelectionOwner, type GrabSelectionOwnerKind, } from "./containers"; +import { GrabOwnerControls } from "./grab-controls"; import { ReactNativeGrabOverlay } from "./grab-overlay"; /** Root and screen owners always fill their parent; surfaces are sized by their host. */ @@ -46,11 +48,15 @@ export const GrabSelectionOwnerView = ({ ...props }: GrabSelectionOwnerViewProps) => { const [panHandlers, setPanHandlers] = useState(null); + // The controls belong to whichever owner resolves selection, so that they are + // rendered in the same native window as the content they act on. + const isResolvedSelectionOwner = useIsResolvedGrabSelectionOwner(ownerId); return ( {children} + {isResolvedSelectionOwner && } ); };