From acc795f8ca5ee5da2043d22de2855dad4696f1d4 Mon Sep 17 00:00:00 2001 From: ahmdshrif Date: Sat, 19 Sep 2026 17:51:37 +0300 Subject: [PATCH 1/2] fix: draw pinned columns without zIndex to stop Android flicker Android implements zIndex by reordering views at draw time, which can flicker while a transform animates. The pinned group is now the last child of the row and header, positioned absolutely over a spacer, so it draws on top without reordering. --- src/GridHeader.tsx | 28 +++++++++++++++------------- src/GridRow.tsx | 8 ++++++-- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/GridHeader.tsx b/src/GridHeader.tsx index 048f85a..7a8bf9d 100644 --- a/src/GridHeader.tsx +++ b/src/GridHeader.tsx @@ -90,6 +90,20 @@ function GridHeaderImpl({ }, ]} > + {layout.pinnedWidth > 0 && } + + + {renderCells(layout.scrolling)} + + + {/* Drawn last so it covers the scrolling header without zIndex (see GridRow). */} {layout.pinnedWidth > 0 && ( ({ /> )} - - - {renderCells(layout.scrolling)} - - ); } @@ -151,7 +153,7 @@ export const GridHeader = memo(GridHeaderImpl) as typeof GridHeaderImpl; const styles = StyleSheet.create({ header: { flexDirection: 'row', borderBottomWidth: 1 }, row: { flexDirection: 'row' }, - pinned: { zIndex: 1, height: '100%' }, + pinned: { position: 'absolute', left: 0, top: 0, bottom: 0 }, pinnedEdge: { position: 'absolute', top: 0, diff --git a/src/GridRow.tsx b/src/GridRow.tsx index 6d2b911..82c44ea 100644 --- a/src/GridRow.tsx +++ b/src/GridRow.tsx @@ -101,8 +101,13 @@ function GridRowImpl({ )); + // The pinned group is drawn last and positioned absolutely, so it covers the scrolling + // cells without zIndex. Android reorders views for zIndex, which flickers while a + // transform animates. const content = ( <> + {layout.pinnedWidth > 0 && } + {renderCells(layout.scrolling)} {layout.pinnedWidth > 0 && ( ({ /> )} - {renderCells(layout.scrolling)} ); @@ -194,7 +198,7 @@ export const GridRow = memo(GridRowImpl) as typeof GridRowImpl; const styles = StyleSheet.create({ row: { flexDirection: 'row' }, rowBorder: { borderBottomWidth: StyleSheet.hairlineWidth }, - pinned: { zIndex: 1, height: '100%' }, + pinned: { position: 'absolute', left: 0, top: 0, bottom: 0 }, pinnedEdge: { position: 'absolute', top: 0, From d209715f13942ba86336b3708e0f32f188c19ad1 Mon Sep 17 00:00:00 2001 From: ahmdshrif Date: Sat, 19 Sep 2026 18:55:18 +0300 Subject: [PATCH 2/2] fix: drive horizontal scrolling with a pan gesture The body scrolled in a native ScrollView while the header and pinned columns were positioned from a Reanimated value fed by scroll events. On Android the two do not land in the same frame, so the pinned columns drifted and snapped back, which read as flicker. A pan gesture now drives one shared value that positions the rows, the header and the pinned columns in the same frame. Adds react-native-gesture-handler as a peer dependency; apps must wrap their root in GestureHandlerRootView. Measured on the Android emulator with a frame detector that flags frames jumping away from both neighbours while the neighbours match: 7 such frames before the change, 0 after, over the same swipe sequence. --- README.md | 20 +++++++-- example/package.json | 1 + example/src/App.tsx | 101 +++++++++++++++++++++++-------------------- package.json | 2 + src/DataGrid.tsx | 64 ++++++++++++++++++++------- yarn.lock | 44 ++++++++++++++++++- 6 files changed, 163 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 7e2ae05..98398a2 100644 --- a/README.md +++ b/README.md @@ -10,23 +10,35 @@ Fast data grid for React Native: pinned columns, sticky header, virtualized rows - **Selection**: single or multiple, with a pinned checkbox column and select-all - **Custom cells** through `renderCell`, or plain text through `format` - **Light and dark themes**, with every color overridable -- Pure JS on top of FlashList and Reanimated: works with Expo, no native code of its own +- Pure JS on top of FlashList, Reanimated and Gesture Handler: works with Expo, no native code of its own > Status: early development (v0.1). The API may still change. ## Installation ```sh -npm install react-native-datagrid @shopify/flash-list react-native-reanimated react-native-worklets +npm install react-native-datagrid @shopify/flash-list react-native-reanimated react-native-worklets react-native-gesture-handler ``` With Expo: ```sh -npx expo install react-native-datagrid @shopify/flash-list react-native-reanimated react-native-worklets +npx expo install react-native-datagrid @shopify/flash-list react-native-reanimated react-native-worklets react-native-gesture-handler ``` -Requires the New Architecture, FlashList 2 and Reanimated 4. If you don't use Expo, follow the [Reanimated setup guide](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/getting-started) to add the worklets Babel plugin. +Requires the New Architecture, FlashList 2, Reanimated 4 and Gesture Handler 2. If you don't use Expo, follow the [Reanimated setup guide](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/getting-started) to add the worklets Babel plugin. + +Wrap your app in `GestureHandlerRootView` once, near the root: + +```tsx +import { GestureHandlerRootView } from 'react-native-gesture-handler'; + +export default function App() { + return {/* ... */}; +} +``` + +Sideways scrolling uses a pan gesture rather than a `ScrollView`, so the header and pinned columns move in the same frame as the rows. A `ScrollView` reports its offset a frame late on Android, which makes pinned columns flicker. ## Usage diff --git a/example/package.json b/example/package.json index 03aafd1..3932155 100644 --- a/example/package.json +++ b/example/package.json @@ -10,6 +10,7 @@ "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.10", + "react-native-gesture-handler": "~2.30.0", "react-native-reanimated": "4.2.1", "react-native-safe-area-context": "~5.6.2", "react-native-web": "^0.21.0", diff --git a/example/src/App.tsx b/example/src/App.tsx index e2b3f07..b78ac82 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { StyleSheet, Text, View, useColorScheme } from 'react-native'; import { StatusBar } from 'expo-status-bar'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import { darkTheme, lightTheme } from 'react-native-datagrid'; import type { DataGridTheme } from 'react-native-datagrid'; @@ -20,55 +21,59 @@ export default function App() { const [showPerf, setShowPerf] = useState(false); return ( - - - - - Work orders - - setMode(key as Mode)} - /> - setDarkOverride(key === 'dark')} - /> - setShowPerf(key === 'on')} - /> + + + + + + + Work orders + + + setMode(key as Mode)} + /> + setDarkOverride(key === 'dark')} + /> + setShowPerf(key === 'on')} + /> + - - {showPerf && } - {mode === 'local' ? ( - - ) : ( - - )} - - + {showPerf && } + {mode === 'local' ? ( + + ) : ( + + )} + + + ); } diff --git a/package.json b/package.json index e0bb422..dccf717 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "react": "19.2.0", "react-native": "0.83.10", "react-native-builder-bob": "^0.43.1", + "react-native-gesture-handler": "~2.30.0", "react-native-reanimated": "4.2.1", "react-native-worklets": "0.7.4", "release-it": "^21.0.1", @@ -100,6 +101,7 @@ "@shopify/flash-list": ">=2.0.0", "react": "*", "react-native": "*", + "react-native-gesture-handler": ">=2.20.0", "react-native-reanimated": ">=4.0.0", "react-native-worklets": ">=0.5.0" }, diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index fa588d4..8fcf7ad 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -10,10 +10,11 @@ import { import type { LayoutChangeEvent } from 'react-native'; import { FlashList } from '@shopify/flash-list'; import type { ListRenderItemInfo } from '@shopify/flash-list'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { - useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, + withDecay, } from 'react-native-reanimated'; import { GridHeader } from './GridHeader'; import { GridRow } from './GridRow'; @@ -223,11 +224,11 @@ export function DataGrid({ const rowsPressable = selectionMode !== 'none' || onRowPress !== undefined; // Horizontal scroll offset drives the header and pinned columns on the UI thread. + // Horizontal scrolling is driven by a pan gesture instead of a ScrollView: the content, + // header and pinned columns all read this one value in the same frame. A native ScrollView + // reports its offset a frame late on Android, which made the pinned columns flicker. const scrollX = useSharedValue(0); - const onScroll = useAnimatedScrollHandler((event) => { - 'worklet'; - scrollX.value = event.contentOffset.x; - }); + const maxScrollX = useSharedValue(0); const pinnedStyle = useAnimatedStyle(() => { 'worklet'; return { transform: [{ translateX: scrollX.value }] }; @@ -237,6 +238,30 @@ export function DataGrid({ return { transform: [{ translateX: -scrollX.value }] }; }); + const panGesture = useMemo( + () => + Gesture.Pan() + .activeOffsetX([-8, 8]) + .failOffsetY([-12, 12]) + .onChange((event) => { + 'worklet'; + const next = scrollX.value - event.changeX; + scrollX.value = Math.min(Math.max(next, 0), maxScrollX.value); + }) + .onEnd((event) => { + 'worklet'; + scrollX.value = withDecay({ + velocity: -event.velocityX, + clamp: [0, maxScrollX.value], + }); + }), + [scrollX, maxScrollX] + ); + const contentStyle = useAnimatedStyle(() => { + 'worklet'; + return { transform: [{ translateX: -scrollX.value }] }; + }); + const [bodySize, setBodySize] = useState({ width: 0, height: 0 }); const onBodyLayout = useCallback((event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; @@ -246,6 +271,13 @@ export function DataGrid({ }, []); const bodyHeight = bodySize.height; + // Keep the offset inside the content when the columns or the viewport change. + useEffect(() => { + const max = Math.max(0, layout.totalWidth - bodySize.width); + maxScrollX.value = max; + if (scrollX.value > max) scrollX.value = max; + }, [layout.totalWidth, bodySize.width, maxScrollX, scrollX]); + const retryButton = onRetry ? ( ({ ) : ( bodyHeight > 0 && ( - - + + ({ onRefresh={onRefresh} testID={testID ? `${testID}-list` : undefined} /> - - + + ) )} @@ -394,7 +426,7 @@ export function DataGrid({ const styles = StyleSheet.create({ root: { flex: 1, overflow: 'hidden' }, - body: { flex: 1 }, + body: { flex: 1, overflow: 'hidden' }, empty: { flex: 1, alignItems: 'center', diff --git a/yarn.lock b/yarn.lock index d822d75..347c7e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1903,6 +1903,15 @@ __metadata: languageName: node linkType: hard +"@egjs/hammerjs@npm:^2.0.17": + version: 2.0.17 + resolution: "@egjs/hammerjs@npm:2.0.17" + dependencies: + "@types/hammerjs": "npm:^2.0.36" + checksum: 10c0/dbedc15a0e633f887c08394bd636faf6a3abd05726dc0909a0e01209d5860a752d9eca5e512da623aecfabe665f49f1d035de3103eb2f9022c5cea692f9cc9be + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.1": version: 4.10.1 resolution: "@eslint-community/eslint-utils@npm:4.10.1" @@ -3752,6 +3761,13 @@ __metadata: languageName: node linkType: hard +"@types/hammerjs@npm:^2.0.36": + version: 2.0.46 + resolution: "@types/hammerjs@npm:2.0.46" + checksum: 10c0/f3c1cb20dc2f0523f7b8c76065078544d50d8ae9b0edc1f62fed657210ed814266ff2dfa835d2c157a075991001eec3b64c88bf92e3e6e895c0db78d05711d06 + languageName: node + linkType: hard + "@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1": version: 2.0.6 resolution: "@types/istanbul-lib-coverage@npm:2.0.6" @@ -7194,6 +7210,15 @@ __metadata: languageName: node linkType: hard +"hoist-non-react-statics@npm:^3.3.0": + version: 3.3.2 + resolution: "hoist-non-react-statics@npm:3.3.2" + dependencies: + react-is: "npm:^16.7.0" + checksum: 10c0/fe0889169e845d738b59b64badf5e55fa3cf20454f9203d1eb088df322d49d4318df774828e789898dcb280e8a5521bb59b3203385662ca5e9218a6ca5820e74 + languageName: node + linkType: hard + "hosted-git-info@npm:^7.0.0": version: 7.0.2 resolution: "hosted-git-info@npm:7.0.2" @@ -10252,7 +10277,7 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^16.13.1": +"react-is@npm:^16.13.1, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" checksum: 10c0/33977da7a5f1a287936a0c85639fec6ca74f4f15ef1e59a6bc20338fc73dc69555381e211f7a3529b8150a1f71e4225525b41b60b52965bda53ce7d47377ada1 @@ -10310,6 +10335,7 @@ __metadata: react-dom: "npm:19.2.0" react-native: "npm:0.83.10" react-native-builder-bob: "npm:^0.43.1" + react-native-gesture-handler: "npm:~2.30.0" react-native-monorepo-config: "npm:^0.4.0" react-native-reanimated: "npm:4.2.1" react-native-safe-area-context: "npm:~5.6.2" @@ -10344,6 +10370,7 @@ __metadata: react: "npm:19.2.0" react-native: "npm:0.83.10" react-native-builder-bob: "npm:^0.43.1" + react-native-gesture-handler: "npm:~2.30.0" react-native-reanimated: "npm:4.2.1" react-native-worklets: "npm:0.7.4" release-it: "npm:^21.0.1" @@ -10353,11 +10380,26 @@ __metadata: "@shopify/flash-list": ">=2.0.0" react: "*" react-native: "*" + react-native-gesture-handler: ">=2.20.0" react-native-reanimated: ">=4.0.0" react-native-worklets: ">=0.5.0" languageName: unknown linkType: soft +"react-native-gesture-handler@npm:~2.30.0": + version: 2.30.1 + resolution: "react-native-gesture-handler@npm:2.30.1" + dependencies: + "@egjs/hammerjs": "npm:^2.0.17" + hoist-non-react-statics: "npm:^3.3.0" + invariant: "npm:^2.2.4" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10c0/6fdace725cfc08a8f2ab4b307d8d7f11438ebe38038443f7057bb1fd10554fcec8ce2816e8da131ae9b850c9be5e3bbef752b90240f5c91595ab20fd03ad6876 + languageName: node + linkType: hard + "react-native-is-edge-to-edge@npm:1.2.1": version: 1.2.1 resolution: "react-native-is-edge-to-edge@npm:1.2.1"