├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .npmignore ├── .prettierignore ├── .vscode └── settings.json ├── Example ├── .expo-shared │ └── assets.json ├── .gitignore ├── App.tsx ├── app.json ├── assets │ ├── fonts │ │ └── SpaceMono-Regular.ttf │ └── images │ │ ├── adaptive-icon.png │ │ ├── favicon.png │ │ ├── icon.png │ │ └── splash.png ├── babel.config.js ├── components │ ├── EditScreenInfo.tsx │ ├── StyledText.tsx │ ├── Themed.tsx │ └── __tests__ │ │ └── StyledText-test.js ├── constants │ ├── Colors.ts │ └── Layout.ts ├── hooks │ ├── useCachedResources.ts │ └── useColorScheme.ts ├── metro.config.js ├── navigation │ ├── LinkingConfiguration.ts │ └── index.tsx ├── package.json ├── screens │ ├── BasicScreen.tsx │ ├── HorizontalScreen.tsx │ ├── NestedScreen.tsx │ ├── NotFoundScreen.tsx │ └── SwipeableScreen.tsx ├── tsconfig.json ├── types.tsx ├── utils │ └── index.ts └── yarn.lock ├── LICENSE.txt ├── README.md ├── babel.config.js ├── jest-setup.js ├── package.json ├── src ├── components │ ├── CellDecorators.tsx │ ├── CellRendererComponent.tsx │ ├── DraggableFlatList.tsx │ ├── NestableDraggableFlatList.tsx │ ├── NestableScrollContainer.tsx │ ├── PlaceholderItem.tsx │ ├── RowItem.tsx │ └── ScrollOffsetListener.tsx ├── constants.ts ├── context │ ├── animatedValueContext.tsx │ ├── cellContext.tsx │ ├── draggableFlatListContext.tsx │ ├── nestableScrollContainerContext.tsx │ ├── propsContext.tsx │ └── refContext.tsx ├── hooks │ ├── useAutoScroll.tsx │ ├── useCellTranslate.tsx │ ├── useNestedAutoScroll.tsx │ ├── useOnCellActiveAnimation.ts │ └── useStableCallback.ts ├── index.tsx ├── types.ts └── utils.ts ├── tests └── index.test.js ├── tsconfig.json └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [computerjazz] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | *This package has external dependencies of [react-native-reanimated](https://github.com/software-mansion/react-native-reanimated) and [react-native-gesture-handler](https://github.com/software-mansion/react-native-gesture-handler) which must be installed separately. Before opening an issue related to animations or gestures please verify that you have completed ALL installation steps, including the [changes to MainActivity.](https://software-mansion.github.io/react-native-gesture-handler/docs/getting-started.html#android)* 11 | 12 | **Describe the bug** 13 | A clear and concise description of what the bug is. 14 | 15 | **To Reproduce** 16 | Link a snack if possible. 17 | 18 | **Platform & Dependencies** 19 | Please list any applicable dependencies in addition to those below (react-navigation etc). 20 | - react-native-draggable-flatlist version: 21 | - Platform: 22 | - React Native or Expo version: 23 | - Reanimated version: 24 | - React Native Gesture Handler version: 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/**/* 2 | /lib 3 | coverage/ 4 | .idea 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /Example 2 | /tests -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore all markdown files: 2 | *.md -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /Example/.expo-shared/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "e997a5256149a4b76e6bfd6cbf519c5e5a0f1d278a3d8fa1253022b03c90473b": true, 3 | "af683c96e0ffd2cf81287651c9433fa44debc1220ca7cb431fe482747f34a505": true, 4 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, 5 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true 6 | } 7 | -------------------------------------------------------------------------------- /Example/.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 | -------------------------------------------------------------------------------- /Example/App.tsx: -------------------------------------------------------------------------------- 1 | import { StatusBar } from "expo-status-bar"; 2 | import { SafeAreaProvider } from "react-native-safe-area-context"; 3 | 4 | import useCachedResources from "./hooks/useCachedResources"; 5 | import useColorScheme from "./hooks/useColorScheme"; 6 | import Navigation from "./navigation"; 7 | 8 | export default function App() { 9 | const isLoadingComplete = useCachedResources(); 10 | const colorScheme = useColorScheme(); 11 | 12 | if (!isLoadingComplete) { 13 | return null; 14 | } else { 15 | return ( 16 | 17 | 18 | 19 | 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Example/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "Example", 4 | "slug": "Example", 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 | "updates": { 16 | "fallbackToCacheTimeout": 0 17 | }, 18 | "assetBundlePatterns": ["**/*"], 19 | "ios": { 20 | "supportsTablet": true 21 | }, 22 | "android": { 23 | "adaptiveIcon": { 24 | "foregroundImage": "./assets/images/adaptive-icon.png", 25 | "backgroundColor": "#ffffff" 26 | } 27 | }, 28 | "web": { 29 | "favicon": "./assets/images/favicon.png" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Example/assets/fonts/SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/computerjazz/react-native-draggable-flatlist/5b2f9665a67c639b8a691cf1b8ea717161a7a7f9/Example/assets/fonts/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /Example/assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/computerjazz/react-native-draggable-flatlist/5b2f9665a67c639b8a691cf1b8ea717161a7a7f9/Example/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /Example/assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/computerjazz/react-native-draggable-flatlist/5b2f9665a67c639b8a691cf1b8ea717161a7a7f9/Example/assets/images/favicon.png -------------------------------------------------------------------------------- /Example/assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/computerjazz/react-native-draggable-flatlist/5b2f9665a67c639b8a691cf1b8ea717161a7a7f9/Example/assets/images/icon.png -------------------------------------------------------------------------------- /Example/assets/images/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/computerjazz/react-native-draggable-flatlist/5b2f9665a67c639b8a691cf1b8ea717161a7a7f9/Example/assets/images/splash.png -------------------------------------------------------------------------------- /Example/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ["babel-preset-expo"], 5 | plugins: ["react-native-reanimated/plugin"], 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /Example/components/EditScreenInfo.tsx: -------------------------------------------------------------------------------- 1 | import * as WebBrowser from "expo-web-browser"; 2 | import { StyleSheet, TouchableOpacity } from "react-native"; 3 | 4 | import Colors from "../constants/Colors"; 5 | import { MonoText } from "./StyledText"; 6 | import { Text, View } from "./Themed"; 7 | 8 | export default function EditScreenInfo({ path }: { path: string }) { 9 | return ( 10 | 11 | 12 | 17 | Open up the code for this screen: 18 | 19 | 20 | 25 | {path} 26 | 27 | 28 | 33 | Change any of the text, save the file, and your app will automatically 34 | update. 35 | 36 | 37 | 38 | 39 | 40 | 41 | Tap here if your app doesn't automatically update after making 42 | changes 43 | 44 | 45 | 46 | 47 | ); 48 | } 49 | 50 | function handleHelpPress() { 51 | WebBrowser.openBrowserAsync( 52 | "https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet" 53 | ); 54 | } 55 | 56 | const styles = StyleSheet.create({ 57 | getStartedContainer: { 58 | alignItems: "center", 59 | marginHorizontal: 50, 60 | }, 61 | homeScreenFilename: { 62 | marginVertical: 7, 63 | }, 64 | codeHighlightContainer: { 65 | borderRadius: 3, 66 | paddingHorizontal: 4, 67 | }, 68 | getStartedText: { 69 | fontSize: 17, 70 | lineHeight: 24, 71 | textAlign: "center", 72 | }, 73 | helpContainer: { 74 | marginTop: 15, 75 | marginHorizontal: 20, 76 | alignItems: "center", 77 | }, 78 | helpLink: { 79 | paddingVertical: 15, 80 | }, 81 | helpLinkText: { 82 | textAlign: "center", 83 | }, 84 | }); 85 | -------------------------------------------------------------------------------- /Example/components/StyledText.tsx: -------------------------------------------------------------------------------- 1 | import { Text, TextProps } from "./Themed"; 2 | 3 | export function MonoText(props: TextProps) { 4 | return ( 5 | 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /Example/components/Themed.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about Light and Dark modes: 3 | * https://docs.expo.io/guides/color-schemes/ 4 | */ 5 | 6 | import { Text as DefaultText, View as DefaultView } from "react-native"; 7 | 8 | import Colors from "../constants/Colors"; 9 | import useColorScheme from "../hooks/useColorScheme"; 10 | 11 | export function useThemeColor( 12 | props: { light?: string; dark?: string }, 13 | colorName: keyof typeof Colors.light & keyof typeof Colors.dark 14 | ) { 15 | const theme = useColorScheme(); 16 | const colorFromProps = props[theme]; 17 | 18 | if (colorFromProps) { 19 | return colorFromProps; 20 | } else { 21 | return Colors[theme][colorName]; 22 | } 23 | } 24 | 25 | type ThemeProps = { 26 | lightColor?: string; 27 | darkColor?: string; 28 | }; 29 | 30 | export type TextProps = ThemeProps & DefaultText["props"]; 31 | export type ViewProps = ThemeProps & DefaultView["props"]; 32 | 33 | export function Text(props: TextProps) { 34 | const { style, lightColor, darkColor, ...otherProps } = props; 35 | const color = useThemeColor({ light: lightColor, dark: darkColor }, "text"); 36 | 37 | return ; 38 | } 39 | 40 | export function View(props: ViewProps) { 41 | const { style, lightColor, darkColor, ...otherProps } = props; 42 | const backgroundColor = useThemeColor( 43 | { light: lightColor, dark: darkColor }, 44 | "background" 45 | ); 46 | 47 | return ; 48 | } 49 | -------------------------------------------------------------------------------- /Example/components/__tests__/StyledText-test.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import renderer from "react-test-renderer"; 3 | 4 | import { MonoText } from "../StyledText"; 5 | 6 | it(`renders correctly`, () => { 7 | const tree = renderer.create(Snapshot test!).toJSON(); 8 | 9 | expect(tree).toMatchSnapshot(); 10 | }); 11 | -------------------------------------------------------------------------------- /Example/constants/Colors.ts: -------------------------------------------------------------------------------- 1 | const tintColorLight = "#2f95dc"; 2 | const tintColorDark = "#fff"; 3 | 4 | export default { 5 | light: { 6 | text: "#000", 7 | background: "#fff", 8 | tint: tintColorLight, 9 | tabIconDefault: "#ccc", 10 | tabIconSelected: tintColorLight, 11 | }, 12 | dark: { 13 | text: "#fff", 14 | background: "#000", 15 | tint: tintColorDark, 16 | tabIconDefault: "#ccc", 17 | tabIconSelected: tintColorDark, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /Example/constants/Layout.ts: -------------------------------------------------------------------------------- 1 | import { Dimensions } from "react-native"; 2 | 3 | const width = Dimensions.get("window").width; 4 | const height = Dimensions.get("window").height; 5 | 6 | export default { 7 | window: { 8 | width, 9 | height, 10 | }, 11 | isSmallDevice: width < 375, 12 | }; 13 | -------------------------------------------------------------------------------- /Example/hooks/useCachedResources.ts: -------------------------------------------------------------------------------- 1 | import { FontAwesome } from "@expo/vector-icons"; 2 | import * as Font from "expo-font"; 3 | import * as SplashScreen from "expo-splash-screen"; 4 | import { useEffect, useState } from "react"; 5 | 6 | export default function useCachedResources() { 7 | const [isLoadingComplete, setLoadingComplete] = useState(false); 8 | 9 | // Load any resources or data that we need prior to rendering the app 10 | useEffect(() => { 11 | async function loadResourcesAndDataAsync() { 12 | try { 13 | SplashScreen.preventAutoHideAsync(); 14 | 15 | // Load fonts 16 | await Font.loadAsync({ 17 | ...FontAwesome.font, 18 | "space-mono": require("../assets/fonts/SpaceMono-Regular.ttf"), 19 | }); 20 | } catch (e) { 21 | // We might want to provide this error information to an error reporting service 22 | console.warn(e); 23 | } finally { 24 | setLoadingComplete(true); 25 | SplashScreen.hideAsync(); 26 | } 27 | } 28 | 29 | loadResourcesAndDataAsync(); 30 | }, []); 31 | 32 | return isLoadingComplete; 33 | } 34 | -------------------------------------------------------------------------------- /Example/hooks/useColorScheme.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ColorSchemeName, 3 | useColorScheme as _useColorScheme, 4 | } from "react-native"; 5 | 6 | // The useColorScheme value is always either light or dark, but the built-in 7 | // type suggests that it can be null. This will not happen in practice, so this 8 | // makes it a bit easier to work with. 9 | export default function useColorScheme(): NonNullable { 10 | return _useColorScheme() as NonNullable; 11 | } 12 | -------------------------------------------------------------------------------- /Example/metro.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Metro configuration for React Native 3 | * https://github.com/facebook/react-native 4 | * 5 | * @format 6 | */ 7 | const path = require("path"); 8 | const extraNodeModules = { 9 | "react-native-draggable-flatlist": path.resolve(__dirname + "/../src"), 10 | }; 11 | const watchFolders = [path.resolve(__dirname + "/../src")]; 12 | module.exports = { 13 | transformer: { 14 | getTransformOptions: async () => ({ 15 | transform: { 16 | experimentalImportSupport: false, 17 | inlineRequires: false, 18 | }, 19 | }), 20 | }, 21 | resolver: { 22 | extraNodeModules: new Proxy(extraNodeModules, { 23 | get: (target, name) => 24 | //redirects dependencies referenced from target/ to local node_modules 25 | name in target 26 | ? target[name] 27 | : path.join(process.cwd(), `node_modules/${name}`), 28 | }), 29 | }, 30 | watchFolders, 31 | }; 32 | -------------------------------------------------------------------------------- /Example/navigation/LinkingConfiguration.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about deep linking with React Navigation 3 | * https://reactnavigation.org/docs/deep-linking 4 | * https://reactnavigation.org/docs/configuring-links 5 | */ 6 | 7 | import { LinkingOptions } from "@react-navigation/native"; 8 | import * as Linking from "expo-linking"; 9 | 10 | import { RootStackParamList } from "../types"; 11 | 12 | const linking: LinkingOptions = { 13 | prefixes: [Linking.makeUrl("/")], 14 | config: { 15 | screens: { 16 | Root: { 17 | screens: { 18 | TabOne: { 19 | screens: { 20 | Basic: "basic", 21 | }, 22 | }, 23 | TabTwo: { 24 | screens: { 25 | Swipeable: "swipeable", 26 | }, 27 | }, 28 | }, 29 | }, 30 | NotFound: "*", 31 | }, 32 | }, 33 | }; 34 | 35 | export default linking; 36 | -------------------------------------------------------------------------------- /Example/navigation/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * If you are not familiar with React Navigation, refer to the "Fundamentals" guide: 3 | * https://reactnavigation.org/docs/getting-started 4 | * 5 | */ 6 | import { FontAwesome } from "@expo/vector-icons"; 7 | import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; 8 | import { 9 | NavigationContainer, 10 | DefaultTheme, 11 | DarkTheme, 12 | } from "@react-navigation/native"; 13 | import { createNativeStackNavigator } from "@react-navigation/native-stack"; 14 | import * as React from "react"; 15 | import { ColorSchemeName, Pressable } from "react-native"; 16 | 17 | import Colors from "../constants/Colors"; 18 | import useColorScheme from "../hooks/useColorScheme"; 19 | import NotFoundScreen from "../screens/NotFoundScreen"; 20 | import BasicScreen from "../screens/BasicScreen"; 21 | import SwipeableScreen from "../screens/SwipeableScreen"; 22 | import { 23 | RootStackParamList, 24 | RootTabParamList, 25 | RootTabScreenProps, 26 | } from "../types"; 27 | import LinkingConfiguration from "./LinkingConfiguration"; 28 | import NestedScreen from "../screens/NestedScreen"; 29 | import HorizontalScreen from "../screens/HorizontalScreen"; 30 | 31 | export default function Navigation({ 32 | colorScheme, 33 | }: { 34 | colorScheme: ColorSchemeName; 35 | }) { 36 | return ( 37 | 41 | 42 | 43 | ); 44 | } 45 | 46 | /** 47 | * A root stack navigator is often used for displaying modals on top of all other content. 48 | * https://reactnavigation.org/docs/modal 49 | */ 50 | const Stack = createNativeStackNavigator(); 51 | 52 | function RootNavigator() { 53 | return ( 54 | 55 | 60 | 61 | ); 62 | } 63 | 64 | /** 65 | * A bottom tab navigator displays tab buttons on the bottom of the display to switch screens. 66 | * https://reactnavigation.org/docs/bottom-tab-navigator 67 | */ 68 | const BottomTab = createBottomTabNavigator(); 69 | 70 | function BottomTabNavigator() { 71 | const colorScheme = useColorScheme(); 72 | 73 | return ( 74 | 80 | ) => ({ 84 | title: "Basic", 85 | tabBarIcon: ({ color }) => , 86 | })} 87 | /> 88 | ( 94 | 95 | ), 96 | }} 97 | /> 98 | , 104 | }} 105 | /> 106 | ( 112 | 113 | ), 114 | }} 115 | /> 116 | 117 | ); 118 | } 119 | 120 | /** 121 | * You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/ 122 | */ 123 | function TabBarIcon(props: { 124 | name: React.ComponentProps["name"]; 125 | color: string; 126 | }) { 127 | return ; 128 | } 129 | -------------------------------------------------------------------------------- /Example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "node_modules/expo/AppEntry.js", 5 | "scripts": { 6 | "start": "expo start", 7 | "android": "expo start --android", 8 | "ios": "expo start --ios", 9 | "web": "expo start --web", 10 | "eject": "expo eject", 11 | "test": "jest --watchAll" 12 | }, 13 | "jest": { 14 | "preset": "jest-expo" 15 | }, 16 | "dependencies": { 17 | "@expo/vector-icons": "^13.0.0", 18 | "@react-navigation/bottom-tabs": "^6.0.5", 19 | "@react-navigation/native": "^6.0.2", 20 | "@react-navigation/native-stack": "^6.1.0", 21 | "expo": "^48.0.0", 22 | "expo-asset": "~8.9.1", 23 | "expo-constants": "~14.2.1", 24 | "expo-font": "~11.1.1", 25 | "expo-linking": "~4.0.1", 26 | "expo-splash-screen": "~0.18.1", 27 | "expo-status-bar": "~1.4.4", 28 | "expo-system-ui": "~2.2.1", 29 | "expo-web-browser": "~12.1.1", 30 | "react": "18.2.0", 31 | "react-dom": "18.2.0", 32 | "react-native": "0.71.3", 33 | "react-native-gesture-handler": "~2.9.0", 34 | "react-native-reanimated": "~2.14.4", 35 | "react-native-safe-area-context": "4.5.0", 36 | "react-native-screens": "~3.20.0", 37 | "react-native-swipeable-item": "^2.0.2", 38 | "react-native-web": "~0.18.11" 39 | }, 40 | "devDependencies": { 41 | "@babel/core": "^7.20.0", 42 | "@types/react": "~18.0.27", 43 | "@types/react-native": "~0.66.13", 44 | "jest": "^29.2.1", 45 | "jest-expo": "^48.0.0", 46 | "react-test-renderer": "17.0.2", 47 | "typescript": "^4.9.4" 48 | }, 49 | "private": true 50 | } 51 | -------------------------------------------------------------------------------- /Example/screens/BasicScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react"; 2 | import { Text, View, StyleSheet, TouchableOpacity } from "react-native"; 3 | import DraggableFlatList, { 4 | ScaleDecorator, 5 | ShadowDecorator, 6 | OpacityDecorator, 7 | RenderItemParams, 8 | } from "react-native-draggable-flatlist"; 9 | 10 | import { mapIndexToData, Item } from "../utils"; 11 | 12 | const NUM_ITEMS = 100; 13 | 14 | const initialData: Item[] = [...Array(NUM_ITEMS)].map(mapIndexToData); 15 | 16 | export default function Basic() { 17 | const [data, setData] = useState(initialData); 18 | 19 | const renderItem = useCallback( 20 | ({ item, drag, isActive }: RenderItemParams) => { 21 | return ( 22 | 23 | 24 | 25 | 34 | {item.text} 35 | 36 | 37 | 38 | 39 | ); 40 | }, 41 | [] 42 | ); 43 | 44 | return ( 45 | setData(data)} 48 | keyExtractor={(item) => item.key} 49 | renderItem={renderItem} 50 | renderPlaceholder={() => ( 51 | 52 | )} 53 | /> 54 | ); 55 | } 56 | 57 | const styles = StyleSheet.create({ 58 | rowItem: { 59 | height: 100, 60 | alignItems: "center", 61 | justifyContent: "center", 62 | }, 63 | text: { 64 | color: "white", 65 | fontSize: 24, 66 | fontWeight: "bold", 67 | textAlign: "center", 68 | }, 69 | }); 70 | -------------------------------------------------------------------------------- /Example/screens/HorizontalScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react"; 2 | import { Text, View, StyleSheet, TouchableOpacity } from "react-native"; 3 | import DraggableFlatList, { 4 | ScaleDecorator, 5 | RenderItemParams, 6 | } from "react-native-draggable-flatlist"; 7 | 8 | import { mapIndexToData, Item } from "../utils"; 9 | 10 | const NUM_ITEMS = 100; 11 | 12 | const initialData: Item[] = [...Array(NUM_ITEMS)].map(mapIndexToData); 13 | 14 | export default function Horizontal() { 15 | const [data, setData] = useState(initialData); 16 | 17 | const renderItem = useCallback( 18 | ({ item, drag, isActive }: RenderItemParams) => { 19 | return ( 20 | 21 | 31 | {item.text} 32 | 33 | 34 | ); 35 | }, 36 | [] 37 | ); 38 | 39 | return ( 40 | setData(data)} 44 | keyExtractor={(item) => { 45 | return item.key; 46 | }} 47 | renderItem={renderItem} 48 | renderPlaceholder={() => ( 49 | 50 | )} 51 | /> 52 | ); 53 | } 54 | 55 | const styles = StyleSheet.create({ 56 | rowItem: { 57 | height: 100, 58 | width: 100, 59 | alignItems: "center", 60 | justifyContent: "center", 61 | }, 62 | text: { 63 | color: "white", 64 | fontSize: 24, 65 | fontWeight: "bold", 66 | textAlign: "center", 67 | }, 68 | }); 69 | -------------------------------------------------------------------------------- /Example/screens/NestedScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from "react"; 2 | import { Text, View, StyleSheet, TouchableOpacity } from "react-native"; 3 | 4 | import { 5 | RenderItemParams, 6 | ScaleDecorator, 7 | ShadowDecorator, 8 | NestableScrollContainer, 9 | NestableDraggableFlatList, 10 | } from "react-native-draggable-flatlist"; 11 | 12 | import { mapIndexToData, Item } from "../utils"; 13 | 14 | const NUM_ITEMS = 6; 15 | const initialData1 = [...Array(NUM_ITEMS)].fill(0).map(mapIndexToData); 16 | const initialData2 = [...Array(NUM_ITEMS)].fill(0).map(mapIndexToData); 17 | const initialData3 = [...Array(NUM_ITEMS)].fill(0).map(mapIndexToData); 18 | 19 | function NestedDraggableListScreen() { 20 | const [data1, setData1] = useState(initialData1); 21 | const [data2, setData2] = useState(initialData2); 22 | const [data3, setData3] = useState(initialData3); 23 | 24 | const renderItem = useCallback((params: RenderItemParams) => { 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | }, []); 33 | 34 | const keyExtractor = (item) => item.key; 35 | 36 | return ( 37 | 38 | 39 |
40 | setData1(data)} 45 | /> 46 |
47 | setData2(data)} 52 | /> 53 |
54 | setData3(data)} 59 | /> 60 | 61 | 62 | ); 63 | } 64 | 65 | function Header({ text }: { text: string }) { 66 | return ( 67 | 68 | 69 | {text} 70 | 71 | 72 | ); 73 | } 74 | 75 | type RowItemProps = { 76 | item: Item; 77 | drag: () => void; 78 | }; 79 | 80 | function RowItem({ item, drag }: RowItemProps) { 81 | return ( 82 | 94 | {item.text} 95 | 96 | ); 97 | } 98 | 99 | export default NestedDraggableListScreen; 100 | 101 | const styles = StyleSheet.create({ 102 | container: { 103 | flex: 1, 104 | backgroundColor: "seashell", 105 | paddingTop: 44, 106 | }, 107 | row: { 108 | flexDirection: "row", 109 | flex: 1, 110 | alignItems: "center", 111 | justifyContent: "center", 112 | padding: 15, 113 | }, 114 | text: { 115 | fontWeight: "bold", 116 | color: "white", 117 | fontSize: 32, 118 | }, 119 | }); 120 | -------------------------------------------------------------------------------- /Example/screens/NotFoundScreen.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet, TouchableOpacity } from "react-native"; 2 | 3 | import { Text, View } from "../components/Themed"; 4 | import { RootStackScreenProps } from "../types"; 5 | 6 | export default function NotFoundScreen({ 7 | navigation, 8 | }: RootStackScreenProps<"NotFound">) { 9 | return ( 10 | 11 | This screen doesn't exist. 12 | navigation.replace("Root")} 14 | style={styles.link} 15 | > 16 | Go to home screen! 17 | 18 | 19 | ); 20 | } 21 | 22 | const styles = StyleSheet.create({ 23 | container: { 24 | flex: 1, 25 | alignItems: "center", 26 | justifyContent: "center", 27 | padding: 20, 28 | }, 29 | title: { 30 | fontSize: 20, 31 | fontWeight: "bold", 32 | }, 33 | link: { 34 | marginTop: 15, 35 | paddingVertical: 15, 36 | }, 37 | linkText: { 38 | fontSize: 14, 39 | color: "#2e78b7", 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /Example/screens/SwipeableScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useCallback } from "react"; 2 | import { 3 | Text, 4 | View, 5 | StyleSheet, 6 | LayoutAnimation, 7 | TouchableOpacity, 8 | } from "react-native"; 9 | import Animated, { useAnimatedStyle } from "react-native-reanimated"; 10 | import DraggableFlatList, { 11 | RenderItemParams, 12 | ScaleDecorator, 13 | } from "react-native-draggable-flatlist"; 14 | import SwipeableItem, { 15 | useSwipeableItemParams, 16 | } from "react-native-swipeable-item"; 17 | import { mapIndexToData, Item } from "../utils"; 18 | 19 | const OVERSWIPE_DIST = 20; 20 | const NUM_ITEMS = 20; 21 | 22 | const initialData: Item[] = [...Array(NUM_ITEMS)].fill(0).map(mapIndexToData); 23 | 24 | function App() { 25 | const [data, setData] = useState(initialData); 26 | const itemRefs = useRef(new Map()); 27 | 28 | const renderItem = useCallback((params: RenderItemParams) => { 29 | const onPressDelete = () => { 30 | LayoutAnimation.configureNext(LayoutAnimation.Presets.spring); 31 | setData((prev) => { 32 | return prev.filter((item) => item !== params.item); 33 | }); 34 | }; 35 | 36 | return ( 37 | 38 | ); 39 | }, []); 40 | 41 | return ( 42 | 43 | item.key} 45 | data={data} 46 | renderItem={renderItem} 47 | onDragEnd={({ data }) => setData(data)} 48 | activationDistance={20} 49 | /> 50 | 51 | ); 52 | } 53 | 54 | export default App; 55 | 56 | type RowItemProps = { 57 | item: Item; 58 | drag: () => void; 59 | onPressDelete: () => void; 60 | itemRefs: React.MutableRefObject>; 61 | }; 62 | 63 | function RowItem({ item, itemRefs, drag, onPressDelete }: RowItemProps) { 64 | const [snapPointsLeft, setSnapPointsLeft] = useState([150]); 65 | 66 | return ( 67 | 68 | { 72 | if (ref && !itemRefs.current.get(item.key)) { 73 | itemRefs.current.set(item.key, ref); 74 | } 75 | }} 76 | onChange={({ open }) => { 77 | if (open) { 78 | // Close all other open items 79 | [...itemRefs.current.entries()].forEach(([key, ref]) => { 80 | if (key !== item.key && ref) ref.close(); 81 | }); 82 | } 83 | }} 84 | overSwipe={OVERSWIPE_DIST} 85 | renderUnderlayLeft={() => ( 86 | 87 | )} 88 | renderUnderlayRight={() => } 89 | snapPointsLeft={snapPointsLeft} 90 | > 91 | 99 | {`${item.text}`} 100 | 101 | 102 | 103 | ); 104 | } 105 | 106 | const UnderlayLeft = ({ 107 | drag, 108 | onPressDelete, 109 | }: { 110 | drag: () => void; 111 | onPressDelete: () => void; 112 | }) => { 113 | const { item, percentOpen } = useSwipeableItemParams(); 114 | const animStyle = useAnimatedStyle( 115 | () => ({ 116 | opacity: percentOpen.value, 117 | }), 118 | [percentOpen] 119 | ); 120 | 121 | return ( 122 | 125 | 126 | {`[delete]`} 127 | 128 | 129 | ); 130 | }; 131 | 132 | function UnderlayRight() { 133 | const { close } = useSwipeableItemParams(); 134 | return ( 135 | 136 | 137 | CLOSE 138 | 139 | 140 | ); 141 | } 142 | 143 | const styles = StyleSheet.create({ 144 | container: { 145 | flex: 1, 146 | }, 147 | row: { 148 | flexDirection: "row", 149 | flex: 1, 150 | alignItems: "center", 151 | justifyContent: "center", 152 | padding: 15, 153 | }, 154 | text: { 155 | fontWeight: "bold", 156 | color: "white", 157 | fontSize: 32, 158 | }, 159 | underlayRight: { 160 | flex: 1, 161 | backgroundColor: "teal", 162 | justifyContent: "flex-start", 163 | }, 164 | underlayLeft: { 165 | flex: 1, 166 | backgroundColor: "tomato", 167 | justifyContent: "flex-end", 168 | }, 169 | }); 170 | -------------------------------------------------------------------------------- /Example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/types.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about using TypeScript with React Navigation: 3 | * https://reactnavigation.org/docs/typescript/ 4 | */ 5 | 6 | import { BottomTabScreenProps } from "@react-navigation/bottom-tabs"; 7 | import { 8 | CompositeScreenProps, 9 | NavigatorScreenParams, 10 | } from "@react-navigation/native"; 11 | import { NativeStackScreenProps } from "@react-navigation/native-stack"; 12 | 13 | declare global { 14 | namespace ReactNavigation { 15 | interface RootParamList extends RootStackParamList {} 16 | } 17 | } 18 | 19 | export type RootStackParamList = { 20 | Root: NavigatorScreenParams | undefined; 21 | NotFound: undefined; 22 | }; 23 | 24 | export type RootStackScreenProps< 25 | Screen extends keyof RootStackParamList 26 | > = NativeStackScreenProps; 27 | 28 | export type RootTabParamList = { 29 | Basic: undefined; 30 | Nested: undefined; 31 | Swipeable: undefined; 32 | Horizontal: undefined; 33 | }; 34 | 35 | export type RootTabScreenProps< 36 | Screen extends keyof RootTabParamList 37 | > = CompositeScreenProps< 38 | BottomTabScreenProps, 39 | NativeStackScreenProps 40 | >; 41 | -------------------------------------------------------------------------------- /Example/utils/index.ts: -------------------------------------------------------------------------------- 1 | export function getColor(i: number, numItems: number = 25) { 2 | const multiplier = 255 / (numItems - 1); 3 | const colorVal = i * multiplier; 4 | return `rgb(${colorVal}, ${Math.abs(128 - colorVal)}, ${255 - colorVal})`; 5 | } 6 | 7 | export const mapIndexToData = (_d: any, index: number, arr: any[]) => { 8 | const backgroundColor = getColor(index, arr.length); 9 | return { 10 | text: `${index}`, 11 | key: `key-${index}`, 12 | backgroundColor, 13 | height: 75, 14 | }; 15 | }; 16 | 17 | export type Item = ReturnType; 18 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 computerjazz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Native Draggable FlatList 2 | 3 | A drag-and-drop-enabled FlatList component for React Native.
4 | Fully native interactions powered by [Reanimated](https://github.com/kmagiera/react-native-reanimated) and [React Native Gesture Handler](https://github.com/kmagiera/react-native-gesture-handler).

5 | To use swipeable list items in a DraggableFlatList see [React Native Swipeable Item](https://github.com/computerjazz/react-native-swipeable-item). 6 | 7 | ![Draggable FlatList demo](https://i.imgur.com/xHCylq1.gif) 8 | 9 | ## Install 10 | 11 | 1. Follow installation instructions for [reanimated](https://github.com/kmagiera/react-native-reanimated) and [react-native-gesture-handler](https://github.com/kmagiera/react-native-gesture-handler). RNGH may require you to make changes to `MainActivity.java`. Be sure to [follow all Android instructions!](https://docs.swmansion.com/react-native-gesture-handler/docs/#android) 12 | 2. Install this package using `npm` or `yarn` 13 | 14 | with `npm`: 15 | 16 | ``` 17 | npm install --save react-native-draggable-flatlist 18 | ``` 19 | 20 | with `yarn`: 21 | 22 | ``` 23 | yarn add react-native-draggable-flatlist 24 | ``` 25 | 26 | 3. `import DraggableFlatList from 'react-native-draggable-flatlist'` 27 | 28 | ## Api 29 | 30 | ### Props 31 | 32 | All props are spread onto underlying [FlatList](https://facebook.github.io/react-native/docs/flatlist) 33 | 34 | | Name | Type | Description 35 | | :------------------------- | :---------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- 36 | | `data` | `T[]` | Items to be rendered. | 37 | | `ref` | `React.RefObject>` | FlatList ref to be forwarded to the underlying FlatList. | 38 | | `renderItem` | `(params: { item: T, getIndex: () => number \| undefined, drag: () => void, isActive: boolean}) => JSX.Element` | Call `drag` when the row should become active (i.e. in an `onLongPress` or `onPressIn`). | 39 | | `renderPlaceholder` | `(params: { item: T, index: number }) => React.ReactNode` | Component to be rendered underneath the hovering component | 40 | | `keyExtractor` | `(item: T, index: number) => string` | Unique key for each item (required) | 41 | | `onDragBegin` | `(index: number) => void` | Called when row becomes active. | 42 | | `onRelease` | `(index: number) => void` | Called when active row touch ends. | 43 | | `onDragEnd` | `(params: { data: T[], from: number, to: number }) => void` | Called after animation has completed. Returns updated ordering of `data` | 44 | | `autoscrollThreshold` | `number` | Distance from edge of container where list begins to autoscroll when dragging. | 45 | | `autoscrollSpeed` | `number` | Determines how fast the list autoscrolls. | 46 | | `animationConfig` | `Partial` | Configure list animations. See [reanimated spring config](https://docs.swmansion.com/react-native-reanimated/docs/api/animations/withSpring/#options-object) | 47 | | `activationDistance` | `number` | Distance a finger must travel before the gesture handler activates. Useful when using a draggable list within a TabNavigator so that the list does not capture navigator gestures. | 48 | | `onScrollOffsetChange` | `(offset: number) => void` | Called with scroll offset. Stand-in for `onScroll`. | 49 | | `onPlaceholderIndexChange` | `(index: number) => void` | Called when the index of the placeholder changes | 50 | | `dragItemOverflow` | `boolean` | If true, dragged item follows finger beyond list boundary. | 51 | | `dragHitSlop` | `object: {top: number, left: number, bottom: number, right: number}` | Enables control over what part of the connected view area can be used to begin recognizing the gesture. Numbers need to be non-positive (only possible to reduce responsive area). | 52 | | `debug` | `boolean` | Enables debug logging and animation debugger. | 53 | | `containerStyle` | `StyleProp` | Style of the main component. | 54 | | `simultaneousHandlers` | `React.Ref` or `React.Ref[]` | References to other gesture handlers, mainly useful when using this component within a `ScrollView`. See [Cross handler interactions](https://docs.swmansion.com/react-native-gesture-handler/docs/interactions/). | 55 | |`itemEnteringAnimation`| Reanimated `AnimationBuilder` ([docs](https://docs.swmansion.com/react-native-reanimated/docs/api/LayoutAnimations/entryAnimations)) | Animation when item is added to list.| 56 | |`itemExitingAnimation`| Reanimated `AnimationBuilder` ([docs](https://docs.swmansion.com/react-native-reanimated/docs/api/LayoutAnimations/exitAnimations))| Animation when item is removed from list.| 57 | |`itemLayoutAnimation`| Reanimated `AnimationBuilder` ([docs](https://docs.swmansion.com/react-native-reanimated/docs/api/LayoutAnimations/layoutTransitions))| Animation when list items change position (`enableLayoutAnimationExperimental` prop must be `true`).| 58 | |`enableLayoutAnimationExperimental`| `boolean`| Flag to turn on experimental support for `itemLayoutAnimation`.| 59 | 60 | 61 | 62 | ## Cell Decorators 63 | 64 | Cell Decorators are an easy way to add common hover animations. For example, wrapping `renderItem` in the `` component will automatically scale up the active item while hovering (see example below). 65 | 66 | `ScaleDecorator`, `ShadowDecorator`, and `OpacityDecorator` are currently exported. Developers may create their own custom decorators using the animated values provided by the `useOnCellActiveAnimation` hook. 67 | 68 | ## Nesting DraggableFlatLists 69 | 70 | It's possible to render multiple `DraggableFlatList` components within a single scrollable parent by wrapping one or more `NestableDraggableFlatList` components within an outer `NestableScrollContainer` component. 71 | 72 | `NestableScrollContainer` extends the `ScrollView` from `react-native-gesture-handler`, and `NestableDraggableFlatList` extends `DraggableFlatList`, so all available props may be passed into both of them. 73 | 74 | > Note: When using NestableDraggableFlatLists, all React Native warnings about nested list performance will be disabled. 75 | 76 | ```tsx 77 | import { NestableScrollContainer, NestableDraggableFlatList } from "react-native-draggable-flatlist" 78 | 79 | ... 80 | 81 | const [data1, setData1] = useState(initialData1); 82 | const [data2, setData2] = useState(initialData2); 83 | const [data3, setData3] = useState(initialData3); 84 | 85 | return ( 86 | 87 |
88 | setData1(data)} 93 | /> 94 |
95 | setData2(data)} 100 | /> 101 |
102 | setData3(data)} 107 | /> 108 | 109 | ) 110 | ``` 111 | 112 | ![Nested DraggableFlatList demo](https://i.imgur.com/Kv0aj4l.gif) 113 | 114 | ## Example 115 | 116 | Example snack: https://snack.expo.dev/@computerjazz/draggable-flatlist-examples
117 | 118 | ```typescript 119 | import React, { useState } from "react"; 120 | import { Text, View, StyleSheet, TouchableOpacity } from "react-native"; 121 | import DraggableFlatList, { 122 | ScaleDecorator, 123 | } from "react-native-draggable-flatlist"; 124 | 125 | const NUM_ITEMS = 10; 126 | function getColor(i: number) { 127 | const multiplier = 255 / (NUM_ITEMS - 1); 128 | const colorVal = i * multiplier; 129 | return `rgb(${colorVal}, ${Math.abs(128 - colorVal)}, ${255 - colorVal})`; 130 | } 131 | 132 | type Item = { 133 | key: string; 134 | label: string; 135 | height: number; 136 | width: number; 137 | backgroundColor: string; 138 | }; 139 | 140 | const initialData: Item[] = [...Array(NUM_ITEMS)].map((d, index) => { 141 | const backgroundColor = getColor(index); 142 | return { 143 | key: `item-${index}`, 144 | label: String(index) + "", 145 | height: 100, 146 | width: 60 + Math.random() * 40, 147 | backgroundColor, 148 | }; 149 | }); 150 | 151 | export default function App() { 152 | const [data, setData] = useState(initialData); 153 | 154 | const renderItem = ({ item, drag, isActive }: RenderItemParams) => { 155 | return ( 156 | 157 | 165 | {item.label} 166 | 167 | 168 | ); 169 | }; 170 | 171 | return ( 172 | setData(data)} 175 | keyExtractor={(item) => item.key} 176 | renderItem={renderItem} 177 | /> 178 | ); 179 | } 180 | 181 | const styles = StyleSheet.create({ 182 | rowItem: { 183 | height: 100, 184 | width: 100, 185 | alignItems: "center", 186 | justifyContent: "center", 187 | }, 188 | text: { 189 | color: "white", 190 | fontSize: 24, 191 | fontWeight: "bold", 192 | textAlign: "center", 193 | }, 194 | }); 195 | ``` 196 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["module:metro-react-native-babel-preset"], 3 | plugins: ["react-native-reanimated/plugin"], 4 | }; 5 | -------------------------------------------------------------------------------- /jest-setup.js: -------------------------------------------------------------------------------- 1 | // see: https://github.com/software-mansion/react-native-reanimated/issues/1380 2 | global.__reanimatedWorkletInit = jest.fn(); 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-draggable-flatlist", 3 | "version": "4.0.3", 4 | "description": "A drag-and-drop-enabled FlatList component for React Native", 5 | "main": "lib/commonjs/index", 6 | "module": "lib/module/index", 7 | "react-native": "src/index.tsx", 8 | "types": "lib/typescript/index.d.ts", 9 | "scripts": { 10 | "test": "jest", 11 | "typecheck": "tsc --skipLibCheck --noEmit", 12 | "build": "bob build" 13 | }, 14 | "husky": { 15 | "hooks": { 16 | "pre-commit": "pretty-quick --staged" 17 | } 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/computerjazz/react-native-draggable-flatlist.git" 22 | }, 23 | "keywords": [ 24 | "react-native", 25 | "sortable", 26 | "draggable", 27 | "flatlist", 28 | "drag", 29 | "drop", 30 | "sort", 31 | "list" 32 | ], 33 | "author": "Daniel Merrill", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/computerjazz/react-native-draggable-flatlist/issues" 37 | }, 38 | "jest": { 39 | "preset": "react-native", 40 | "setupFiles": [ 41 | "./node_modules/react-native-gesture-handler/jestSetup.js", 42 | "./jest-setup.js" 43 | ], 44 | "transform": { 45 | "^.+\\.[t|j]sx?$": "babel-jest" 46 | }, 47 | "transformIgnorePatterns": [] 48 | }, 49 | "homepage": "https://github.com/computerjazz/react-native-draggable-flatlist#readme", 50 | "peerDependencies": { 51 | "react-native": ">=0.64.0", 52 | "react-native-gesture-handler": ">=2.0.0", 53 | "react-native-reanimated": ">=2.8.0" 54 | }, 55 | "devDependencies": { 56 | "@testing-library/react-native": "^7.2.0", 57 | "@types/react": "^17.0.5", 58 | "@types/react-native": "^0.64.5", 59 | "babel-jest": "^26.6.3", 60 | "husky": "^4.2.0", 61 | "jest": "^26.6.3", 62 | "metro-react-native-babel-preset": "^0.71.0", 63 | "prettier": "^2.2.1", 64 | "pretty-quick": "^2.0.1", 65 | "react": "^17.0.2", 66 | "react-native": "^0.64.0", 67 | "react-native-builder-bob": "^0.18.2", 68 | "react-native-gesture-handler": "^2.1.0", 69 | "react-native-reanimated": "^2.8.0", 70 | "react-test-renderer": "^17.0.2", 71 | "typescript": "^4.2.4" 72 | }, 73 | "react-native-builder-bob": { 74 | "source": "src", 75 | "output": "lib", 76 | "targets": [ 77 | [ 78 | "commonjs", 79 | { 80 | "configFile": "./babel.config.js" 81 | } 82 | ], 83 | "module", 84 | "typescript" 85 | ] 86 | }, 87 | "dependencies": { 88 | "@babel/preset-typescript": "^7.17.12" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/components/CellDecorators.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet } from "react-native"; 3 | import Animated, { 4 | interpolate, 5 | useAnimatedStyle, 6 | } from "react-native-reanimated"; 7 | import { useDraggableFlatListContext } from "../context/draggableFlatListContext"; 8 | export { useOnCellActiveAnimation } from "../hooks/useOnCellActiveAnimation"; 9 | import { useOnCellActiveAnimation } from "../hooks/useOnCellActiveAnimation"; 10 | 11 | type ScaleProps = { 12 | activeScale?: number; 13 | children: React.ReactNode; 14 | }; 15 | 16 | export const ScaleDecorator = ({ activeScale = 1.1, children }: ScaleProps) => { 17 | const { isActive, onActiveAnim } = useOnCellActiveAnimation({ 18 | animationConfig: { mass: 0.1, restDisplacementThreshold: 0.0001 }, 19 | }); 20 | const { horizontal } = useDraggableFlatListContext(); 21 | 22 | const style = useAnimatedStyle(() => { 23 | const animScale = interpolate(onActiveAnim.value, [0, 1], [1, activeScale]); 24 | const scale = isActive ? animScale : 1; 25 | return { 26 | transform: [{ scaleX: scale }, { scaleY: scale }], 27 | }; 28 | }, [isActive]); 29 | 30 | return ( 31 | 32 | {children} 33 | 34 | ); 35 | }; 36 | 37 | type ShadowProps = { 38 | children: React.ReactNode; 39 | elevation?: number; 40 | radius?: number; 41 | color?: string; 42 | opacity?: number; 43 | }; 44 | 45 | export const ShadowDecorator = ({ 46 | elevation = 10, 47 | color = "black", 48 | opacity = 0.25, 49 | radius = 5, 50 | children, 51 | }: ShadowProps) => { 52 | const { isActive, onActiveAnim } = useOnCellActiveAnimation(); 53 | const { horizontal } = useDraggableFlatListContext(); 54 | 55 | const style = useAnimatedStyle(() => { 56 | const shadowOpacity = onActiveAnim.value * opacity; 57 | return { 58 | elevation: isActive ? elevation : 0, 59 | shadowRadius: isActive ? radius : 0, 60 | shadowColor: isActive ? color : "transparent", 61 | shadowOpacity: isActive ? shadowOpacity : 0, 62 | }; 63 | }, [isActive, onActiveAnim]); 64 | 65 | return ( 66 | 67 | {children} 68 | 69 | ); 70 | }; 71 | 72 | type OpacityProps = { 73 | activeOpacity?: number; 74 | children: React.ReactNode; 75 | }; 76 | 77 | export const OpacityDecorator = ({ 78 | activeOpacity = 0.25, 79 | children, 80 | }: OpacityProps) => { 81 | const { isActive, onActiveAnim } = useOnCellActiveAnimation(); 82 | const { horizontal } = useDraggableFlatListContext(); 83 | const style = useAnimatedStyle(() => { 84 | const opacity = interpolate(onActiveAnim.value, [0, 1], [1, activeOpacity]); 85 | return { 86 | opacity: isActive ? opacity : 1, 87 | }; 88 | }, [isActive]); 89 | 90 | return ( 91 | 92 | {children} 93 | 94 | ); 95 | }; 96 | 97 | const styles = StyleSheet.create({ 98 | horizontal: { 99 | flexDirection: "row", 100 | flex: 1, 101 | }, 102 | }); 103 | -------------------------------------------------------------------------------- /src/components/CellRendererComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useRef } from "react"; 2 | import { 3 | findNodeHandle, 4 | LayoutChangeEvent, 5 | MeasureLayoutOnSuccessCallback, 6 | StyleProp, 7 | ViewStyle, 8 | } from "react-native"; 9 | import Animated, { 10 | runOnUI, 11 | useAnimatedStyle, 12 | useSharedValue, 13 | } from "react-native-reanimated"; 14 | import { useDraggableFlatListContext } from "../context/draggableFlatListContext"; 15 | import { isWeb } from "../constants"; 16 | import { useCellTranslate } from "../hooks/useCellTranslate"; 17 | import { typedMemo } from "../utils"; 18 | import { useRefs } from "../context/refContext"; 19 | import { useAnimatedValues } from "../context/animatedValueContext"; 20 | import CellProvider from "../context/cellContext"; 21 | import { useStableCallback } from "../hooks/useStableCallback"; 22 | 23 | type Props = { 24 | item: T; 25 | index: number; 26 | children: React.ReactNode; 27 | onLayout?: (e: LayoutChangeEvent) => void; 28 | style?: StyleProp; 29 | }; 30 | 31 | function CellRendererComponent(props: Props) { 32 | const { item, index, onLayout, children, ...rest } = props; 33 | 34 | const viewRef = useRef(null); 35 | const { cellDataRef, propsRef, containerRef } = useRefs(); 36 | 37 | const { horizontalAnim, scrollOffset } = useAnimatedValues(); 38 | const { 39 | activeKey, 40 | keyExtractor, 41 | horizontal, 42 | layoutAnimationDisabled, 43 | } = useDraggableFlatListContext(); 44 | 45 | const key = keyExtractor(item, index); 46 | const offset = useSharedValue(-1); 47 | const size = useSharedValue(-1); 48 | const heldTanslate = useSharedValue(0); 49 | 50 | const translate = useCellTranslate({ 51 | cellOffset: offset, 52 | cellSize: size, 53 | cellIndex: index, 54 | }); 55 | 56 | const isActive = activeKey === key; 57 | 58 | const animStyle = useAnimatedStyle(() => { 59 | // When activeKey becomes null at the end of a drag and the list reorders, 60 | // the animated style may apply before the next paint, causing a flicker. 61 | // Solution is to hold over the last animated value until the next onLayout. 62 | // (Not required in web) 63 | if (translate.value && !isWeb) { 64 | heldTanslate.value = translate.value; 65 | } 66 | const t = activeKey ? translate.value : heldTanslate.value; 67 | return { 68 | transform: [horizontalAnim.value ? { translateX: t } : { translateY: t }], 69 | }; 70 | }, [translate, activeKey]); 71 | 72 | const updateCellMeasurements = useStableCallback(() => { 73 | const onSuccess: MeasureLayoutOnSuccessCallback = (x, y, w, h) => { 74 | if (isWeb && horizontal) x += scrollOffset.value; 75 | const cellOffset = horizontal ? x : y; 76 | const cellSize = horizontal ? w : h; 77 | cellDataRef.current.set(key, { 78 | measurements: { size: cellSize, offset: cellOffset }, 79 | }); 80 | 81 | size.value = cellSize; 82 | offset.value = cellOffset; 83 | }; 84 | 85 | const onFail = () => { 86 | if (propsRef.current?.debug) { 87 | console.log(`## on measure fail, index: ${index}`); 88 | } 89 | }; 90 | 91 | const containerNode = containerRef.current; 92 | const viewNode = viewRef.current; 93 | const nodeHandle = containerNode; 94 | 95 | if (viewNode && nodeHandle) { 96 | //@ts-ignore 97 | viewNode.measureLayout(nodeHandle, onSuccess, onFail); 98 | } 99 | }); 100 | 101 | const onCellLayout = useStableCallback((e?: LayoutChangeEvent) => { 102 | heldTanslate.value = 0; 103 | updateCellMeasurements(); 104 | if (onLayout && e) onLayout(e); 105 | }); 106 | 107 | useEffect(() => { 108 | if (isWeb) { 109 | // onLayout isn't called on web when the cell index changes, so we manually re-measure 110 | requestAnimationFrame(() => { 111 | onCellLayout(); 112 | }); 113 | } 114 | }, [index, onCellLayout]); 115 | 116 | const baseStyle = useMemo(() => { 117 | return { 118 | elevation: isActive ? 1 : 0, 119 | zIndex: isActive ? 999 : 0, 120 | flexDirection: horizontal ? ("row" as const) : ("column" as const), 121 | }; 122 | }, [isActive, horizontal]); 123 | 124 | const { 125 | itemEnteringAnimation, 126 | itemExitingAnimation, 127 | itemLayoutAnimation, 128 | } = propsRef.current; 129 | 130 | useEffect(() => { 131 | // NOTE: Keep an eye on reanimated LayoutAnimation refactor: 132 | // https://github.com/software-mansion/react-native-reanimated/pull/3332/files 133 | // We might have to change the way we register/unregister LayouAnimations: 134 | // - get native module: https://github.com/software-mansion/react-native-reanimated/blob/cf59766460d05eb30357913455318d8a95909468/src/reanimated2/NativeReanimated/NativeReanimated.ts#L18 135 | // - register layout animation for tag: https://github.com/software-mansion/react-native-reanimated/blob/cf59766460d05eb30357913455318d8a95909468/src/reanimated2/NativeReanimated/NativeReanimated.ts#L99 136 | if (!propsRef.current.enableLayoutAnimationExperimental) return; 137 | const tag = findNodeHandle(viewRef.current); 138 | 139 | runOnUI((t: number | null, _layoutDisabled) => { 140 | "worklet"; 141 | if (!t) return; 142 | const config = global.LayoutAnimationRepository.configs[t]; 143 | if (config) stashConfig(t, config); 144 | const stashedConfig = getStashedConfig(t); 145 | if (_layoutDisabled) { 146 | global.LayoutAnimationRepository.removeConfig(t); 147 | } else if (stashedConfig) { 148 | global.LayoutAnimationRepository.registerConfig(t, stashedConfig); 149 | } 150 | })(tag, layoutAnimationDisabled); 151 | }, [layoutAnimationDisabled]); 152 | 153 | return ( 154 | 168 | {children} 169 | 170 | ); 171 | } 172 | 173 | export default typedMemo(CellRendererComponent); 174 | 175 | declare global { 176 | namespace NodeJS { 177 | interface Global { 178 | RNDFLLayoutAnimationConfigStash: Record; 179 | } 180 | } 181 | } 182 | 183 | runOnUI(() => { 184 | "worklet"; 185 | global.RNDFLLayoutAnimationConfigStash = {}; 186 | })(); 187 | 188 | function stashConfig(tag: number, config: unknown) { 189 | "worklet"; 190 | if (!global.RNDFLLayoutAnimationConfigStash) 191 | global.RNDFLLayoutAnimationConfigStash = {}; 192 | global.RNDFLLayoutAnimationConfigStash[tag] = config; 193 | } 194 | 195 | function getStashedConfig(tag: number) { 196 | "worklet"; 197 | if (!global.RNDFLLayoutAnimationConfigStash) return null; 198 | return global.RNDFLLayoutAnimationConfigStash[tag] as Record; 199 | } 200 | -------------------------------------------------------------------------------- /src/components/DraggableFlatList.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useCallback, 3 | useEffect, 4 | useLayoutEffect, 5 | useMemo, 6 | useRef, 7 | useState, 8 | } from "react"; 9 | import { 10 | ListRenderItem, 11 | FlatListProps, 12 | LayoutChangeEvent, 13 | InteractionManager, 14 | } from "react-native"; 15 | import { 16 | FlatList, 17 | Gesture, 18 | GestureDetector, 19 | } from "react-native-gesture-handler"; 20 | import Animated, { 21 | runOnJS, 22 | useAnimatedReaction, 23 | useAnimatedScrollHandler, 24 | useSharedValue, 25 | withSpring, 26 | } from "react-native-reanimated"; 27 | import CellRendererComponent from "./CellRendererComponent"; 28 | import { DEFAULT_PROPS } from "../constants"; 29 | import PlaceholderItem from "./PlaceholderItem"; 30 | import RowItem from "./RowItem"; 31 | import { DraggableFlatListProps } from "../types"; 32 | import PropsProvider from "../context/propsContext"; 33 | import AnimatedValueProvider, { 34 | useAnimatedValues, 35 | } from "../context/animatedValueContext"; 36 | import RefProvider, { useRefs } from "../context/refContext"; 37 | import DraggableFlatListProvider from "../context/draggableFlatListContext"; 38 | import { useAutoScroll } from "../hooks/useAutoScroll"; 39 | import { useStableCallback } from "../hooks/useStableCallback"; 40 | import ScrollOffsetListener from "./ScrollOffsetListener"; 41 | import { typedMemo } from "../utils"; 42 | 43 | type RNGHFlatListProps = Animated.AnimateProps< 44 | FlatListProps & { 45 | ref: React.Ref>; 46 | simultaneousHandlers?: React.Ref | React.Ref[]; 47 | } 48 | >; 49 | 50 | type OnViewableItemsChangedCallback = Exclude< 51 | FlatListProps["onViewableItemsChanged"], 52 | undefined | null 53 | >; 54 | 55 | const AnimatedFlatList = (Animated.createAnimatedComponent( 56 | FlatList 57 | ) as unknown) as (props: RNGHFlatListProps) => React.ReactElement; 58 | 59 | function DraggableFlatListInner(props: DraggableFlatListProps) { 60 | const { 61 | cellDataRef, 62 | containerRef, 63 | flatlistRef, 64 | keyToIndexRef, 65 | propsRef, 66 | animationConfigRef, 67 | } = useRefs(); 68 | const { 69 | activeCellOffset, 70 | activeCellSize, 71 | activeIndexAnim, 72 | containerSize, 73 | scrollOffset, 74 | scrollViewSize, 75 | spacerIndexAnim, 76 | horizontalAnim, 77 | placeholderOffset, 78 | touchTranslate, 79 | autoScrollDistance, 80 | panGestureState, 81 | isTouchActiveNative, 82 | viewableIndexMin, 83 | viewableIndexMax, 84 | disabled, 85 | } = useAnimatedValues(); 86 | 87 | const reset = useStableCallback(() => { 88 | activeIndexAnim.value = -1; 89 | spacerIndexAnim.value = -1; 90 | touchTranslate.value = 0; 91 | activeCellSize.value = -1; 92 | activeCellOffset.value = -1; 93 | setActiveKey(null); 94 | }); 95 | 96 | const { 97 | dragHitSlop = DEFAULT_PROPS.dragHitSlop, 98 | scrollEnabled = DEFAULT_PROPS.scrollEnabled, 99 | activationDistance: activationDistanceProp = DEFAULT_PROPS.activationDistance, 100 | } = props; 101 | 102 | let [activeKey, setActiveKey] = useState(null); 103 | const [layoutAnimationDisabled, setLayoutAnimationDisabled] = useState( 104 | !propsRef.current.enableLayoutAnimationExperimental 105 | ); 106 | 107 | const keyExtractor = useStableCallback((item: T, index: number) => { 108 | if (!props.keyExtractor) { 109 | throw new Error("You must provide a keyExtractor to DraggableFlatList"); 110 | } 111 | return props.keyExtractor(item, index); 112 | }); 113 | 114 | const dataRef = useRef(props.data); 115 | const dataHasChanged = 116 | dataRef.current.map(keyExtractor).join("") !== 117 | props.data.map(keyExtractor).join(""); 118 | dataRef.current = props.data; 119 | if (dataHasChanged) { 120 | // When data changes make sure `activeKey` is nulled out in the same render pass 121 | activeKey = null; 122 | InteractionManager.runAfterInteractions(() => { 123 | reset(); 124 | }); 125 | } 126 | 127 | useEffect(() => { 128 | if (!propsRef.current.enableLayoutAnimationExperimental) return; 129 | if (activeKey) { 130 | setLayoutAnimationDisabled(true); 131 | } else { 132 | // setTimeout result of trial-and-error to determine how long to wait before 133 | // re-enabling layout animations so that a drag reorder does not trigger it. 134 | setTimeout(() => { 135 | setLayoutAnimationDisabled(false); 136 | }, 100); 137 | } 138 | }, [activeKey]); 139 | 140 | useLayoutEffect(() => { 141 | props.data.forEach((d, i) => { 142 | const key = keyExtractor(d, i); 143 | keyToIndexRef.current.set(key, i); 144 | }); 145 | }, [props.data, keyExtractor, keyToIndexRef]); 146 | 147 | const drag = useStableCallback((activeKey: string) => { 148 | if (disabled.value) return; 149 | const index = keyToIndexRef.current.get(activeKey); 150 | const cellData = cellDataRef.current.get(activeKey); 151 | if (cellData) { 152 | activeCellOffset.value = cellData.measurements.offset; 153 | activeCellSize.value = cellData.measurements.size; 154 | } 155 | 156 | const { onDragBegin } = propsRef.current; 157 | if (index !== undefined) { 158 | spacerIndexAnim.value = index; 159 | activeIndexAnim.value = index; 160 | setActiveKey(activeKey); 161 | onDragBegin?.(index); 162 | } 163 | }); 164 | 165 | const onContainerLayout = ({ 166 | nativeEvent: { layout }, 167 | }: LayoutChangeEvent) => { 168 | const { width, height } = layout; 169 | containerSize.value = props.horizontal ? width : height; 170 | props.onContainerLayout?.({ layout, containerRef }); 171 | }; 172 | 173 | const onListContentSizeChange = (w: number, h: number) => { 174 | scrollViewSize.value = props.horizontal ? w : h; 175 | props.onContentSizeChange?.(w, h); 176 | }; 177 | 178 | const onContainerTouchStart = () => { 179 | if (!disabled.value) { 180 | isTouchActiveNative.value = true; 181 | } 182 | return false; 183 | }; 184 | 185 | const onContainerTouchEnd = () => { 186 | isTouchActiveNative.value = false; 187 | }; 188 | 189 | const extraData = useMemo( 190 | () => ({ 191 | activeKey, 192 | extraData: props.extraData, 193 | }), 194 | [activeKey, props.extraData] 195 | ); 196 | 197 | const renderItem: ListRenderItem = useCallback( 198 | ({ item, index }) => { 199 | const key = keyExtractor(item, index); 200 | if (index !== keyToIndexRef.current.get(key)) { 201 | keyToIndexRef.current.set(key, index); 202 | } 203 | 204 | return ( 205 | 212 | ); 213 | }, 214 | [props.renderItem, props.extraData, drag, keyExtractor] 215 | ); 216 | 217 | const onRelease = useStableCallback((index: number) => { 218 | props.onRelease?.(index); 219 | }); 220 | 221 | const onDragEnd = useStableCallback( 222 | ({ from, to }: { from: number; to: number }) => { 223 | const { onDragEnd, data } = props; 224 | 225 | const newData = [...data]; 226 | if (from !== to) { 227 | newData.splice(from, 1); 228 | newData.splice(to, 0, data[from]); 229 | } 230 | 231 | onDragEnd?.({ from, to, data: newData }); 232 | 233 | setActiveKey(null); 234 | } 235 | ); 236 | 237 | const onPlaceholderIndexChange = useStableCallback((index: number) => { 238 | props.onPlaceholderIndexChange?.(index); 239 | }); 240 | 241 | // Handle case where user ends drag without moving their finger. 242 | useAnimatedReaction( 243 | () => { 244 | return isTouchActiveNative.value; 245 | }, 246 | (cur, prev) => { 247 | if (cur !== prev && !cur) { 248 | const hasMoved = !!touchTranslate.value; 249 | if (!hasMoved && activeIndexAnim.value >= 0 && !disabled.value) { 250 | runOnJS(onRelease)(activeIndexAnim.value); 251 | runOnJS(onDragEnd)({ 252 | from: activeIndexAnim.value, 253 | to: spacerIndexAnim.value, 254 | }); 255 | } 256 | } 257 | }, 258 | [isTouchActiveNative, onDragEnd, onRelease] 259 | ); 260 | 261 | useAnimatedReaction( 262 | () => { 263 | return spacerIndexAnim.value; 264 | }, 265 | (cur, prev) => { 266 | if (prev !== null && cur !== prev && cur >= 0 && prev >= 0) { 267 | runOnJS(onPlaceholderIndexChange)(cur); 268 | } 269 | }, 270 | [spacerIndexAnim] 271 | ); 272 | 273 | const gestureDisabled = useSharedValue(false); 274 | 275 | const panGesture = Gesture.Pan() 276 | .onBegin((evt) => { 277 | gestureDisabled.value = disabled.value; 278 | if (gestureDisabled.value) return; 279 | panGestureState.value = evt.state; 280 | }) 281 | .onUpdate((evt) => { 282 | if (gestureDisabled.value) return; 283 | panGestureState.value = evt.state; 284 | const translation = horizontalAnim.value 285 | ? evt.translationX 286 | : evt.translationY; 287 | touchTranslate.value = translation; 288 | }) 289 | .onEnd((evt) => { 290 | if (gestureDisabled.value) return; 291 | // Set touch val to current translate val 292 | isTouchActiveNative.value = false; 293 | const translation = horizontalAnim.value 294 | ? evt.translationX 295 | : evt.translationY; 296 | 297 | touchTranslate.value = translation + autoScrollDistance.value; 298 | panGestureState.value = evt.state; 299 | 300 | // Only call onDragEnd if actually dragging a cell 301 | if (activeIndexAnim.value === -1 || disabled.value) return; 302 | disabled.value = true; 303 | runOnJS(onRelease)(activeIndexAnim.value); 304 | const springTo = placeholderOffset.value - activeCellOffset.value; 305 | touchTranslate.value = withSpring( 306 | springTo, 307 | animationConfigRef.value, 308 | () => { 309 | runOnJS(onDragEnd)({ 310 | from: activeIndexAnim.value, 311 | to: spacerIndexAnim.value, 312 | }); 313 | disabled.value = false; 314 | } 315 | ); 316 | }) 317 | .onTouchesDown(() => { 318 | runOnJS(onContainerTouchStart)(); 319 | }) 320 | .onTouchesUp(() => { 321 | // Turning this into a worklet causes timing issues. We want it to run 322 | // just after the finger lifts. 323 | runOnJS(onContainerTouchEnd)(); 324 | }); 325 | 326 | if (dragHitSlop) panGesture.hitSlop(dragHitSlop); 327 | if (activationDistanceProp) { 328 | const activeOffset = [-activationDistanceProp, activationDistanceProp]; 329 | if (props.horizontal) { 330 | panGesture.activeOffsetX(activeOffset); 331 | } else { 332 | panGesture.activeOffsetY(activeOffset); 333 | } 334 | } 335 | 336 | const onScroll = useStableCallback((scrollOffset: number) => { 337 | props.onScrollOffsetChange?.(scrollOffset); 338 | }); 339 | 340 | const scrollHandler = useAnimatedScrollHandler( 341 | { 342 | onScroll: (evt) => { 343 | scrollOffset.value = horizontalAnim.value 344 | ? evt.contentOffset.x 345 | : evt.contentOffset.y; 346 | runOnJS(onScroll)(scrollOffset.value); 347 | }, 348 | }, 349 | [horizontalAnim] 350 | ); 351 | 352 | useAutoScroll(); 353 | 354 | const onViewableItemsChanged = useStableCallback< 355 | OnViewableItemsChangedCallback 356 | >((info) => { 357 | const viewableIndices = info.viewableItems 358 | .filter((item) => item.isViewable) 359 | .map((item) => item.index) 360 | .filter((index): index is number => typeof index === "number"); 361 | 362 | const min = Math.min(...viewableIndices); 363 | const max = Math.max(...viewableIndices); 364 | viewableIndexMin.value = min; 365 | viewableIndexMax.value = max; 366 | props.onViewableItemsChanged?.(info); 367 | }); 368 | 369 | return ( 370 | 376 | 377 | 382 | {props.renderPlaceholder && ( 383 | 384 | )} 385 | 401 | {!!props.onScrollOffsetChange && ( 402 | 406 | )} 407 | 408 | 409 | 410 | ); 411 | } 412 | 413 | function DraggableFlatList( 414 | props: DraggableFlatListProps, 415 | ref?: React.ForwardedRef> | null 416 | ) { 417 | return ( 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | ); 426 | } 427 | 428 | const MemoizedInner = typedMemo(DraggableFlatListInner); 429 | 430 | // Generic forwarded ref type assertion taken from: 431 | // https://fettblog.eu/typescript-react-generic-forward-refs/#option-1%3A-type-assertion 432 | export default React.forwardRef(DraggableFlatList) as ( 433 | props: DraggableFlatListProps & { ref?: React.ForwardedRef> } 434 | ) => ReturnType; 435 | -------------------------------------------------------------------------------- /src/components/NestableDraggableFlatList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from "react"; 2 | import { findNodeHandle, LogBox } from "react-native"; 3 | import Animated, { 4 | useDerivedValue, 5 | useSharedValue, 6 | } from "react-native-reanimated"; 7 | import { DraggableFlatListProps } from "../types"; 8 | import DraggableFlatList from "../components/DraggableFlatList"; 9 | import { useSafeNestableScrollContainerContext } from "../context/nestableScrollContainerContext"; 10 | import { useNestedAutoScroll } from "../hooks/useNestedAutoScroll"; 11 | import { typedMemo } from "../utils"; 12 | import { useStableCallback } from "../hooks/useStableCallback"; 13 | import { FlatList } from "react-native-gesture-handler"; 14 | 15 | function NestableDraggableFlatListInner( 16 | props: DraggableFlatListProps, 17 | ref?: React.ForwardedRef> 18 | ) { 19 | const hasSuppressedWarnings = useRef(false); 20 | 21 | if (!hasSuppressedWarnings.current) { 22 | LogBox.ignoreLogs([ 23 | "VirtualizedLists should never be nested inside plain ScrollViews with the same orientation because it can break windowing", 24 | ]); // Ignore log notification by message 25 | //@ts-ignore 26 | console.reportErrorsAsExceptions = false; 27 | hasSuppressedWarnings.current = true; 28 | } 29 | 30 | const { 31 | scrollableRef, 32 | outerScrollOffset, 33 | setOuterScrollEnabled, 34 | } = useSafeNestableScrollContainerContext(); 35 | 36 | const listVerticalOffset = useSharedValue(0); 37 | const [animVals, setAnimVals] = useState({}); 38 | const defaultHoverOffset = useSharedValue(0); 39 | const [listHoverOffset, setListHoverOffset] = useState(defaultHoverOffset); 40 | 41 | const hoverOffset = useDerivedValue(() => { 42 | return listHoverOffset.value + listVerticalOffset.value; 43 | }, [listHoverOffset]); 44 | 45 | useNestedAutoScroll({ 46 | ...animVals, 47 | hoverOffset, 48 | }); 49 | 50 | const onListContainerLayout = useStableCallback(async ({ containerRef }) => { 51 | const nodeHandle = findNodeHandle(scrollableRef.current); 52 | 53 | const onSuccess = (_x: number, y: number) => { 54 | listVerticalOffset.value = y; 55 | }; 56 | const onFail = () => { 57 | console.log("## nested draggable list measure fail"); 58 | }; 59 | //@ts-ignore 60 | containerRef.current.measureLayout(nodeHandle, onSuccess, onFail); 61 | }); 62 | 63 | const onDragBegin: DraggableFlatListProps["onDragBegin"] = useStableCallback( 64 | (params) => { 65 | setOuterScrollEnabled(false); 66 | props.onDragBegin?.(params); 67 | } 68 | ); 69 | 70 | const onDragEnd: DraggableFlatListProps["onDragEnd"] = useStableCallback( 71 | (params) => { 72 | setOuterScrollEnabled(true); 73 | props.onDragEnd?.(params); 74 | } 75 | ); 76 | 77 | const onAnimValInit: DraggableFlatListProps["onAnimValInit"] = useStableCallback( 78 | (params) => { 79 | setListHoverOffset(params.hoverOffset); 80 | setAnimVals({ 81 | ...params, 82 | hoverOffset, 83 | }); 84 | props.onAnimValInit?.(params); 85 | } 86 | ); 87 | 88 | return ( 89 | 100 | ); 101 | } 102 | 103 | // Generic forwarded ref type assertion taken from: 104 | // https://fettblog.eu/typescript-react-generic-forward-refs/#option-1%3A-type-assertion 105 | export const NestableDraggableFlatList = React.forwardRef( 106 | NestableDraggableFlatListInner 107 | ) as ( 108 | props: DraggableFlatListProps & { ref?: React.ForwardedRef> } 109 | ) => ReturnType; 110 | -------------------------------------------------------------------------------- /src/components/NestableScrollContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { LayoutChangeEvent, ScrollViewProps } from "react-native"; 3 | import { ScrollView } from "react-native-gesture-handler"; 4 | import Animated, { useAnimatedScrollHandler } from "react-native-reanimated"; 5 | import { 6 | NestableScrollContainerProvider, 7 | useSafeNestableScrollContainerContext, 8 | } from "../context/nestableScrollContainerContext"; 9 | import { useStableCallback } from "../hooks/useStableCallback"; 10 | 11 | const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView); 12 | 13 | function NestableScrollContainerInner(props: ScrollViewProps) { 14 | const { 15 | outerScrollOffset, 16 | containerSize, 17 | scrollViewSize, 18 | scrollableRef, 19 | outerScrollEnabled, 20 | } = useSafeNestableScrollContainerContext(); 21 | 22 | const onScroll = useAnimatedScrollHandler({ 23 | onScroll: ({ contentOffset }) => { 24 | outerScrollOffset.value = contentOffset.y; 25 | }, 26 | }); 27 | 28 | const onLayout = useStableCallback((event: LayoutChangeEvent) => { 29 | const { 30 | nativeEvent: { layout }, 31 | } = event; 32 | containerSize.value = layout.height; 33 | }); 34 | 35 | const onContentSizeChange = useStableCallback((w: number, h: number) => { 36 | scrollViewSize.value = h; 37 | props.onContentSizeChange?.(w, h); 38 | }); 39 | 40 | return ( 41 | 50 | ); 51 | } 52 | 53 | export const NestableScrollContainer = React.forwardRef( 54 | (props: ScrollViewProps, forwardedRef?: React.ForwardedRef) => { 55 | return ( 56 | ) || undefined 59 | } 60 | > 61 | 62 | 63 | ); 64 | } 65 | ); 66 | -------------------------------------------------------------------------------- /src/components/PlaceholderItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useState } from "react"; 2 | import { StyleSheet } from "react-native"; 3 | import Animated, { 4 | runOnJS, 5 | useAnimatedReaction, 6 | useAnimatedStyle, 7 | } from "react-native-reanimated"; 8 | import { useAnimatedValues } from "../context/animatedValueContext"; 9 | import { useDraggableFlatListContext } from "../context/draggableFlatListContext"; 10 | import { useRefs } from "../context/refContext"; 11 | import { RenderPlaceholder } from "../types"; 12 | import { typedMemo } from "../utils"; 13 | 14 | type Props = { 15 | renderPlaceholder?: RenderPlaceholder; 16 | }; 17 | 18 | function PlaceholderItem({ renderPlaceholder }: Props) { 19 | const [size, setSize] = useState(0); 20 | const { 21 | activeCellSize, 22 | placeholderOffset, 23 | spacerIndexAnim, 24 | horizontalAnim, 25 | scrollOffset, 26 | } = useAnimatedValues(); 27 | const { keyToIndexRef, propsRef } = useRefs(); 28 | const { activeKey, horizontal } = useDraggableFlatListContext(); 29 | 30 | // Size does not seem to be respected when it is an animated style 31 | useAnimatedReaction( 32 | () => { 33 | return activeCellSize.value; 34 | }, 35 | (cur, prev) => { 36 | if (cur !== prev) { 37 | runOnJS(setSize)(cur); 38 | } 39 | } 40 | ); 41 | 42 | const activeIndex = activeKey 43 | ? keyToIndexRef.current.get(activeKey) 44 | : undefined; 45 | const activeItem = 46 | activeIndex === undefined ? null : propsRef.current?.data[activeIndex]; 47 | 48 | const animStyle = useAnimatedStyle(() => { 49 | const offset = placeholderOffset.value - scrollOffset.value 50 | return { 51 | opacity: size >= 0 ? 1 : 0, 52 | overflow: 'hidden', 53 | transform: [ 54 | horizontalAnim.value 55 | ? { translateX: offset } 56 | : { translateY: offset }, 57 | ], 58 | }; 59 | 60 | }, [spacerIndexAnim, placeholderOffset, horizontalAnim, scrollOffset, size]); 61 | 62 | const extraStyle = useMemo(() => { 63 | return horizontal ? { width: size } : { height: size }; 64 | }, [horizontal, size]) 65 | 66 | return ( 67 | 71 | {!activeItem || activeIndex === undefined 72 | ? null 73 | : renderPlaceholder?.({ item: activeItem, index: activeIndex })} 74 | 75 | ); 76 | } 77 | 78 | export default typedMemo(PlaceholderItem); 79 | -------------------------------------------------------------------------------- /src/components/RowItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import { useDraggableFlatListContext } from "../context/draggableFlatListContext"; 3 | import { useRefs } from "../context/refContext"; 4 | import { useStableCallback } from "../hooks/useStableCallback"; 5 | import { RenderItem } from "../types"; 6 | import { typedMemo } from "../utils"; 7 | 8 | type Props = { 9 | extraData?: any; 10 | drag: (itemKey: string) => void; 11 | item: T; 12 | renderItem: RenderItem; 13 | itemKey: string; 14 | debug?: boolean; 15 | }; 16 | 17 | function RowItem(props: Props) { 18 | const propsRef = useRef(props); 19 | propsRef.current = props; 20 | 21 | const { activeKey } = useDraggableFlatListContext(); 22 | const activeKeyRef = useRef(activeKey); 23 | activeKeyRef.current = activeKey; 24 | const { keyToIndexRef } = useRefs(); 25 | 26 | const drag = useStableCallback(() => { 27 | const { drag, itemKey, debug } = propsRef.current; 28 | if (activeKeyRef.current) { 29 | // already dragging an item, noop 30 | if (debug) 31 | console.log( 32 | "## attempt to drag item while another item is already active, noop" 33 | ); 34 | } 35 | drag(itemKey); 36 | }); 37 | 38 | const { renderItem, item, itemKey, extraData } = props; 39 | 40 | const getIndex = useStableCallback(() => { 41 | return keyToIndexRef.current.get(itemKey); 42 | }); 43 | 44 | return ( 45 | 53 | ); 54 | } 55 | 56 | export default typedMemo(RowItem); 57 | 58 | type InnerProps = { 59 | isActive: boolean; 60 | item: T; 61 | getIndex: () => number | undefined; 62 | drag: () => void; 63 | renderItem: RenderItem; 64 | extraData?: any; 65 | }; 66 | 67 | function Inner({ renderItem, extraData, ...rest }: InnerProps) { 68 | return renderItem({ ...rest }) as JSX.Element; 69 | } 70 | 71 | const MemoizedInner = typedMemo(Inner); 72 | -------------------------------------------------------------------------------- /src/components/ScrollOffsetListener.tsx: -------------------------------------------------------------------------------- 1 | import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"; 2 | import { typedMemo } from "../utils"; 3 | 4 | type Props = { 5 | scrollOffset: Animated.SharedValue; 6 | onScrollOffsetChange: (offset: number) => void; 7 | }; 8 | 9 | const ScrollOffsetListener = ({ 10 | scrollOffset, 11 | onScrollOffsetChange, 12 | }: Props) => { 13 | 14 | useAnimatedReaction(() => { 15 | return scrollOffset.value 16 | }, (cur, prev) => { 17 | if (cur !== prev) { 18 | runOnJS(onScrollOffsetChange)(cur) 19 | } 20 | }, [scrollOffset]) 21 | 22 | return null; 23 | }; 24 | 25 | export default typedMemo(ScrollOffsetListener); 26 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from "react-native"; 2 | import { PanGestureHandlerProperties } from "react-native-gesture-handler"; 3 | import Animated, { 4 | useSharedValue, 5 | WithSpringConfig, 6 | } from "react-native-reanimated"; 7 | 8 | // Fire onScrollComplete when within this many px of target offset 9 | export const SCROLL_POSITION_TOLERANCE = 2; 10 | export const DEFAULT_ANIMATION_CONFIG: WithSpringConfig = { 11 | damping: 20, 12 | mass: 0.2, 13 | stiffness: 100, 14 | overshootClamping: false, 15 | restSpeedThreshold: 0.2, 16 | restDisplacementThreshold: 0.2, 17 | }; 18 | 19 | export const DEFAULT_PROPS = { 20 | autoscrollThreshold: 30, 21 | autoscrollSpeed: 100, 22 | animationConfig: DEFAULT_ANIMATION_CONFIG, 23 | scrollEnabled: true, 24 | dragHitSlop: 0 as PanGestureHandlerProperties["hitSlop"], 25 | activationDistance: 0, 26 | dragItemOverflow: false, 27 | }; 28 | 29 | export const isIOS = Platform.OS === "ios"; 30 | export const isAndroid = Platform.OS === "android"; 31 | export const isWeb = Platform.OS === "web"; 32 | 33 | // Is there a better way to check for v2? 34 | export const isReanimatedV2 = !!useSharedValue; 35 | 36 | if (!isReanimatedV2) { 37 | throw new Error( 38 | "Your version of react-native-reanimated is too old for react-native-draggable-flatlist!" 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/context/animatedValueContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useEffect, useCallback, useContext } from "react"; 2 | import { 3 | useAnimatedReaction, 4 | useDerivedValue, 5 | useSharedValue, 6 | } from "react-native-reanimated"; 7 | import { State as GestureState } from "react-native-gesture-handler"; 8 | import { useProps } from "./propsContext"; 9 | 10 | const AnimatedValueContext = React.createContext< 11 | ReturnType | undefined 12 | >(undefined); 13 | 14 | export default function AnimatedValueProvider({ 15 | children, 16 | }: { 17 | children: React.ReactNode; 18 | }) { 19 | const value = useSetupAnimatedValues(); 20 | return ( 21 | 22 | {children} 23 | 24 | ); 25 | } 26 | 27 | export function useAnimatedValues() { 28 | const value = useContext(AnimatedValueContext); 29 | if (!value) { 30 | throw new Error( 31 | "useAnimatedValues must be called from within AnimatedValueProvider!" 32 | ); 33 | } 34 | return value; 35 | } 36 | 37 | function useSetupAnimatedValues() { 38 | const props = useProps(); 39 | 40 | const DEFAULT_VAL = useSharedValue(0); 41 | 42 | const containerSize = useSharedValue(0); 43 | const scrollViewSize = useSharedValue(0); 44 | 45 | const panGestureState = useSharedValue( 46 | GestureState.UNDETERMINED 47 | ); 48 | const touchTranslate = useSharedValue(0); 49 | 50 | const isTouchActiveNative = useSharedValue(false); 51 | 52 | const hasMoved = useSharedValue(0); 53 | const disabled = useSharedValue(false); 54 | 55 | const horizontalAnim = useSharedValue(!!props.horizontal); 56 | 57 | const activeIndexAnim = useSharedValue(-1); // Index of hovering cell 58 | const spacerIndexAnim = useSharedValue(-1); // Index of hovered-over cell 59 | 60 | const activeCellSize = useSharedValue(0); // Height or width of acctive cell 61 | const activeCellOffset = useSharedValue(0); // Distance between active cell and edge of container 62 | 63 | const scrollOffset = useSharedValue(0); 64 | const scrollInit = useSharedValue(0); 65 | 66 | const viewableIndexMin = useSharedValue(0); 67 | const viewableIndexMax = useSharedValue(0); 68 | 69 | // If list is nested there may be an outer scrollview 70 | const outerScrollOffset = props.outerScrollOffset || DEFAULT_VAL; 71 | const outerScrollInit = useSharedValue(0); 72 | 73 | useAnimatedReaction( 74 | () => { 75 | return activeIndexAnim.value; 76 | }, 77 | (cur, prev) => { 78 | if (cur !== prev && cur >= 0) { 79 | scrollInit.value = scrollOffset.value; 80 | outerScrollInit.value = outerScrollOffset.value; 81 | } 82 | }, 83 | [outerScrollOffset] 84 | ); 85 | 86 | const placeholderOffset = useSharedValue(0); 87 | 88 | const isDraggingCell = useDerivedValue(() => { 89 | return isTouchActiveNative.value && activeIndexAnim.value >= 0; 90 | }, []); 91 | 92 | const autoScrollDistance = useDerivedValue(() => { 93 | if (!isDraggingCell.value) return 0; 94 | const innerScrollDiff = scrollOffset.value - scrollInit.value; 95 | // If list is nested there may be an outer scroll diff 96 | const outerScrollDiff = outerScrollOffset.value - outerScrollInit.value; 97 | const scrollDiff = innerScrollDiff + outerScrollDiff; 98 | return scrollDiff; 99 | }, []); 100 | 101 | const touchPositionDiff = useDerivedValue(() => { 102 | const extraTranslate = isTouchActiveNative.value 103 | ? autoScrollDistance.value 104 | : 0; 105 | return touchTranslate.value + extraTranslate; 106 | }, []); 107 | 108 | const touchPositionDiffConstrained = useDerivedValue(() => { 109 | const containerMinusActiveCell = 110 | containerSize.value - activeCellSize.value + scrollOffset.value; 111 | 112 | const offsetRelativeToScrollTop = 113 | touchPositionDiff.value + activeCellOffset.value; 114 | const constrained = Math.min( 115 | containerMinusActiveCell, 116 | Math.max(scrollOffset.value, offsetRelativeToScrollTop) 117 | ); 118 | 119 | const maxTranslateNegative = -activeCellOffset.value; 120 | const maxTranslatePositive = 121 | scrollViewSize.value - (activeCellOffset.value + activeCellSize.value); 122 | 123 | // Only constrain the touch position while the finger is on the screen. This allows the active cell 124 | // to snap above/below the fold once let go, if the drag ends at the top/bottom of the screen. 125 | const constrainedBase = isTouchActiveNative.value 126 | ? constrained - activeCellOffset.value 127 | : touchPositionDiff.value; 128 | 129 | // Make sure item is constrained to the boundaries of the scrollview 130 | return Math.min( 131 | Math.max(constrainedBase, maxTranslateNegative), 132 | maxTranslatePositive 133 | ); 134 | }, []); 135 | 136 | const dragItemOverflow = props.dragItemOverflow; 137 | const hoverAnim = useDerivedValue(() => { 138 | if (activeIndexAnim.value < 0) return 0; 139 | return dragItemOverflow 140 | ? touchPositionDiff.value 141 | : touchPositionDiffConstrained.value; 142 | }, []); 143 | 144 | const hoverOffset = useDerivedValue(() => { 145 | return hoverAnim.value + activeCellOffset.value; 146 | }, [hoverAnim, activeCellOffset]); 147 | 148 | useDerivedValue(() => { 149 | // Reset spacer index when we stop hovering 150 | const isHovering = activeIndexAnim.value >= 0; 151 | if (!isHovering && spacerIndexAnim.value >= 0) { 152 | spacerIndexAnim.value = -1; 153 | } 154 | }, []); 155 | 156 | // Note: this could use a refactor as it combines touch state + cell animation 157 | const resetTouchedCell = useCallback(() => { 158 | activeCellOffset.value = 0; 159 | hasMoved.value = 0; 160 | }, []); 161 | 162 | const value = useMemo( 163 | () => ({ 164 | activeCellOffset, 165 | activeCellSize, 166 | activeIndexAnim, 167 | containerSize, 168 | disabled, 169 | horizontalAnim, 170 | hoverAnim, 171 | hoverOffset, 172 | isDraggingCell, 173 | isTouchActiveNative, 174 | panGestureState, 175 | placeholderOffset, 176 | resetTouchedCell, 177 | scrollOffset, 178 | scrollViewSize, 179 | spacerIndexAnim, 180 | touchPositionDiff, 181 | touchTranslate, 182 | autoScrollDistance, 183 | viewableIndexMin, 184 | viewableIndexMax, 185 | }), 186 | [ 187 | activeCellOffset, 188 | activeCellSize, 189 | activeIndexAnim, 190 | containerSize, 191 | disabled, 192 | horizontalAnim, 193 | hoverAnim, 194 | hoverOffset, 195 | isDraggingCell, 196 | isTouchActiveNative, 197 | panGestureState, 198 | placeholderOffset, 199 | resetTouchedCell, 200 | scrollOffset, 201 | scrollViewSize, 202 | spacerIndexAnim, 203 | touchPositionDiff, 204 | touchTranslate, 205 | autoScrollDistance, 206 | viewableIndexMin, 207 | viewableIndexMax, 208 | ] 209 | ); 210 | 211 | useEffect(() => { 212 | props.onAnimValInit?.(value); 213 | }, [value]); 214 | 215 | return value; 216 | } 217 | -------------------------------------------------------------------------------- /src/context/cellContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useMemo } from "react"; 2 | import { typedMemo } from "../utils"; 3 | 4 | type CellContextValue = { 5 | isActive: boolean; 6 | }; 7 | 8 | const CellContext = React.createContext( 9 | undefined 10 | ); 11 | 12 | type Props = { 13 | isActive: boolean; 14 | children: React.ReactNode; 15 | }; 16 | 17 | export function CellProvider({ isActive, children }: Props) { 18 | const value = useMemo( 19 | () => ({ 20 | isActive, 21 | }), 22 | [isActive] 23 | ); 24 | return {children}; 25 | } 26 | 27 | export default typedMemo(CellProvider); 28 | 29 | export function useIsActive() { 30 | const value = useContext(CellContext); 31 | if (!value) { 32 | throw new Error("useIsActive must be called from within CellProvider!"); 33 | } 34 | return value.isActive; 35 | } 36 | -------------------------------------------------------------------------------- /src/context/draggableFlatListContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useMemo } from "react"; 2 | 3 | type Props = { 4 | activeKey: string | null; 5 | keyExtractor: (item: T, index: number) => string; 6 | horizontal: boolean; 7 | layoutAnimationDisabled: boolean; 8 | children: React.ReactNode; 9 | }; 10 | 11 | type DraggableFlatListContextValue = Omit, "children">; 12 | 13 | const DraggableFlatListContext = React.createContext< 14 | DraggableFlatListContextValue | undefined 15 | >(undefined); 16 | 17 | export default function DraggableFlatListProvider({ 18 | activeKey, 19 | keyExtractor, 20 | horizontal, 21 | layoutAnimationDisabled, 22 | children, 23 | }: Props) { 24 | const value = useMemo( 25 | () => ({ 26 | activeKey, 27 | keyExtractor, 28 | horizontal, 29 | layoutAnimationDisabled, 30 | }), 31 | [activeKey, keyExtractor, horizontal, layoutAnimationDisabled] 32 | ); 33 | 34 | return ( 35 | 36 | {children} 37 | 38 | ); 39 | } 40 | 41 | export function useDraggableFlatListContext() { 42 | const value = useContext(DraggableFlatListContext); 43 | if (!value) { 44 | throw new Error( 45 | "useDraggableFlatListContext must be called within DraggableFlatListProvider" 46 | ); 47 | } 48 | return value as DraggableFlatListContextValue; 49 | } 50 | -------------------------------------------------------------------------------- /src/context/nestableScrollContainerContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useMemo, useRef, useState } from "react"; 2 | import { ScrollView } from "react-native-gesture-handler"; 3 | import Animated, { useSharedValue } from "react-native-reanimated"; 4 | 5 | type NestableScrollContainerContextVal = ReturnType< 6 | typeof useSetupNestableScrollContextValue 7 | >; 8 | const NestableScrollContainerContext = React.createContext< 9 | NestableScrollContainerContextVal | undefined 10 | >(undefined); 11 | 12 | function useSetupNestableScrollContextValue({ 13 | forwardedRef, 14 | }: { 15 | forwardedRef?: React.MutableRefObject; 16 | }) { 17 | const [outerScrollEnabled, setOuterScrollEnabled] = useState(true); 18 | const scrollViewSize = useSharedValue(0); 19 | const scrollableRefInner = useRef(null); 20 | const scrollableRef = forwardedRef || scrollableRefInner; 21 | const outerScrollOffset = useSharedValue(0); 22 | const containerSize = useSharedValue(0); 23 | 24 | const contextVal = useMemo( 25 | () => ({ 26 | outerScrollEnabled, 27 | setOuterScrollEnabled, 28 | outerScrollOffset, 29 | scrollViewSize, 30 | scrollableRef, 31 | containerSize, 32 | }), 33 | [outerScrollEnabled] 34 | ); 35 | 36 | return contextVal; 37 | } 38 | 39 | export function NestableScrollContainerProvider({ 40 | children, 41 | forwardedRef, 42 | }: { 43 | children: React.ReactNode; 44 | forwardedRef?: React.MutableRefObject; 45 | }) { 46 | const contextVal = useSetupNestableScrollContextValue({ forwardedRef }); 47 | return ( 48 | 49 | {children} 50 | 51 | ); 52 | } 53 | 54 | export function useNestableScrollContainerContext() { 55 | const value = useContext(NestableScrollContainerContext); 56 | return value; 57 | } 58 | 59 | export function useSafeNestableScrollContainerContext() { 60 | const value = useNestableScrollContainerContext(); 61 | if (!value) { 62 | throw new Error( 63 | "useSafeNestableScrollContainerContext must be called within a NestableScrollContainerContext.Provider" 64 | ); 65 | } 66 | return value; 67 | } 68 | -------------------------------------------------------------------------------- /src/context/propsContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { DraggableFlatListProps } from "../types"; 3 | 4 | const PropsContext = React.createContext< 5 | DraggableFlatListProps | undefined 6 | >(undefined); 7 | 8 | type Props = DraggableFlatListProps & { children: React.ReactNode }; 9 | 10 | export default function PropsProvider({ children, ...props }: Props) { 11 | return ( 12 | {children} 13 | ); 14 | } 15 | 16 | export function useProps() { 17 | const value = useContext(PropsContext) as 18 | | DraggableFlatListProps 19 | | undefined; 20 | if (!value) { 21 | throw new Error("useProps must be called from within PropsProvider!"); 22 | } 23 | return value; 24 | } 25 | -------------------------------------------------------------------------------- /src/context/refContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from "react"; 2 | import { useMemo, useRef } from "react"; 3 | import { FlatList } from "react-native-gesture-handler"; 4 | import Animated, { type SharedValue, useSharedValue, WithSpringConfig } from "react-native-reanimated"; 5 | import { DEFAULT_PROPS } from "../constants"; 6 | import { useProps } from "./propsContext"; 7 | import { CellData, DraggableFlatListProps } from "../types"; 8 | 9 | type RefContextValue = { 10 | propsRef: React.MutableRefObject>; 11 | animationConfigRef: SharedValue; 12 | cellDataRef: React.MutableRefObject>; 13 | keyToIndexRef: React.MutableRefObject>; 14 | containerRef: React.RefObject; 15 | flatlistRef: React.RefObject> | React.ForwardedRef>; 16 | scrollViewRef: React.RefObject; 17 | }; 18 | const RefContext = React.createContext | undefined>( 19 | undefined 20 | ); 21 | 22 | export default function RefProvider({ 23 | children, 24 | flatListRef, 25 | }: { 26 | children: React.ReactNode; 27 | flatListRef?: React.ForwardedRef> | null; 28 | }) { 29 | const value = useSetupRefs({ flatListRef }); 30 | return {children}; 31 | } 32 | 33 | export function useRefs() { 34 | const value = useContext(RefContext); 35 | if (!value) { 36 | throw new Error( 37 | "useRefs must be called from within a RefContext.Provider!" 38 | ); 39 | } 40 | return value as RefContextValue; 41 | } 42 | 43 | function useSetupRefs({ 44 | flatListRef: flatListRefProp, 45 | }: { 46 | flatListRef?: React.ForwardedRef> | null; 47 | }) { 48 | const props = useProps(); 49 | const { animationConfig = DEFAULT_PROPS.animationConfig } = props; 50 | 51 | const propsRef = useRef(props); 52 | propsRef.current = props; 53 | const animConfig = useMemo( 54 | () => ({ 55 | ...DEFAULT_PROPS.animationConfig, 56 | ...animationConfig, 57 | } as WithSpringConfig), 58 | [animationConfig] 59 | ); 60 | 61 | const animationConfigRef = useSharedValue(animConfig); 62 | useEffect(() => { 63 | animationConfigRef.value = animConfig; 64 | }, [animConfig]); 65 | 66 | const cellDataRef = useRef(new Map()); 67 | const keyToIndexRef = useRef(new Map()); 68 | const containerRef = useRef(null); 69 | const flatlistRefInternal = useRef>(null); 70 | const flatlistRef = flatListRefProp || flatlistRefInternal; 71 | const scrollViewRef = useRef(null); 72 | 73 | // useEffect(() => { 74 | // // This is a workaround for the fact that RN does not respect refs passed in 75 | // // to renderScrollViewComponent underlying ScrollView (currently not used but 76 | // // may need to add if we want to use reanimated scrollTo in the future) 77 | // //@ts-ignore 78 | // const scrollRef = flatlistRef.current?.getNativeScrollRef(); 79 | // if (!scrollViewRef.current) { 80 | // //@ts-ignore 81 | // scrollViewRef(scrollRef); 82 | // } 83 | // }, []); 84 | 85 | const refs = useMemo( 86 | () => ({ 87 | animationConfigRef, 88 | cellDataRef, 89 | containerRef, 90 | flatlistRef, 91 | keyToIndexRef, 92 | propsRef, 93 | scrollViewRef, 94 | }), 95 | [] 96 | ); 97 | 98 | return refs; 99 | } 100 | -------------------------------------------------------------------------------- /src/hooks/useAutoScroll.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | runOnJS, 3 | useAnimatedReaction, 4 | useDerivedValue, 5 | useSharedValue, 6 | } from "react-native-reanimated"; 7 | import { DEFAULT_PROPS, SCROLL_POSITION_TOLERANCE } from "../constants"; 8 | import { useProps } from "../context/propsContext"; 9 | import { useAnimatedValues } from "../context/animatedValueContext"; 10 | import { useRefs } from "../context/refContext"; 11 | 12 | export function useAutoScroll() { 13 | const { flatlistRef } = useRefs(); 14 | 15 | const { 16 | autoscrollThreshold = DEFAULT_PROPS.autoscrollThreshold, 17 | autoscrollSpeed = DEFAULT_PROPS.autoscrollSpeed, 18 | } = useProps(); 19 | 20 | const { 21 | scrollOffset, 22 | scrollViewSize, 23 | containerSize, 24 | activeCellSize, 25 | hoverOffset, 26 | activeIndexAnim, 27 | } = useAnimatedValues(); 28 | 29 | const hoverScreenOffset = useDerivedValue(() => { 30 | return hoverOffset.value - scrollOffset.value; 31 | }, []); 32 | 33 | const isScrolledUp = useDerivedValue(() => { 34 | return scrollOffset.value - SCROLL_POSITION_TOLERANCE <= 0; 35 | }, []); 36 | 37 | const isScrolledDown = useDerivedValue(() => { 38 | return ( 39 | scrollOffset.value + containerSize.value + SCROLL_POSITION_TOLERANCE >= 40 | scrollViewSize.value 41 | ); 42 | }, []); 43 | 44 | const distToTopEdge = useDerivedValue(() => { 45 | return Math.max(0, hoverScreenOffset.value); 46 | }, []); 47 | 48 | const distToBottomEdge = useDerivedValue(() => { 49 | const hoverPlusActiveCell = hoverScreenOffset.value + activeCellSize.value; 50 | return Math.max(0, containerSize.value - hoverPlusActiveCell); 51 | }, []); 52 | 53 | const isAtTopEdge = useDerivedValue(() => { 54 | return distToTopEdge.value <= autoscrollThreshold; 55 | }); 56 | 57 | const isAtBottomEdge = useDerivedValue(() => { 58 | return distToBottomEdge.value <= autoscrollThreshold; 59 | }, []); 60 | 61 | const scrollTarget = useSharedValue(0); 62 | const dragIsActive = useDerivedValue(() => { 63 | return activeIndexAnim.value >= 0; 64 | }, []); 65 | 66 | useAnimatedReaction( 67 | () => { 68 | return dragIsActive.value; 69 | }, 70 | (cur, prev) => { 71 | if (cur && !prev) { 72 | scrollTarget.value = scrollOffset.value; 73 | } 74 | } 75 | ); 76 | 77 | const shouldAutoScroll = useDerivedValue(() => { 78 | const scrollTargetDiff = Math.abs(scrollTarget.value - scrollOffset.value); 79 | const hasScrolledToTarget = scrollTargetDiff < SCROLL_POSITION_TOLERANCE; 80 | 81 | const isAtEdge = isAtTopEdge.value || isAtBottomEdge.value; 82 | const topDisabled = isAtTopEdge.value && isScrolledUp.value; 83 | const bottomDisabled = isAtBottomEdge.value && isScrolledDown.value; 84 | const isEdgeDisabled = topDisabled || bottomDisabled; 85 | 86 | const cellIsActive = activeIndexAnim.value >= 0; 87 | 88 | return hasScrolledToTarget && isAtEdge && !isEdgeDisabled && cellIsActive; 89 | }, []); 90 | 91 | function scrollToInternal(offset: number) { 92 | if (flatlistRef && "current" in flatlistRef) { 93 | flatlistRef.current?.scrollToOffset({ offset, animated: true }); 94 | } 95 | } 96 | 97 | useDerivedValue(() => { 98 | if (!shouldAutoScroll.value) return; 99 | 100 | const distFromEdge = isAtTopEdge.value 101 | ? distToTopEdge.value 102 | : distToBottomEdge.value; 103 | const speedPct = 1 - distFromEdge / autoscrollThreshold!; 104 | const offset = speedPct * autoscrollSpeed; 105 | const targetOffset = isAtTopEdge.value 106 | ? Math.max(0, scrollOffset.value - offset) 107 | : Math.min( 108 | scrollOffset.value + offset, 109 | scrollViewSize.value - containerSize.value 110 | ); 111 | 112 | scrollTarget.value = targetOffset; 113 | // Reanimated scrollTo is crashing on android. use 'regular' scrollTo until figured out. 114 | // scrollTo(scrollViewRef, targetX, targetY, true); 115 | runOnJS(scrollToInternal)(targetOffset); 116 | }, []); 117 | 118 | return null; 119 | } 120 | -------------------------------------------------------------------------------- /src/hooks/useCellTranslate.tsx: -------------------------------------------------------------------------------- 1 | import Animated, { useDerivedValue, withSpring } from "react-native-reanimated"; 2 | import { useAnimatedValues } from "../context/animatedValueContext"; 3 | import { useDraggableFlatListContext } from "../context/draggableFlatListContext"; 4 | import { useRefs } from "../context/refContext"; 5 | 6 | type Params = { 7 | cellIndex: number; 8 | cellSize: Animated.SharedValue; 9 | cellOffset: Animated.SharedValue; 10 | }; 11 | 12 | export function useCellTranslate({ cellIndex, cellSize, cellOffset }: Params) { 13 | const { 14 | activeIndexAnim, 15 | activeCellSize, 16 | hoverOffset, 17 | spacerIndexAnim, 18 | placeholderOffset, 19 | hoverAnim, 20 | viewableIndexMin, 21 | viewableIndexMax, 22 | } = useAnimatedValues(); 23 | 24 | const { activeKey } = useDraggableFlatListContext(); 25 | 26 | const { animationConfigRef } = useRefs(); 27 | 28 | const translate = useDerivedValue(() => { 29 | const isActiveCell = cellIndex === activeIndexAnim.value; 30 | const isOutsideViewableRange = 31 | !isActiveCell && 32 | (cellIndex < viewableIndexMin.value || 33 | cellIndex > viewableIndexMax.value); 34 | if (!activeKey || activeIndexAnim.value < 0 || isOutsideViewableRange) { 35 | return 0; 36 | } 37 | 38 | // Determining spacer index is hard to visualize. See diagram: https://i.imgur.com/jRPf5t3.jpg 39 | const isBeforeActive = cellIndex < activeIndexAnim.value; 40 | const isAfterActive = cellIndex > activeIndexAnim.value; 41 | 42 | const hoverPlusActiveSize = hoverOffset.value + activeCellSize.value; 43 | const offsetPlusHalfSize = cellOffset.value + cellSize.value / 2; 44 | const offsetPlusSize = cellOffset.value + cellSize.value; 45 | let result = -1; 46 | 47 | if (isAfterActive) { 48 | if ( 49 | hoverPlusActiveSize >= cellOffset.value && 50 | hoverPlusActiveSize < offsetPlusHalfSize 51 | ) { 52 | // bottom edge of active cell overlaps top half of current cell 53 | result = cellIndex - 1; 54 | } else if ( 55 | hoverPlusActiveSize >= offsetPlusHalfSize && 56 | hoverPlusActiveSize < offsetPlusSize 57 | ) { 58 | // bottom edge of active cell overlaps bottom half of current cell 59 | result = cellIndex; 60 | } 61 | } else if (isBeforeActive) { 62 | if ( 63 | hoverOffset.value < offsetPlusSize && 64 | hoverOffset.value >= offsetPlusHalfSize 65 | ) { 66 | // top edge of active cell overlaps bottom half of current cell 67 | result = cellIndex + 1; 68 | } else if ( 69 | hoverOffset.value >= cellOffset.value && 70 | hoverOffset.value < offsetPlusHalfSize 71 | ) { 72 | // top edge of active cell overlaps top half of current cell 73 | result = cellIndex; 74 | } 75 | } 76 | 77 | if (result !== -1 && result !== spacerIndexAnim.value) { 78 | spacerIndexAnim.value = result; 79 | } 80 | 81 | if (spacerIndexAnim.value === cellIndex) { 82 | const newPlaceholderOffset = isAfterActive 83 | ? cellSize.value + (cellOffset.value - activeCellSize.value) 84 | : cellOffset.value; 85 | placeholderOffset.value = newPlaceholderOffset; 86 | } 87 | 88 | // Active cell follows touch 89 | if (isActiveCell) { 90 | return hoverAnim.value; 91 | } 92 | 93 | // Translate cell down if it is before active index and active cell has passed it. 94 | // Translate cell up if it is after the active index and active cell has passed it. 95 | 96 | const shouldTranslate = isAfterActive 97 | ? cellIndex <= spacerIndexAnim.value 98 | : cellIndex >= spacerIndexAnim.value; 99 | 100 | const translationAmt = shouldTranslate 101 | ? activeCellSize.value * (isAfterActive ? -1 : 1) 102 | : 0; 103 | 104 | return withSpring(translationAmt, animationConfigRef.value); 105 | }, [activeKey, cellIndex]); 106 | 107 | return translate; 108 | } 109 | -------------------------------------------------------------------------------- /src/hooks/useNestedAutoScroll.tsx: -------------------------------------------------------------------------------- 1 | import Animated, { 2 | runOnJS, 3 | useAnimatedReaction, 4 | useDerivedValue, 5 | useSharedValue, 6 | } from "react-native-reanimated"; 7 | import { State as GestureState } from "react-native-gesture-handler"; 8 | import { useSafeNestableScrollContainerContext } from "../context/nestableScrollContainerContext"; 9 | import { SCROLL_POSITION_TOLERANCE } from "../constants"; 10 | 11 | // This is mostly copied over from the main react-native-draggable-flatlist 12 | // useAutoScroll hook with a few notable exceptions: 13 | // - Since animated values are now coming in via a callback, 14 | // we won't guarantee they exist (and default them if not). 15 | // - Outer scrollable is a ScrollView, not a FlatList 16 | // TODO: see if we can combine into a single shared `useAutoScroll()` hook 17 | 18 | export function useNestedAutoScroll(params: { 19 | activeCellSize?: Animated.SharedValue; 20 | autoscrollSpeed?: number; 21 | autoscrollThreshold?: number; 22 | hoverOffset?: Animated.SharedValue; 23 | isDraggingCell?: Animated.SharedValue; 24 | isTouchActiveNative?: Animated.SharedValue; 25 | panGestureState?: Animated.SharedValue; 26 | }) { 27 | const { 28 | outerScrollOffset, 29 | containerSize, 30 | scrollableRef, 31 | scrollViewSize, 32 | } = useSafeNestableScrollContainerContext(); 33 | 34 | const DUMMY_VAL = useSharedValue(0); 35 | 36 | const { 37 | hoverOffset = DUMMY_VAL, 38 | activeCellSize = DUMMY_VAL, 39 | autoscrollSpeed = 100, 40 | autoscrollThreshold = 30, 41 | isDraggingCell = DUMMY_VAL, 42 | isTouchActiveNative = DUMMY_VAL, 43 | } = params; 44 | 45 | const hoverScreenOffset = useDerivedValue(() => { 46 | return hoverOffset.value - outerScrollOffset.value; 47 | }, []); 48 | 49 | const isScrolledUp = useDerivedValue(() => { 50 | return outerScrollOffset.value - SCROLL_POSITION_TOLERANCE <= 0; 51 | }, []); 52 | 53 | const isScrolledDown = useDerivedValue(() => { 54 | return ( 55 | outerScrollOffset.value + containerSize.value + SCROLL_POSITION_TOLERANCE >= 56 | scrollViewSize.value 57 | ); 58 | }, []); 59 | 60 | const distToTopEdge = useDerivedValue(() => { 61 | return Math.max(0, hoverScreenOffset.value); 62 | }, [hoverScreenOffset]); 63 | 64 | const distToBottomEdge = useDerivedValue(() => { 65 | const dist = containerSize.value - (hoverScreenOffset.value + activeCellSize.value) 66 | return Math.max(0, dist); 67 | }, [hoverScreenOffset, activeCellSize, containerSize]); 68 | 69 | const isAtTopEdge = useDerivedValue(() => { 70 | return distToTopEdge.value <= autoscrollThreshold; 71 | }, []); 72 | 73 | const isAtBottomEdge = useDerivedValue(() => { 74 | return distToBottomEdge.value <= autoscrollThreshold; 75 | }); 76 | 77 | const scrollTarget = useSharedValue(0); 78 | 79 | useAnimatedReaction( 80 | () => { 81 | return isDraggingCell.value; 82 | }, 83 | (cur, prev) => { 84 | if (cur && !prev) { 85 | scrollTarget.value = outerScrollOffset.value; 86 | } 87 | }, 88 | [activeCellSize] 89 | ); 90 | 91 | function scrollToInternal(y: number) { 92 | scrollableRef.current?.scrollTo({ y, animated: true }); 93 | } 94 | 95 | useDerivedValue(() => { 96 | const isAtEdge = isAtTopEdge.value || isAtBottomEdge.value; 97 | const topDisabled = isAtTopEdge.value && isScrolledUp.value; 98 | const bottomDisabled = isAtBottomEdge.value && isScrolledDown.value; 99 | const isEdgeDisabled = topDisabled || bottomDisabled; 100 | 101 | const scrollTargetDiff = Math.abs(scrollTarget.value - outerScrollOffset.value); 102 | const scrollInProgress = scrollTargetDiff > SCROLL_POSITION_TOLERANCE; 103 | 104 | const shouldScroll = 105 | isAtEdge && 106 | !isEdgeDisabled && 107 | isDraggingCell.value && 108 | isTouchActiveNative.value && 109 | !scrollInProgress; 110 | 111 | const distFromEdge = isAtTopEdge.value 112 | ? distToTopEdge.value 113 | : distToBottomEdge.value; 114 | const speedPct = 1 - distFromEdge / autoscrollThreshold; 115 | const offset = speedPct * autoscrollSpeed; 116 | const targetOffset = isAtTopEdge.value 117 | ? Math.max(0, outerScrollOffset.value - offset) 118 | : outerScrollOffset.value + offset; 119 | if (shouldScroll) { 120 | scrollTarget.value = targetOffset; 121 | // Reanimated scrollTo is crashing on android. use 'regular' scrollTo until figured out. 122 | // scrollTo(scrollViewRef, 0, scrollTarget.value, true) 123 | runOnJS(scrollToInternal)(targetOffset); 124 | } 125 | }, [autoscrollSpeed, autoscrollThreshold, isDraggingCell]); 126 | 127 | return null; 128 | } 129 | -------------------------------------------------------------------------------- /src/hooks/useOnCellActiveAnimation.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import Animated, { 3 | useDerivedValue, 4 | useSharedValue, 5 | withSpring, 6 | WithSpringConfig, 7 | } from "react-native-reanimated"; 8 | import { DEFAULT_ANIMATION_CONFIG } from "../constants"; 9 | import { useAnimatedValues } from "../context/animatedValueContext"; 10 | import { useIsActive } from "../context/cellContext"; 11 | 12 | type Params = { 13 | animationConfig: Partial; 14 | }; 15 | 16 | export function useOnCellActiveAnimation( 17 | { animationConfig }: Params = { animationConfig: {} } 18 | ) { 19 | const animationConfigRef = useSharedValue(animationConfig); 20 | 21 | useEffect(() => { 22 | animationConfigRef.value = animationConfig; 23 | }, [animationConfig]); 24 | 25 | const isActive = useIsActive(); 26 | 27 | const { isTouchActiveNative } = useAnimatedValues(); 28 | 29 | const onActiveAnim = useDerivedValue(() => { 30 | const toVal = isActive && isTouchActiveNative.value ? 1 : 0; 31 | return withSpring(toVal, { 32 | ...DEFAULT_ANIMATION_CONFIG, 33 | ...animationConfigRef.value, 34 | } as WithSpringConfig); 35 | }, [isActive]); 36 | 37 | return { 38 | isActive, 39 | onActiveAnim, 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/hooks/useStableCallback.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useCallback } from "react"; 2 | 3 | // Utility hook that returns a function that never has stale dependencies, but 4 | // without changing identity, as a useCallback with dep array would. 5 | // Useful for functions that depend on external state, but 6 | // should not trigger effects when that external state changes. 7 | 8 | export function useStableCallback< 9 | T extends (arg1?: any, arg2?: any, arg3?: any) => any 10 | >(cb: T) { 11 | const cbRef = useRef(cb); 12 | cbRef.current = cb; 13 | const identityRetainingCb = useCallback( 14 | (...args: Parameters) => cbRef.current(...args), 15 | [] 16 | ); 17 | return identityRetainingCb as T; 18 | } 19 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import DraggableFlatList from "./components/DraggableFlatList"; 2 | export * from "./components/CellDecorators"; 3 | export * from "./components/NestableDraggableFlatList"; 4 | export * from "./components/NestableScrollContainer"; 5 | export * from "./types"; 6 | export default DraggableFlatList; 7 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | FlatListProps, 4 | LayoutChangeEvent, 5 | StyleProp, 6 | ViewStyle, 7 | } from "react-native"; 8 | import { useAnimatedValues } from "./context/animatedValueContext"; 9 | import { FlatList } from "react-native-gesture-handler"; 10 | import Animated, { 11 | AnimateProps, 12 | WithSpringConfig, 13 | } from "react-native-reanimated"; 14 | import { DEFAULT_PROPS } from "./constants"; 15 | 16 | export type DragEndParams = { 17 | data: T[]; 18 | from: number; 19 | to: number; 20 | }; 21 | type Modify = Omit & R; 22 | 23 | type DefaultProps = Readonly; 24 | 25 | export type DraggableFlatListProps = Modify< 26 | FlatListProps, 27 | { 28 | data: T[]; 29 | activationDistance?: number; 30 | animationConfig?: Partial; 31 | autoscrollSpeed?: number; 32 | autoscrollThreshold?: number; 33 | containerStyle?: StyleProp; 34 | debug?: boolean; 35 | dragItemOverflow?: boolean; 36 | keyExtractor: (item: T, index: number) => string; 37 | onDragBegin?: (index: number) => void; 38 | onDragEnd?: (params: DragEndParams) => void; 39 | onPlaceholderIndexChange?: (placeholderIndex: number) => void; 40 | onRelease?: (index: number) => void; 41 | onScrollOffsetChange?: (scrollOffset: number) => void; 42 | renderItem: RenderItem; 43 | renderPlaceholder?: RenderPlaceholder; 44 | simultaneousHandlers?: React.Ref | React.Ref[]; 45 | outerScrollOffset?: Animated.SharedValue; 46 | onAnimValInit?: (animVals: ReturnType) => void; 47 | itemEnteringAnimation?: AnimateProps["entering"]; 48 | itemExitingAnimation?: AnimateProps["exiting"]; 49 | itemLayoutAnimation?: AnimateProps["layout"]; 50 | enableLayoutAnimationExperimental?: boolean; 51 | onContainerLayout?: (params: { 52 | layout: LayoutChangeEvent["nativeEvent"]["layout"]; 53 | containerRef: React.RefObject; 54 | }) => void; 55 | } & Partial 56 | >; 57 | 58 | export type RenderPlaceholder = (params: { 59 | item: T; 60 | index: number; 61 | }) => JSX.Element; 62 | 63 | export type RenderItemParams = { 64 | item: T; 65 | getIndex: () => number | undefined; // This is technically a "last known index" since cells don't necessarily rerender when their index changes 66 | drag: () => void; 67 | isActive: boolean; 68 | }; 69 | 70 | export type RenderItem = (params: RenderItemParams) => React.ReactNode; 71 | 72 | export type AnimatedFlatListType = ( 73 | props: Animated.AnimateProps< 74 | FlatListProps & { 75 | ref: React.Ref>; 76 | simultaneousHandlers?: React.Ref | React.Ref[]; 77 | } 78 | > 79 | ) => React.ReactElement; 80 | 81 | export type CellData = { 82 | measurements: { 83 | size: number; 84 | offset: number; 85 | }; 86 | }; 87 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // Fixes bug with useMemo + generic types: 4 | // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/37087#issuecomment-542793243 5 | export const typedMemo: (c: T) => T = React.memo; 6 | -------------------------------------------------------------------------------- /tests/index.test.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Text, View } from "react-native"; 3 | import { fireEvent, render } from "@testing-library/react-native"; 4 | import DraggableFlatList from "../src/index"; 5 | 6 | jest.mock("react-native-reanimated", () => 7 | require("react-native-reanimated/mock") 8 | ); 9 | 10 | 11 | const DummyFlatList = (props) => { 12 | const [data] = useState([ 13 | { id: "1", name: "item 1" }, 14 | { id: "2", name: "item 2" } 15 | ]); 16 | 17 | return ( 18 | item.id} 20 | renderItem={({ item, drag }) => ( 21 | 22 | {item.name} 23 | 24 | )} 25 | testID="draggable-flat-list" 26 | data={data} 27 | {...props} 28 | /> 29 | ); 30 | }; 31 | 32 | describe("DraggableFlatList", () => { 33 | 34 | const setup = propOverrides => { 35 | const defaultProps = { 36 | ...propOverrides 37 | }; 38 | 39 | return render(); 40 | }; 41 | 42 | it("calls onDragBegin with the index of the element when the drag starts", () => { 43 | const mockOnDragBegin = jest.fn(); 44 | const { getByText } = setup({ onDragBegin: mockOnDragBegin }); 45 | 46 | fireEvent(getByText("item 1"), "longPress"); 47 | 48 | expect(mockOnDragBegin).toHaveBeenCalledWith(0); 49 | }); 50 | 51 | it("renders a placeholder when renderPlaceholder is defined", () => { 52 | const renderPlaceholder = () => ; 53 | const { getByText, getByTestId } = setup({ 54 | renderPlaceholder: renderPlaceholder 55 | }); 56 | 57 | fireEvent(getByText("item 1"), "longPress"); 58 | 59 | expect(getByTestId("some-placeholder")).toBeDefined(); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "jsx": "react", 5 | "lib": ["esnext"], 6 | "skipLibCheck": true, 7 | "strict": true 8 | }, 9 | "exclude": ["node_modules"], 10 | "include": ["src"] 11 | } 12 | --------------------------------------------------------------------------------