Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/shiny-moons-shout.md
Original file line number Diff line number Diff line change
@@ -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.
175 changes: 175 additions & 0 deletions src/react-native/grab-controls.tsx
Original file line number Diff line number Diff line change
@@ -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<ControlsBounds | null>(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.
<View pointerEvents="box-none" style={styles.overlayAnchor}>
<FullScreenOverlay>
{/* Measured rather than the anchor, because `FullScreenOverlay` lifts this
out to window size on iOS while it stays owner-sized elsewhere. */}
<View pointerEvents="box-none" style={styles.overlayRoot} onLayout={handleLayout}>
<GrabControlBar
containerStyle={containerStyle}
dragHandlePanHandlers={dragHandlePanResponder.panHandlers}
isSessionEnabled={state.selectionSessionOwnerId !== null}
isVisible={isControlBarVisible}
onHidden={resetControlBarPosition}
onPressHide={toggleGrabMenu}
onPressSelect={enableGrabbing}
/>
</View>
</FullScreenOverlay>
</View>
);
};

const styles = StyleSheet.create({
overlayAnchor: {
...StyleSheet.absoluteFillObject,
zIndex: 9999,
},
overlayRoot: {
...StyleSheet.absoluteFillObject,
zIndex: 9999,
},
controlBar: {
position: "absolute",
top: 0,
left: 0,
zIndex: 2,
},
});
121 changes: 0 additions & 121 deletions src/react-native/grab-root-controls.tsx

This file was deleted.

27 changes: 14 additions & 13 deletions src/react-native/grab-root.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -11,18 +12,18 @@ export type ReactNativeGrabRootProps = ViewProps;
export const ReactNativeGrabRoot = ({ children, style, ...props }: ReactNativeGrabRootProps) => {
const { ownerId, ownerRef } = useGrabSelectionOwner("root");

return (
<>
<GrabSelectionOwnerView
{...props}
ownerId={ownerId}
ownerRef={ownerRef}
style={[grabSelectionOwnerFillStyle, style]}
>
{children}
</GrabSelectionOwnerView>
// Registered from the root rather than from the resolved owner: the dev menu
// item has to outlive selection moving between owners.
useDevMenu(toggleGrabMenu);

<ReactNativeGrabRootControls />
</>
return (
<GrabSelectionOwnerView
{...props}
ownerId={ownerId}
ownerRef={ownerRef}
style={[grabSelectionOwnerFillStyle, style]}
>
{children}
</GrabSelectionOwnerView>
);
};
6 changes: 6 additions & 0 deletions src/react-native/grab-selection-owner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -46,11 +48,15 @@ export const GrabSelectionOwnerView = ({
...props
}: GrabSelectionOwnerViewProps) => {
const [panHandlers, setPanHandlers] = useState<GestureResponderHandlers | null>(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 (
<View {...props} {...(panHandlers ?? {})} collapsable={false} ref={ownerRef}>
{children}
<ReactNativeGrabOverlay ownerId={ownerId} onPanHandlersChange={setPanHandlers} />
{isResolvedSelectionOwner && <GrabOwnerControls />}
</View>
);
};
Loading