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/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,
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"