From 15cf245917db692b3a56023ea460caa9651933ac Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 31 Aug 2026 13:26:30 +0200 Subject: [PATCH] fix: resolve grab points in the touch's window space on Android --- .changeset/olive-otters-grab.md | 5 ++ src/react-native/__tests__/measure.test.ts | 67 ++++++++++++++++++++++ src/react-native/grab-overlay.tsx | 16 ++++-- src/react-native/measure.ts | 47 +++++++++++++++ 4 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 .changeset/olive-otters-grab.md create mode 100644 src/react-native/__tests__/measure.test.ts diff --git a/.changeset/olive-otters-grab.md b/.changeset/olive-otters-grab.md new file mode 100644 index 0000000..ad2de55 --- /dev/null +++ b/.changeset/olive-otters-grab.md @@ -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). diff --git a/src/react-native/__tests__/measure.test.ts b/src/react-native/__tests__/measure.test.ts new file mode 100644 index 0000000..e623d93 --- /dev/null +++ b/src/react-native/__tests__/measure.test.ts @@ -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) => + ({ + 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, + ]); + }); +}); diff --git a/src/react-native/grab-overlay.tsx b/src/react-native/grab-overlay.tsx index ff66c25..55cb0fd 100644 --- a/src/react-native/grab-overlay.tsx +++ b/src/react-native/grab-overlay.tsx @@ -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"; @@ -51,6 +51,7 @@ export const ReactNativeGrabOverlay = ({ }: ReactNativeGrabOverlayProps) => { const isResolvedSelectionOwner = useIsResolvedGrabSelectionOwner(ownerId); const copyBadgeTimeoutRef = useRef | null>(null); + const windowOffsetRef = useRef<[number, number]>([0, 0]); const [bounds, setBounds] = useState({ width: 0, height: 0 }); const [state, setState] = useState({ isSessionEnabled: false, @@ -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; @@ -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 () => { diff --git a/src/react-native/measure.ts b/src/react-native/measure.ts index c521b5a..1272026 100644 --- a/src/react-native/measure.ts +++ b/src/react-native/measure.ts @@ -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; @@ -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]; + } +};