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/olive-otters-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-native-grab": patch
---

Fix selection landing below the finger on Android. Fabric measures in the coordinate space of the surface root, while touches report page coordinates in the coordinate space of the native window, and Android's main surface starts a status bar below its window. The gap is now read off the touch that starts the gesture instead of assumed to be zero, so it stays correct for natively presented surfaces (which have their own window) and on iOS (where the two spaces already coincide).
67 changes: 67 additions & 0 deletions src/react-native/__tests__/measure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NativeTouchEvent } from "react-native";

const findShadowNodeByTag_DEPRECATED = vi.fn();
const measureInWindow = vi.fn();

vi.mock("../fabric", () => ({
getFabricUIManager: () => ({ findShadowNodeByTag_DEPRECATED }),
}));

import { getFabricWindowOffset } from "../measure";

beforeEach(() => {
vi.clearAllMocks();
globalThis.nativeFabricUIManager = { measureInWindow } as never;
});

const touch = (values: Partial<NativeTouchEvent>) =>
({
target: "1" as never,
pageX: 0,
pageY: 0,
locationX: 0,
locationY: 0,
...values,
}) as NativeTouchEvent;

const targetAt = (x: number, y: number) => {
findShadowNodeByTag_DEPRECATED.mockReturnValue({});
measureInWindow.mockImplementation(
(_node: unknown, callback: (x: number, y: number, w: number, h: number) => void) => {
callback(x, y, 10, 10);
},
);
};

describe("getFabricWindowOffset", () => {
it("is zero when the surface root sits at the window origin", () => {
targetAt(20, 300);
expect(
getFabricWindowOffset(touch({ pageX: 25, pageY: 310, locationX: 5, locationY: 10 })),
).toEqual([0, 0]);
});

it("reports the gap when the surface root is offset inside its window", () => {
// The target measures 48.8dp higher in Fabric space than the touch reports,
// which is roughly how far Android's main surface starts below the window.
targetAt(20, 251);
expect(
getFabricWindowOffset(touch({ pageX: 25, pageY: 310, locationX: 5, locationY: 10 })),
).toEqual([0, -49]);
});

it("falls back to no offset when the touch target cannot be resolved", () => {
findShadowNodeByTag_DEPRECATED.mockReturnValue(null);
expect(getFabricWindowOffset(touch({ pageX: 25, pageY: 310 }))).toEqual([0, 0]);

findShadowNodeByTag_DEPRECATED.mockImplementation(() => {
throw new Error("unknown tag");
});
expect(getFabricWindowOffset(touch({ pageX: 25, pageY: 310 }))).toEqual([0, 0]);

expect(getFabricWindowOffset(touch({ target: "0" as never, pageX: 25, pageY: 310 }))).toEqual([
0, 0,
]);
});
});
16 changes: 11 additions & 5 deletions src/react-native/grab-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
} from "./grab-controller";
import { getDescription, getGrabSelectionTitle } from "./description";
import { getRenderedBy, type RenderedByFrame } from "./get-rendered-by";
import { findNodeAtPoint, measureInWindow } from "./measure";
import { findNodeAtPoint, getFabricWindowOffset, measureInWindow } from "./measure";
import { openStackFrameInEditor } from "./open";
import type { BoundingClientRect, ReactNativeFiberNode } from "./types";

Expand Down Expand Up @@ -51,6 +51,7 @@ export const ReactNativeGrabOverlay = ({
}: ReactNativeGrabOverlayProps) => {
const isResolvedSelectionOwner = useIsResolvedGrabSelectionOwner(ownerId);
const copyBadgeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const windowOffsetRef = useRef<[number, number]>([0, 0]);
const [bounds, setBounds] = useState({ width: 0, height: 0 });
const [state, setState] = useState({
isSessionEnabled: false,
Expand Down Expand Up @@ -145,14 +146,16 @@ export const ReactNativeGrabOverlay = ({
}

// findNodeAtPoint resolves the point against the owner, not the window, so
// page coordinates have to be rebased onto the owner's origin. They only
// page coordinates have to be moved into Fabric's space (see
// getFabricWindowOffset) and then rebased onto the owner's origin. They only
// coincide for an owner sitting at the window origin, which is why a screen
// under a native header, or a natively presented surface, missed its target.
const ownerRect = measureInWindow(owner.shadowNode);
const [windowOffsetX, windowOffsetY] = windowOffsetRef.current;
const internalNode = findNodeAtPoint(
owner.shadowNode,
pageX - ownerRect[0],
pageY - ownerRect[1],
pageX + windowOffsetX - ownerRect[0],
pageY + windowOffsetY - ownerRect[1],
);
const shadowNode = internalNode?.stateNode?.node;

Expand Down Expand Up @@ -225,7 +228,10 @@ export const ReactNativeGrabOverlay = ({
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponderCapture: () => true,

onPanResponderGrant: (evt) => handleTouch(evt.nativeEvent),
onPanResponderGrant: (evt) => {
windowOffsetRef.current = getFabricWindowOffset(evt.nativeEvent);
handleTouch(evt.nativeEvent);
},
onPanResponderMove: (evt) => handleTouch(evt.nativeEvent),
onPanResponderRelease: (evt) => {
void (async () => {
Expand Down
47 changes: 47 additions & 0 deletions src/react-native/measure.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { NativeTouchEvent } from "react-native";
import { BoundingClientRect, ReactNativeFiberNode, ReactNativeShadowNode } from "./types";
import { getFabricUIManager } from "./fabric";

export const measureInWindow = (node: ReactNativeShadowNode): BoundingClientRect => {
let boundingClientRect: BoundingClientRect | null = null;
Expand Down Expand Up @@ -27,3 +29,48 @@ export const findNodeAtPoint = (

return fiberNode;
};

const findShadowNodeByTag = (tag: NativeTouchEvent["target"]): ReactNativeShadowNode | null => {
const nativeTag = typeof tag === "string" ? Number(tag) : tag;

if (!nativeTag || Number.isNaN(nativeTag)) {
return null;
}

try {
return getFabricUIManager().findShadowNodeByTag_DEPRECATED(nativeTag) ?? null;
} catch {
return null;
}
};

/**
* Fabric measures in the coordinate space of the surface root, while touches
* report `pageX`/`pageY` in the coordinate space of the native window hosting
* that surface. The two only coincide when the surface root sits at the window
* origin, which is why hit testing was accurate on iOS and a status bar too low
* in Android's main window.
*
* A touch carries both spaces at once - `pageX`/`pageY` alongside `locationX`/
* `locationY` relative to `target` - so the offset between them can be read off
* the touch itself rather than guessed per platform. Read it once when the
* gesture starts: the offset belongs to the window, not to the touched view, and
* Android only guarantees that `location` matches `target` on the initial touch.
*/
export const getFabricWindowOffset = (nativeEvent: NativeTouchEvent): [number, number] => {
const targetNode = findShadowNodeByTag(nativeEvent.target);

if (!targetNode) {
return [0, 0];
}

try {
const targetRect = measureInWindow(targetNode);
return [
targetRect[0] + nativeEvent.locationX - nativeEvent.pageX,
targetRect[1] + nativeEvent.locationY - nativeEvent.pageY,
];
} catch {
return [0, 0];
}
};
Loading