├── .gitignore ├── README.md ├── app.json ├── app ├── (tabs) │ ├── _layout.tsx │ ├── explore.tsx │ └── index.tsx ├── +html.tsx ├── +not-found.tsx └── _layout.tsx ├── assets ├── fonts │ └── SpaceMono-Regular.ttf └── images │ ├── adaptive-icon.png │ ├── favicon.png │ ├── icon.png │ ├── partial-react-logo.png │ ├── react-logo.png │ ├── react-logo@2x.png │ ├── react-logo@3x.png │ └── splash.png ├── babel.config.js ├── components ├── Collapsible.tsx ├── ExternalLink.tsx ├── HelloWave.tsx ├── ParallaxScrollView.tsx ├── ThemedText.tsx ├── ThemedView.tsx ├── __tests__ │ ├── ThemedText-test.tsx │ └── __snapshots__ │ │ └── ThemedText-test.tsx.snap └── navigation │ └── TabBarIcon.tsx ├── constants └── Colors.ts ├── hooks ├── useColorScheme.ts ├── useColorScheme.web.ts └── useThemeColor.ts ├── package.json ├── patches └── react-native-gesture-handler+2.18.1.patch ├── scripts └── reset-project.js ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | npm-debug.* 5 | *.jks 6 | *.p8 7 | *.p12 8 | *.key 9 | *.mobileprovision 10 | *.orig.* 11 | web-build/ 12 | 13 | # macOS 14 | .DS_Store 15 | 16 | # @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb 17 | # The following patterns were generated by expo-cli 18 | 19 | expo-env.d.ts 20 | # @end expo-cli 21 | 22 | ios/ 23 | android/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FlashList Drag to Reorder 2 | 3 | ## Motivation 4 | 5 | Inspired by [Drag and Drop List in React Native](https://www.youtube.com/watch?v=qeKP2A7bLUw) among other examples, I wanted to learn how to make this work with Shopify's FlashList. Many examples out there are implemented via ``, `react-native-flashdrag-list`, `react-native-draglist` or something else. These didn't fit my needs as I wanted the recycler view to be involved. 6 | 7 | I also need it to support web. 8 | 9 | ## Screenshots 10 | 11 | https://github.com/user-attachments/assets/3dc80750-2cc5-428c-a8b9-d82b1d4d1ba1 12 | 13 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "ExpoGestureHandler", 4 | "slug": "ExpoGestureHandler", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "myapp", 9 | "userInterfaceStyle": "automatic", 10 | "splash": { 11 | "image": "./assets/images/splash.png", 12 | "resizeMode": "contain", 13 | "backgroundColor": "#ffffff" 14 | }, 15 | "ios": { 16 | "supportsTablet": true, 17 | "bundleIdentifier": "com.frankcalise.ExpoGestureHandler" 18 | }, 19 | "android": { 20 | "adaptiveIcon": { 21 | "foregroundImage": "./assets/images/adaptive-icon.png", 22 | "backgroundColor": "#ffffff" 23 | }, 24 | "package": "com.frankcalise.ExpoGestureHandler" 25 | }, 26 | "web": { 27 | "bundler": "metro", 28 | "output": "static", 29 | "favicon": "./assets/images/favicon.png" 30 | }, 31 | "plugins": [ 32 | "expo-router" 33 | ], 34 | "experiments": { 35 | "typedRoutes": true 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/(tabs)/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Tabs } from 'expo-router'; 2 | import React from 'react'; 3 | 4 | import { TabBarIcon } from '@/components/navigation/TabBarIcon'; 5 | import { Colors } from '@/constants/Colors'; 6 | import { useColorScheme } from '@/hooks/useColorScheme'; 7 | 8 | export default function TabLayout() { 9 | const colorScheme = useColorScheme(); 10 | 11 | return ( 12 | 17 | ( 22 | 23 | ), 24 | }} 25 | /> 26 | ( 31 | 32 | ), 33 | }} 34 | /> 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /app/(tabs)/explore.tsx: -------------------------------------------------------------------------------- 1 | import Ionicons from '@expo/vector-icons/Ionicons'; 2 | import { StyleSheet, Image, Platform } from 'react-native'; 3 | 4 | import { Collapsible } from '@/components/Collapsible'; 5 | import { ExternalLink } from '@/components/ExternalLink'; 6 | import ParallaxScrollView from '@/components/ParallaxScrollView'; 7 | import { ThemedText } from '@/components/ThemedText'; 8 | import { ThemedView } from '@/components/ThemedView'; 9 | 10 | export default function TabTwoScreen() { 11 | return ( 12 | }> 15 | 16 | Explore 17 | 18 | This app includes example code to help you get started. 19 | 20 | 21 | This app has two screens:{' '} 22 | app/(tabs)/index.tsx and{' '} 23 | app/(tabs)/explore.tsx 24 | 25 | 26 | The layout file in app/(tabs)/_layout.tsx{' '} 27 | sets up the tab navigator. 28 | 29 | 30 | Learn more 31 | 32 | 33 | 34 | 35 | You can open this project on Android, iOS, and the web. To open the web version, press{' '} 36 | w in the terminal running this project. 37 | 38 | 39 | 40 | 41 | For static images, you can use the @2x and{' '} 42 | @3x suffixes to provide files for 43 | different screen densities 44 | 45 | 46 | 47 | Learn more 48 | 49 | 50 | 51 | 52 | Open app/_layout.tsx to see how to load{' '} 53 | 54 | custom fonts such as this one. 55 | 56 | 57 | 58 | Learn more 59 | 60 | 61 | 62 | 63 | This template has light and dark mode support. The{' '} 64 | useColorScheme() hook lets you inspect 65 | what the user's current color scheme is, and so you can adjust UI colors accordingly. 66 | 67 | 68 | Learn more 69 | 70 | 71 | 72 | 73 | This template includes an example of an animated component. The{' '} 74 | components/HelloWave.tsx component uses 75 | the powerful react-native-reanimated library 76 | to create a waving hand animation. 77 | 78 | {Platform.select({ 79 | ios: ( 80 | 81 | The components/ParallaxScrollView.tsx{' '} 82 | component provides a parallax effect for the header image. 83 | 84 | ), 85 | })} 86 | 87 | 88 | ); 89 | } 90 | 91 | const styles = StyleSheet.create({ 92 | headerImage: { 93 | color: '#808080', 94 | bottom: -90, 95 | left: -35, 96 | position: 'absolute', 97 | }, 98 | titleContainer: { 99 | flexDirection: 'row', 100 | gap: 8, 101 | }, 102 | }); 103 | -------------------------------------------------------------------------------- /app/(tabs)/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button, Dimensions, View } from "react-native"; 3 | 4 | import { ThemedText } from "@/components/ThemedText"; 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; 6 | import { FlashList } from "@shopify/flash-list"; 7 | import { 8 | Gesture, 9 | GestureDetector, 10 | GestureType, 11 | PanGestureHandler, 12 | } from "react-native-gesture-handler"; 13 | import Animated, { 14 | runOnJS, 15 | scrollTo, 16 | useAnimatedRef, 17 | useAnimatedScrollHandler, 18 | useAnimatedStyle, 19 | useSharedValue, 20 | } from "react-native-reanimated"; 21 | 22 | /** 23 | * Things learned: 24 | * 1. The error handling provided by a native build is much better than the Expo Go client. 25 | * 2. Worklets must be defined before they are used (doing 1 earlier would have helped realize this sooner) 26 | * 3. setState is not allowed in a worklet, but you can use runOnJS to call a function that uses setState 27 | * 4. To use a `GestureDetector` with FlashList, you need to pass a ref of the detector to 28 | * the `simultaneousHandlers` prop to the FlashList via `overrideProps` 29 | */ 30 | 31 | const AnimatedFlashList = Animated.createAnimatedComponent(FlashList); 32 | 33 | const colorMap: Record = {}; 34 | const ROW_HEIGHT = 70; 35 | const HEADER_HEIGHT = 0; 36 | 37 | function getRandomColor() { 38 | const letters = "0123456789ABCDEF"; 39 | let color = "#"; 40 | for (let i = 0; i < 6; i++) { 41 | color += letters[Math.floor(Math.random() * 16)]; 42 | } 43 | return color; 44 | } 45 | 46 | const INITIAL_DATA = Array.from(Array(200), (_, i) => { 47 | colorMap[i] = getRandomColor(); 48 | return i; 49 | }); 50 | 51 | export default function HomeScreen() { 52 | const insets = useSafeAreaInsets(); 53 | const listRef = useAnimatedRef>(); 54 | const scrollOffsetY = useSharedValue(0); 55 | const dragItemAbsY = useSharedValue(0); 56 | const headerHeight = useSharedValue(0); 57 | const [enabled, setEnabled] = React.useState(true); 58 | const [isDraggingItem, setIsDraggingItem] = React.useState(false); 59 | const [draggingIndex, setDraggingIndex] = React.useState(-1); 60 | const [floatingOverIndex, setFloatingOverIndex] = React.useState(-1); 61 | const [data, setData] = React.useState(INITIAL_DATA); 62 | 63 | // TODO get the list height 64 | const listHeight = useSharedValue(0); 65 | 66 | /** 67 | * ! Worklets must be defined before they are used, otherwise you will experience a crash. 68 | * 69 | * Calculate header height if your list would render ListHeaderComponent 70 | */ 71 | const getIndexFromY = React.useCallback( 72 | (y: number) => { 73 | "worklet"; 74 | 75 | return Math.floor( 76 | Math.min( 77 | data.length - 1, 78 | Math.max( 79 | 0, 80 | Math.floor(y + scrollOffsetY.value - headerHeight.value) / 81 | ROW_HEIGHT 82 | ) 83 | ) 84 | ); 85 | }, 86 | [data, scrollOffsetY, insets, headerHeight] 87 | ); 88 | 89 | // TODO doesn't seem to work on Android 90 | const scrollLogic = React.useCallback( 91 | ({ absoluteY }: { absoluteY: number }) => { 92 | "worklet"; 93 | const lowerBound = 1.5 * ROW_HEIGHT; 94 | const upperBound = scrollOffsetY.value + listHeight.value; 95 | 96 | // scroll speed is proportional to the item height (the bigger the item, the faster it scrolls) 97 | const scrollSpeed = ROW_HEIGHT * 0.8; 98 | 99 | if (absoluteY <= lowerBound) { 100 | // while scrolling to the top of the list 101 | const nextPosition = scrollOffsetY.value - scrollSpeed; 102 | scrollTo(listRef, 0, Math.max(nextPosition, 0), false); 103 | } else if (absoluteY + scrollOffsetY.value >= upperBound) { 104 | // while scrolling to the bottom of the list 105 | const nextPosition = scrollOffsetY.value + scrollSpeed; 106 | scrollTo(listRef, 0, Math.max(nextPosition, 0), false); 107 | } 108 | }, 109 | [scrollOffsetY, listRef] 110 | ); 111 | 112 | /** 113 | * This gesture will run on a native thread, if you try to access JS code, such as setState, it will crash. 114 | * Thanks Hirbod - https://github.com/software-mansion/react-native-gesture-handler/discussions/2061#discussioncomment-2794942 115 | */ 116 | const panGesture = Gesture.Pan() 117 | /** 118 | * ! Issue with toggling this from UI 119 | * Tracked here: https://github.com/software-mansion/react-native-gesture-handler/issues/3074 120 | */ 121 | .enabled(enabled) 122 | .maxPointers(1) 123 | .activateAfterLongPress(300) 124 | .onBegin(() => { 125 | console.log("onBegin"); 126 | }) 127 | .onStart(({ y, absoluteY, oldState, state }) => { 128 | // Get item index from the Y position where the gesture started 129 | const index = getIndexFromY(y); 130 | console.log("onStart", { index, y, absoluteY, oldState, state }); 131 | runOnJS(setIsDraggingItem)(true); 132 | runOnJS(setDraggingIndex)(index); 133 | runOnJS(setFloatingOverIndex)(index); 134 | dragItemAbsY.value = absoluteY; 135 | }) 136 | .onUpdate(({ absoluteY, y }) => { 137 | scrollLogic({ absoluteY }); 138 | dragItemAbsY.value = absoluteY; 139 | 140 | const index = getIndexFromY(y); 141 | runOnJS(setFloatingOverIndex)(index); 142 | }) 143 | .onFinalize(() => { 144 | // update the data array to reflect the new order 145 | const updatedData = [...data]; 146 | const [removed] = updatedData.splice(draggingIndex, 1); 147 | // when dragging down, we have to subtract 1 from the floating index 148 | // but when dragging the item up, just insert it as is 149 | const directionOffset = floatingOverIndex > draggingIndex ? 1 : 0; 150 | updatedData.splice(floatingOverIndex - directionOffset, 0, removed); 151 | 152 | runOnJS(setData)(updatedData); 153 | 154 | // reset things 155 | runOnJS(setIsDraggingItem)(false); 156 | runOnJS(setDraggingIndex)(-1); 157 | runOnJS(setFloatingOverIndex)(-1); 158 | }); 159 | 160 | /** 161 | * Ran into `useAnimatedScrollHandler` not working with the version that ships with Expo Go 162 | * https://github.com/software-mansion/react-native-reanimated/issues/5941 163 | */ 164 | const onScroll = useAnimatedScrollHandler({ 165 | onScroll: ({ contentOffset: { y } }) => { 166 | console.log("onscroll", y); 167 | scrollOffsetY.value = y; 168 | }, 169 | }); 170 | 171 | const $floatingItem = useAnimatedStyle(() => { 172 | return { 173 | top: dragItemAbsY.value, 174 | }; 175 | }); 176 | 177 | // TODO extract list item into component to use inbetween the floating Animated layer and for the FlashList 178 | return ( 179 | 180 | {isDraggingItem && ( 181 | 192 | 205 | 211 | 212 | Dragging idx {draggingIndex} (val={data[draggingIndex]}) 213 | 214 | 215 | Float over idx {floatingOverIndex} (val= 216 | {data[floatingOverIndex]}) 217 | 218 | 219 | 220 | 221 | )} 222 | 223 | 224 | { 231 | listHeight.value = nativeEvent.layout.height; 232 | }} 233 | ListHeaderComponent={ 234 | { 236 | headerHeight.value = nativeEvent.layout.height; 237 | }} 238 | style={{ height: 50 }} 239 | > 240 |