Port to Reanimated v2 (#335)

* first pass v2 migration

* autoscroll (WIP

* update decorators, cleanup

* ts ignore

* tweak constrained hover anim

* use single useDerivedValue

* fixed layout measurement (#325)

Co-authored-by: Tatiana Iasencova <t.iasencova@unifun.com>

* back to working

* fix autoscroll

* fix placeholder item

* bugfix placeholder

* add identityRetainingCallback

* enforce reanimated v2

* fix onDragEnd

* cleanup

* fix filcker (mostly)

* flip raf and setTimeout

* fix placeholder position

* fix filicker, nestable checkpoint

* fix nested scroll

* remove unnecessary wrapper

* fix placeholderitem

* cleanup, fix tests

* remove fail case

* add bob

* 4.0.0-beta.0

* memoize, useAnimatedRef -> useRef

* memoize, index -> getIndex

* memoize

* performance, cleanup

* update README

* fix ref types

* 4.0.0-beta.1

* disable gestures when animating

* bump reanimated

* 4.0.0-beta.2

* fix commonjs build

* 4.0.0-beta.3

* prevent flicker on web

* use v2 gesture api

* no worklet

* useIdentityRetainingCallback -> useStableCallback

* 4.0.0-beta.4

* foward refs

* add example

* add npmignore

* 4.0.0-beta.5

* fix autoscroll not working after scroll down

Co-authored-by: computerjazz <hi@danielmerrill.com>
Co-authored-by: taniaI <47004999+taniaIas@users.noreply.github.com>
Co-authored-by: Tatiana Iasencova <t.iasencova@unifun.com>
This commit is contained in:
Daniel Merrill 2022-07-24 19:47:45 -07:00 committed by GitHub
parent d8a392bf4a
commit 0937df86d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 12581 additions and 1774 deletions

2
.npmignore Normal file
View File

@ -0,0 +1,2 @@
/Example
/tests

View File

@ -0,0 +1,6 @@
{
"e997a5256149a4b76e6bfd6cbf519c5e5a0f1d278a3d8fa1253022b03c90473b": true,
"af683c96e0ffd2cf81287651c9433fa44debc1220ca7cb431fe482747f34a505": true,
"12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
"40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
}

14
Example/.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# macOS
.DS_Store

22
Example/App.tsx Normal file
View File

@ -0,0 +1,22 @@
import { StatusBar } from "expo-status-bar";
import { SafeAreaProvider } from "react-native-safe-area-context";
import useCachedResources from "./hooks/useCachedResources";
import useColorScheme from "./hooks/useColorScheme";
import Navigation from "./navigation";
export default function App() {
const isLoadingComplete = useCachedResources();
const colorScheme = useColorScheme();
if (!isLoadingComplete) {
return null;
} else {
return (
<SafeAreaProvider>
<Navigation colorScheme={colorScheme} />
<StatusBar />
</SafeAreaProvider>
);
}
}

32
Example/app.json Normal file
View File

@ -0,0 +1,32 @@
{
"expo": {
"name": "Example",
"slug": "Example",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "myapp",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"updates": {
"fallbackToCacheTimeout": 0
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"web": {
"favicon": "./assets/images/favicon.png"
}
}
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

7
Example/babel.config.js Normal file
View File

@ -0,0 +1,7 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: ["react-native-reanimated/plugin"],
};
};

View File

@ -0,0 +1,84 @@
import * as WebBrowser from "expo-web-browser";
import { StyleSheet, TouchableOpacity } from "react-native";
import Colors from "../constants/Colors";
import { MonoText } from "./StyledText";
import { Text, View } from "./Themed";
export default function EditScreenInfo({ path }: { path: string }) {
return (
<View>
<View style={styles.getStartedContainer}>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)"
>
Open up the code for this screen:
</Text>
<View
style={[styles.codeHighlightContainer, styles.homeScreenFilename]}
darkColor="rgba(255,255,255,0.05)"
lightColor="rgba(0,0,0,0.05)"
>
<MonoText>{path}</MonoText>
</View>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)"
>
Change any of the text, save the file, and your app will automatically
update.
</Text>
</View>
<View style={styles.helpContainer}>
<TouchableOpacity onPress={handleHelpPress} style={styles.helpLink}>
<Text style={styles.helpLinkText} lightColor={Colors.light.tint}>
Tap here if your app doesn't automatically update after making
changes
</Text>
</TouchableOpacity>
</View>
</View>
);
}
function handleHelpPress() {
WebBrowser.openBrowserAsync(
"https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet"
);
}
const styles = StyleSheet.create({
getStartedContainer: {
alignItems: "center",
marginHorizontal: 50,
},
homeScreenFilename: {
marginVertical: 7,
},
codeHighlightContainer: {
borderRadius: 3,
paddingHorizontal: 4,
},
getStartedText: {
fontSize: 17,
lineHeight: 24,
textAlign: "center",
},
helpContainer: {
marginTop: 15,
marginHorizontal: 20,
alignItems: "center",
},
helpLink: {
paddingVertical: 15,
},
helpLinkText: {
textAlign: "center",
},
});

View File

@ -0,0 +1,7 @@
import { Text, TextProps } from "./Themed";
export function MonoText(props: TextProps) {
return (
<Text {...props} style={[props.style, { fontFamily: "space-mono" }]} />
);
}

View File

@ -0,0 +1,48 @@
/**
* Learn more about Light and Dark modes:
* https://docs.expo.io/guides/color-schemes/
*/
import { Text as DefaultText, View as DefaultView } from "react-native";
import Colors from "../constants/Colors";
import useColorScheme from "../hooks/useColorScheme";
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme();
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}
type ThemeProps = {
lightColor?: string;
darkColor?: string;
};
export type TextProps = ThemeProps & DefaultText["props"];
export type ViewProps = ThemeProps & DefaultView["props"];
export function Text(props: TextProps) {
const { style, lightColor, darkColor, ...otherProps } = props;
const color = useThemeColor({ light: lightColor, dark: darkColor }, "text");
return <DefaultText style={[{ color }, style]} {...otherProps} />;
}
export function View(props: ViewProps) {
const { style, lightColor, darkColor, ...otherProps } = props;
const backgroundColor = useThemeColor(
{ light: lightColor, dark: darkColor },
"background"
);
return <DefaultView style={[{ backgroundColor }, style]} {...otherProps} />;
}

View File

@ -0,0 +1,10 @@
import * as React from "react";
import renderer from "react-test-renderer";
import { MonoText } from "../StyledText";
it(`renders correctly`, () => {
const tree = renderer.create(<MonoText>Snapshot test!</MonoText>).toJSON();
expect(tree).toMatchSnapshot();
});

View File

@ -0,0 +1,19 @@
const tintColorLight = "#2f95dc";
const tintColorDark = "#fff";
export default {
light: {
text: "#000",
background: "#fff",
tint: tintColorLight,
tabIconDefault: "#ccc",
tabIconSelected: tintColorLight,
},
dark: {
text: "#fff",
background: "#000",
tint: tintColorDark,
tabIconDefault: "#ccc",
tabIconSelected: tintColorDark,
},
};

View File

@ -0,0 +1,12 @@
import { Dimensions } from "react-native";
const width = Dimensions.get("window").width;
const height = Dimensions.get("window").height;
export default {
window: {
width,
height,
},
isSmallDevice: width < 375,
};

View File

@ -0,0 +1,33 @@
import { FontAwesome } from "@expo/vector-icons";
import * as Font from "expo-font";
import * as SplashScreen from "expo-splash-screen";
import { useEffect, useState } from "react";
export default function useCachedResources() {
const [isLoadingComplete, setLoadingComplete] = useState(false);
// Load any resources or data that we need prior to rendering the app
useEffect(() => {
async function loadResourcesAndDataAsync() {
try {
SplashScreen.preventAutoHideAsync();
// Load fonts
await Font.loadAsync({
...FontAwesome.font,
"space-mono": require("../assets/fonts/SpaceMono-Regular.ttf"),
});
} catch (e) {
// We might want to provide this error information to an error reporting service
console.warn(e);
} finally {
setLoadingComplete(true);
SplashScreen.hideAsync();
}
}
loadResourcesAndDataAsync();
}, []);
return isLoadingComplete;
}

View File

@ -0,0 +1,11 @@
import {
ColorSchemeName,
useColorScheme as _useColorScheme,
} from "react-native";
// The useColorScheme value is always either light or dark, but the built-in
// type suggests that it can be null. This will not happen in practice, so this
// makes it a bit easier to work with.
export default function useColorScheme(): NonNullable<ColorSchemeName> {
return _useColorScheme() as NonNullable<ColorSchemeName>;
}

31
Example/metro.config.js Normal file
View File

@ -0,0 +1,31 @@
/**
* Metro configuration for React Native
* https://github.com/facebook/react-native
*
* @format
*/
const path = require("path");
const extraNodeModules = {
"react-native-draggable-flatlist": path.resolve(__dirname + "/../src"),
};
const watchFolders = [path.resolve(__dirname + "/../src")];
module.exports = {
transformer: {
getTransformOptions: async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: false,
},
}),
},
resolver: {
extraNodeModules: new Proxy(extraNodeModules, {
get: (target, name) =>
//redirects dependencies referenced from target/ to local node_modules
name in target
? target[name]
: path.join(process.cwd(), `node_modules/${name}`),
}),
},
watchFolders,
};

View File

@ -0,0 +1,35 @@
/**
* Learn more about deep linking with React Navigation
* https://reactnavigation.org/docs/deep-linking
* https://reactnavigation.org/docs/configuring-links
*/
import { LinkingOptions } from "@react-navigation/native";
import * as Linking from "expo-linking";
import { RootStackParamList } from "../types";
const linking: LinkingOptions<RootStackParamList> = {
prefixes: [Linking.makeUrl("/")],
config: {
screens: {
Root: {
screens: {
TabOne: {
screens: {
Basic: "basic",
},
},
TabTwo: {
screens: {
Swipeable: "swipeable",
},
},
},
},
NotFound: "*",
},
},
};
export default linking;

View File

@ -0,0 +1,128 @@
/**
* If you are not familiar with React Navigation, refer to the "Fundamentals" guide:
* https://reactnavigation.org/docs/getting-started
*
*/
import { FontAwesome } from "@expo/vector-icons";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import {
NavigationContainer,
DefaultTheme,
DarkTheme,
} from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import * as React from "react";
import { ColorSchemeName, Pressable } from "react-native";
import Colors from "../constants/Colors";
import useColorScheme from "../hooks/useColorScheme";
import NotFoundScreen from "../screens/NotFoundScreen";
import BasicScreen from "../screens/BasicScreen";
import SwipeableScreen from "../screens/SwipeableScreen";
import {
RootStackParamList,
RootTabParamList,
RootTabScreenProps,
} from "../types";
import LinkingConfiguration from "./LinkingConfiguration";
import NestedScreen from "../screens/NestedScreen";
import HorizontalScreen from "../screens/HorizontalScreen";
export default function Navigation({
colorScheme,
}: {
colorScheme: ColorSchemeName;
}) {
return (
<NavigationContainer
linking={LinkingConfiguration}
theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}
>
<RootNavigator />
</NavigationContainer>
);
}
/**
* A root stack navigator is often used for displaying modals on top of all other content.
* https://reactnavigation.org/docs/modal
*/
const Stack = createNativeStackNavigator<RootStackParamList>();
function RootNavigator() {
return (
<Stack.Navigator>
<Stack.Screen
name="Root"
component={BottomTabNavigator}
options={{ headerShown: false }}
/>
</Stack.Navigator>
);
}
/**
* A bottom tab navigator displays tab buttons on the bottom of the display to switch screens.
* https://reactnavigation.org/docs/bottom-tab-navigator
*/
const BottomTab = createBottomTabNavigator<RootTabParamList>();
function BottomTabNavigator() {
const colorScheme = useColorScheme();
return (
<BottomTab.Navigator
initialRouteName="Basic"
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme].tint,
}}
>
<BottomTab.Screen
name="Basic"
component={BasicScreen}
options={({ navigation }: RootTabScreenProps<"Basic">) => ({
title: "Basic",
tabBarIcon: ({ color }) => <TabBarIcon name="list" color={color} />,
})}
/>
<BottomTab.Screen
name="Swipeable"
component={SwipeableScreen}
options={{
title: "Swipeable",
tabBarIcon: ({ color }) => (
<TabBarIcon name="hand-o-left" color={color} />
),
}}
/>
<BottomTab.Screen
name="Nested"
component={NestedScreen}
options={{
title: "Nested",
tabBarIcon: ({ color }) => <TabBarIcon name="indent" color={color} />,
}}
/>
<BottomTab.Screen
name="Horizontal"
component={HorizontalScreen}
options={{
title: "Horizontal",
tabBarIcon: ({ color }) => (
<TabBarIcon name="arrows-h" color={color} />
),
}}
/>
</BottomTab.Navigator>
);
}
/**
* You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
*/
function TabBarIcon(props: {
name: React.ComponentProps<typeof FontAwesome>["name"];
color: string;
}) {
return <FontAwesome size={30} style={{ marginBottom: -3 }} {...props} />;
}

50
Example/package.json Normal file
View File

@ -0,0 +1,50 @@
{
"name": "example",
"version": "1.0.0",
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"eject": "expo eject",
"test": "jest --watchAll"
},
"jest": {
"preset": "jest-expo"
},
"dependencies": {
"@expo/vector-icons": "^13.0.0",
"@react-navigation/bottom-tabs": "^6.0.5",
"@react-navigation/native": "^6.0.2",
"@react-navigation/native-stack": "^6.1.0",
"expo": "~45.0.0",
"expo-asset": "~8.5.0",
"expo-constants": "~13.1.1",
"expo-font": "~10.1.0",
"expo-linking": "~3.1.0",
"expo-splash-screen": "~0.15.1",
"expo-status-bar": "~1.3.0",
"expo-system-ui": "~1.2.0",
"expo-web-browser": "~10.2.0",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-native": "0.68.2",
"react-native-gesture-handler": "~2.2.1",
"react-native-reanimated": "~2.8.0",
"react-native-safe-area-context": "4.2.4",
"react-native-screens": "~3.11.1",
"react-native-swipeable-item": "^2.0.2",
"react-native-web": "0.17.7"
},
"devDependencies": {
"@babel/core": "^7.12.9",
"@types/react": "~17.0.21",
"@types/react-native": "~0.66.13",
"jest": "^26.6.3",
"jest-expo": "~45.0.0",
"react-test-renderer": "17.0.2",
"typescript": "~4.3.5"
},
"private": true
}

View File

@ -0,0 +1,69 @@
import React, { useCallback, useState } from "react";
import { Text, View, StyleSheet, TouchableOpacity } from "react-native";
import DraggableFlatList, {
ScaleDecorator,
ShadowDecorator,
OpacityDecorator,
RenderItemParams,
} from "react-native-draggable-flatlist";
import { mapIndexToData, Item } from "../utils";
const NUM_ITEMS = 100;
const initialData: Item[] = [...Array(NUM_ITEMS)].map(mapIndexToData);
export default function Basic() {
const [data, setData] = useState(initialData);
const renderItem = useCallback(
({ item, drag, isActive }: RenderItemParams<Item>) => {
return (
<ShadowDecorator>
<ScaleDecorator>
<OpacityDecorator>
<TouchableOpacity
activeOpacity={1}
onLongPress={drag}
disabled={isActive}
style={[
styles.rowItem,
{ backgroundColor: isActive ? "blue" : item.backgroundColor },
]}
>
<Text style={styles.text}>{item.text}</Text>
</TouchableOpacity>
</OpacityDecorator>
</ScaleDecorator>
</ShadowDecorator>
);
},
[]
);
return (
<DraggableFlatList
data={data}
onDragEnd={({ data }) => setData(data)}
keyExtractor={(item) => item.key}
renderItem={renderItem}
renderPlaceholder={() => (
<View style={{ flex: 1, backgroundColor: "tomato" }} />
)}
/>
);
}
const styles = StyleSheet.create({
rowItem: {
height: 100,
alignItems: "center",
justifyContent: "center",
},
text: {
color: "white",
fontSize: 24,
fontWeight: "bold",
textAlign: "center",
},
});

View File

@ -0,0 +1,68 @@
import React, { useCallback, useState } from "react";
import { Text, View, StyleSheet, TouchableOpacity } from "react-native";
import DraggableFlatList, {
ScaleDecorator,
RenderItemParams,
} from "react-native-draggable-flatlist";
import { mapIndexToData, Item } from "../utils";
const NUM_ITEMS = 100;
const initialData: Item[] = [...Array(NUM_ITEMS)].map(mapIndexToData);
export default function Horizontal() {
const [data, setData] = useState(initialData);
const renderItem = useCallback(
({ item, drag, isActive }: RenderItemParams<Item>) => {
return (
<ScaleDecorator>
<TouchableOpacity
activeOpacity={1}
onLongPress={drag}
disabled={isActive}
style={[
styles.rowItem,
{ opacity: isActive ? 0.5 : 1 },
{ backgroundColor: isActive ? "blue" : item.backgroundColor },
]}
>
<Text style={styles.text}>{item.text}</Text>
</TouchableOpacity>
</ScaleDecorator>
);
},
[]
);
return (
<DraggableFlatList
horizontal
data={data}
onDragEnd={({ data }) => setData(data)}
keyExtractor={(item) => {
return item.key;
}}
renderItem={renderItem}
renderPlaceholder={() => (
<View style={{ flex: 1, backgroundColor: "tomato" }} />
)}
/>
);
}
const styles = StyleSheet.create({
rowItem: {
height: 100,
width: 100,
alignItems: "center",
justifyContent: "center",
},
text: {
color: "white",
fontSize: 24,
fontWeight: "bold",
textAlign: "center",
},
});

View File

@ -0,0 +1,119 @@
import React, { useState, useCallback } from "react";
import { Text, View, StyleSheet, TouchableOpacity } from "react-native";
import {
RenderItemParams,
ScaleDecorator,
ShadowDecorator,
NestableScrollContainer,
NestableDraggableFlatList,
} from "react-native-draggable-flatlist";
import { mapIndexToData, Item } from "../utils";
const NUM_ITEMS = 6;
const initialData1 = [...Array(NUM_ITEMS)].fill(0).map(mapIndexToData);
const initialData2 = [...Array(NUM_ITEMS)].fill(0).map(mapIndexToData);
const initialData3 = [...Array(NUM_ITEMS)].fill(0).map(mapIndexToData);
function NestedDraggableListScreen() {
const [data1, setData1] = useState(initialData1);
const [data2, setData2] = useState(initialData2);
const [data3, setData3] = useState(initialData3);
const renderItem = useCallback((params: RenderItemParams<Item>) => {
return (
<ShadowDecorator>
<ScaleDecorator activeScale={1.25}>
<RowItem {...params} />
</ScaleDecorator>
</ShadowDecorator>
);
}, []);
const keyExtractor = (item) => item.key;
return (
<View style={styles.container}>
<NestableScrollContainer>
<Header text="List 1" />
<NestableDraggableFlatList
data={data1}
renderItem={renderItem}
keyExtractor={keyExtractor}
onDragEnd={({ data }) => setData1(data)}
/>
<Header text="List 2" />
<NestableDraggableFlatList
data={data2}
renderItem={renderItem}
keyExtractor={keyExtractor}
onDragEnd={({ data }) => setData2(data)}
/>
<Header text="List 3" />
<NestableDraggableFlatList
data={data3}
renderItem={renderItem}
keyExtractor={keyExtractor}
onDragEnd={({ data }) => setData3(data)}
/>
</NestableScrollContainer>
</View>
);
}
function Header({ text }: { text: string }) {
return (
<View style={{ padding: 10, backgroundColor: "seashell" }}>
<Text style={{ fontSize: 24, fontWeight: "bold", color: "gray" }}>
{text}
</Text>
</View>
);
}
type RowItemProps = {
item: Item;
drag: () => void;
};
function RowItem({ item, drag }: RowItemProps) {
return (
<TouchableOpacity
activeOpacity={1}
onLongPress={drag}
style={[
styles.row,
{
backgroundColor: item.backgroundColor,
width: item.width,
height: item.height,
},
]}
>
<Text style={styles.text}>{item.text}</Text>
</TouchableOpacity>
);
}
export default NestedDraggableListScreen;
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "seashell",
paddingTop: 44,
},
row: {
flexDirection: "row",
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: 15,
},
text: {
fontWeight: "bold",
color: "white",
fontSize: 32,
},
});

View File

@ -0,0 +1,41 @@
import { StyleSheet, TouchableOpacity } from "react-native";
import { Text, View } from "../components/Themed";
import { RootStackScreenProps } from "../types";
export default function NotFoundScreen({
navigation,
}: RootStackScreenProps<"NotFound">) {
return (
<View style={styles.container}>
<Text style={styles.title}>This screen doesn't exist.</Text>
<TouchableOpacity
onPress={() => navigation.replace("Root")}
style={styles.link}
>
<Text style={styles.linkText}>Go to home screen!</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: 20,
},
title: {
fontSize: 20,
fontWeight: "bold",
},
link: {
marginTop: 15,
paddingVertical: 15,
},
linkText: {
fontSize: 14,
color: "#2e78b7",
},
});

View File

@ -0,0 +1,169 @@
import React, { useState, useRef, useCallback } from "react";
import {
Text,
View,
StyleSheet,
LayoutAnimation,
TouchableOpacity,
} from "react-native";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import DraggableFlatList, {
RenderItemParams,
ScaleDecorator,
} from "react-native-draggable-flatlist";
import SwipeableItem, {
useSwipeableItemParams,
} from "react-native-swipeable-item";
import { mapIndexToData, Item } from "../utils";
const OVERSWIPE_DIST = 20;
const NUM_ITEMS = 20;
const initialData: Item[] = [...Array(NUM_ITEMS)].fill(0).map(mapIndexToData);
function App() {
const [data, setData] = useState(initialData);
const itemRefs = useRef(new Map());
const renderItem = useCallback((params: RenderItemParams<Item>) => {
const onPressDelete = () => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.spring);
setData((prev) => {
return prev.filter((item) => item !== params.item);
});
};
return (
<RowItem {...params} itemRefs={itemRefs} onPressDelete={onPressDelete} />
);
}, []);
return (
<View style={styles.container}>
<DraggableFlatList
keyExtractor={(item) => item.key}
data={data}
renderItem={renderItem}
onDragEnd={({ data }) => setData(data)}
activationDistance={20}
/>
</View>
);
}
export default App;
type RowItemProps = {
item: Item;
drag: () => void;
onPressDelete: () => void;
itemRefs: React.MutableRefObject<Map<any, any>>;
};
function RowItem({ item, itemRefs, drag, onPressDelete }: RowItemProps) {
const [snapPointsLeft, setSnapPointsLeft] = useState([150]);
return (
<ScaleDecorator>
<SwipeableItem
key={item.key}
item={item}
ref={(ref) => {
if (ref && !itemRefs.current.get(item.key)) {
itemRefs.current.set(item.key, ref);
}
}}
onChange={({ open }) => {
if (open) {
// Close all other open items
[...itemRefs.current.entries()].forEach(([key, ref]) => {
if (key !== item.key && ref) ref.close();
});
}
}}
overSwipe={OVERSWIPE_DIST}
renderUnderlayLeft={() => (
<UnderlayLeft drag={drag} onPressDelete={onPressDelete} />
)}
renderUnderlayRight={() => <UnderlayRight />}
snapPointsLeft={snapPointsLeft}
>
<TouchableOpacity
activeOpacity={1}
onLongPress={drag}
style={[
styles.row,
{ backgroundColor: item.backgroundColor, height: item.height },
]}
>
<Text style={styles.text}>{`${item.text}`}</Text>
</TouchableOpacity>
</SwipeableItem>
</ScaleDecorator>
);
}
const UnderlayLeft = ({
drag,
onPressDelete,
}: {
drag: () => void;
onPressDelete: () => void;
}) => {
const { item, percentOpen } = useSwipeableItemParams<Item>();
const animStyle = useAnimatedStyle(
() => ({
opacity: percentOpen.value,
}),
[percentOpen]
);
return (
<Animated.View
style={[styles.row, styles.underlayLeft, animStyle]} // Fade in on open
>
<TouchableOpacity onPress={onPressDelete}>
<Text style={styles.text}>{`[delete]`}</Text>
</TouchableOpacity>
</Animated.View>
);
};
function UnderlayRight() {
const { close } = useSwipeableItemParams<Item>();
return (
<Animated.View style={[styles.row, styles.underlayRight]}>
<TouchableOpacity onPressOut={close}>
<Text style={styles.text}>CLOSE</Text>
</TouchableOpacity>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
row: {
flexDirection: "row",
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: 15,
},
text: {
fontWeight: "bold",
color: "white",
fontSize: 32,
},
underlayRight: {
flex: 1,
backgroundColor: "teal",
justifyContent: "flex-start",
},
underlayLeft: {
flex: 1,
backgroundColor: "tomato",
justifyContent: "flex-end",
},
});

6
Example/tsconfig.json Normal file
View File

@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}

40
Example/types.tsx Normal file
View File

@ -0,0 +1,40 @@
/**
* Learn more about using TypeScript with React Navigation:
* https://reactnavigation.org/docs/typescript/
*/
import { BottomTabScreenProps } from "@react-navigation/bottom-tabs";
import {
CompositeScreenProps,
NavigatorScreenParams,
} from "@react-navigation/native";
import { NativeStackScreenProps } from "@react-navigation/native-stack";
declare global {
namespace ReactNavigation {
interface RootParamList extends RootStackParamList {}
}
}
export type RootStackParamList = {
Root: NavigatorScreenParams<RootTabParamList> | undefined;
NotFound: undefined;
};
export type RootStackScreenProps<
Screen extends keyof RootStackParamList
> = NativeStackScreenProps<RootStackParamList, Screen>;
export type RootTabParamList = {
Basic: undefined;
Nested: undefined;
Swipeable: undefined;
Horizontal: undefined;
};
export type RootTabScreenProps<
Screen extends keyof RootTabParamList
> = CompositeScreenProps<
BottomTabScreenProps<RootTabParamList, Screen>,
NativeStackScreenProps<RootStackParamList>
>;

17
Example/utils/index.ts Normal file
View File

@ -0,0 +1,17 @@
export function getColor(i: number, numItems: number = 25) {
const multiplier = 255 / (numItems - 1);
const colorVal = i * multiplier;
return `rgb(${colorVal}, ${Math.abs(128 - colorVal)}, ${255 - colorVal})`;
}
export const mapIndexToData = (_d: any, index: number, arr: any[]) => {
const backgroundColor = getColor(index, arr.length);
return {
text: `${index}`,
key: `key-${index}`,
backgroundColor,
height: 75,
};
};
export type Item = ReturnType<typeof mapIndexToData>;

8640
Example/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -38,7 +38,7 @@ All props are spread onto underlying [FlatList](https://facebook.github.io/react
| :------------------------- | :---------------------------------------------------------------------------------------- | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| `data` | `T[]` | Items to be rendered. |
| `ref` | `React.RefObject<FlatList<T>>` | FlatList ref to be forwarded to the underlying FlatList. |
| `renderItem` | `(params: { item: T, index: number, drag: () => void, isActive: boolean}) => JSX.Element` | Call `drag` when the row should become active (i.e. in an `onLongPress` or `onPressIn`). |
| `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`). |
| `renderPlaceholder` | `(params: { item: T, index: number }) => React.ReactNode` | Component to be rendered underneath the hovering component |
| `keyExtractor` | `(item: T, index: number) => string` | Unique key for each item |
| `onDragBegin` | `(index: number) => void` | Called when row becomes active. |

4
babel.config.js Normal file
View File

@ -0,0 +1,4 @@
module.exports = {
presets: ["module:metro-react-native-babel-preset"],
plugins: ["react-native-reanimated/plugin"],
};

View File

@ -1,3 +0,0 @@
{
"presets": ["module:metro-react-native-babel-preset"]
}

2
jest-setup.js Normal file
View File

@ -0,0 +1,2 @@
// see: https://github.com/software-mansion/react-native-reanimated/issues/1380
global.__reanimatedWorkletInit = jest.fn();

View File

@ -1,13 +1,15 @@
{
"name": "react-native-draggable-flatlist",
"version": "3.1.2",
"version": "4.0.0-beta.5",
"description": "A drag-and-drop-enabled FlatList component for React Native",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"main": "lib/commonjs/index",
"module": "lib/module/index",
"react-native": "src/index.tsx",
"types": "lib/typescript/index.d.ts",
"scripts": {
"test": "jest",
"build": "tsc",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --skipLibCheck --noEmit",
"build": "bob build"
},
"husky": {
"hooks": {
@ -36,7 +38,8 @@
"jest": {
"preset": "react-native",
"setupFiles": [
"./node_modules/react-native-gesture-handler/jestSetup.js"
"./node_modules/react-native-gesture-handler/jestSetup.js",
"./jest-setup.js"
],
"transform": {
"^.+\\.[t|j]sx?$": "babel-jest"
@ -46,23 +49,42 @@
"homepage": "https://github.com/computerjazz/react-native-draggable-flatlist#readme",
"peerDependencies": {
"react-native": ">=0.64.0",
"react-native-gesture-handler": ">=1.10.0",
"react-native-reanimated": ">=2.2.0"
"react-native-gesture-handler": ">=2.0.0",
"react-native-reanimated": ">=2.8.0"
},
"devDependencies": {
"@testing-library/react-native": "^7.2.0",
"@types/react": "^17.0.3",
"@types/react-native": "^0.64.2",
"@types/react": "^17.0.5",
"@types/react-native": "^0.64.5",
"babel-jest": "^26.6.3",
"husky": "^4.2.0",
"jest": "^26.6.3",
"metro-react-native-babel-preset": "^0.71.0",
"prettier": "^2.2.1",
"pretty-quick": "^2.0.1",
"react": "^17.0.2",
"react-native": "^0.64.0",
"react-native-builder-bob": "^0.18.2",
"react-native-gesture-handler": "^2.1.0",
"react-native-reanimated": "^2.3.1",
"react-native-reanimated": "^2.8.0",
"react-test-renderer": "^17.0.2",
"typescript": "^4.2.4"
},
"react-native-builder-bob": {
"source": "src",
"output": "lib",
"targets": [
[
"commonjs",
{
"configFile": "./babel.config.js"
}
],
"module",
"typescript"
]
},
"dependencies": {
"@babel/preset-typescript": "^7.17.12"
}
}

View File

@ -2,11 +2,9 @@ import React from "react";
import { StyleSheet } from "react-native";
import Animated, {
interpolate,
interpolateNode,
multiply,
useAnimatedStyle,
} from "react-native-reanimated";
import { useDraggableFlatListContext } from "../context/draggableFlatListContext";
import { useNode } from "../hooks/useNode";
export { useOnCellActiveAnimation } from "../hooks/useOnCellActiveAnimation";
import { useOnCellActiveAnimation } from "../hooks/useOnCellActiveAnimation";
@ -15,32 +13,22 @@ type ScaleProps = {
children: React.ReactNode;
};
// support older versions of Reanimated v1 by using the old interpolate function
// if interpolateNode not available.
const interpolateFn = ((interpolateNode ||
interpolate) as unknown) as typeof interpolateNode;
export const ScaleDecorator = ({ activeScale = 1.1, children }: ScaleProps) => {
const { isActive, onActiveAnim } = useOnCellActiveAnimation({
animationConfig: { mass: 0.1, restDisplacementThreshold: 0.0001 },
});
const animScale = useNode(
interpolateFn(onActiveAnim, {
inputRange: [0, 1],
outputRange: [1, activeScale],
})
);
const { horizontal } = useDraggableFlatListContext<any>();
const scale = isActive ? animScale : 1;
const style = useAnimatedStyle(() => {
const animScale = interpolate(onActiveAnim.value, [0, 1], [1, activeScale]);
const scale = isActive ? animScale : 1;
return {
transform: [{ scaleX: scale }, { scaleY: scale }],
};
}, [isActive]);
return (
<Animated.View
style={[
{ transform: [{ scaleX: scale }, { scaleY: scale }] },
horizontal && styles.horizontal,
]}
>
<Animated.View style={[style, horizontal && styles.horizontal]}>
{children}
</Animated.View>
);
@ -64,14 +52,15 @@ export const ShadowDecorator = ({
const { isActive, onActiveAnim } = useOnCellActiveAnimation();
const { horizontal } = useDraggableFlatListContext<any>();
const shadowOpacity = useNode(multiply(onActiveAnim, opacity));
const style = {
elevation: isActive ? elevation : 0,
shadowRadius: isActive ? radius : 0,
shadowColor: isActive ? color : "transparent",
shadowOpacity: isActive ? shadowOpacity : 0,
};
const style = useAnimatedStyle(() => {
const shadowOpacity = onActiveAnim.value * opacity;
return {
elevation: isActive ? elevation : 0,
shadowRadius: isActive ? radius : 0,
shadowColor: isActive ? color : "transparent",
shadowOpacity: isActive ? shadowOpacity : 0,
};
}, [isActive, onActiveAnim]);
return (
<Animated.View style={[style, horizontal && styles.horizontal]}>
@ -91,17 +80,12 @@ export const OpacityDecorator = ({
}: OpacityProps) => {
const { isActive, onActiveAnim } = useOnCellActiveAnimation();
const { horizontal } = useDraggableFlatListContext<any>();
const opacity = useNode(
interpolateFn(onActiveAnim, {
inputRange: [0, 1],
outputRange: [1, activeOpacity],
})
);
const style = {
opacity: isActive ? opacity : 1,
};
const style = useAnimatedStyle(() => {
const opacity = interpolate(onActiveAnim.value, [0, 1], [1, activeOpacity]);
return {
opacity: isActive ? opacity : 1,
};
}, [isActive]);
return (
<Animated.View style={[style, horizontal && styles.horizontal]}>

View File

@ -1,25 +1,22 @@
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react";
import React, { useEffect, useMemo, useRef } from "react";
import {
findNodeHandle,
LayoutChangeEvent,
MeasureLayoutOnSuccessCallback,
StyleProp,
ViewStyle,
} from "react-native";
import Animated, { cond, useValue } from "react-native-reanimated";
import Animated, {
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";
import { useDraggableFlatListContext } from "../context/draggableFlatListContext";
import { isAndroid, isIOS, isReanimatedV2, isWeb } from "../constants";
import { isWeb } from "../constants";
import { useCellTranslate } from "../hooks/useCellTranslate";
import { typedMemo } from "../utils";
import { useRefs } from "../context/refContext";
import { useAnimatedValues } from "../context/animatedValueContext";
import CellProvider from "../context/cellContext";
import { useStableCallback } from "../hooks/useStableCallback";
type Props<T> = {
item: T;
@ -30,18 +27,23 @@ type Props<T> = {
};
function CellRendererComponent<T>(props: Props<T>) {
const { item, index, onLayout, children } = props;
const { item, index, onLayout, children, ...rest } = props;
const currentIndexAnim = useValue(index);
const currentIndexAnim = useSharedValue(index);
useLayoutEffect(() => {
currentIndexAnim.setValue(index);
}, [index, currentIndexAnim]);
useEffect(() => {
// If we set the index immediately the newly-ordered data can get out of sync
// with the activeIndexAnim, and cause the wrong item to momentarily become the
// "active item", which causes a flicker.
requestAnimationFrame(() => {
currentIndexAnim.value = index;
});
}, [index]);
const viewRef = useRef<Animated.View>(null);
const { cellDataRef, propsRef, scrollOffsetRef, containerRef } = useRefs<T>();
const { cellDataRef, propsRef, containerRef } = useRefs<T>();
const { horizontalAnim } = useAnimatedValues();
const { horizontalAnim, scrollOffset } = useAnimatedValues();
const {
activeKey,
keyExtractor,
@ -49,41 +51,44 @@ function CellRendererComponent<T>(props: Props<T>) {
} = useDraggableFlatListContext<T>();
const key = keyExtractor(item, index);
const offset = useValue<number>(-1);
const size = useValue<number>(-1);
const offset = useSharedValue(-1);
const size = useSharedValue(-1);
const translate = useCellTranslate({
cellOffset: offset,
cellSize: size,
cellIndex: currentIndexAnim,
});
useMemo(() => {
// prevent flicker on web
if (isWeb) translate.setValue(0);
}, [index]); //eslint-disable-line react-hooks/exhaustive-deps
const indexRef = useRef(index);
const indexHasChanged = index !== indexRef.current;
indexRef.current = index;
const isActive = activeKey === key;
const dragInProgress = !!activeKey && !indexHasChanged;
const isActive = dragInProgress && activeKey === key;
const style = useMemo(
() => ({
const animStyle = useAnimatedStyle(() => {
const _translate = dragInProgress ? translate.value : 0;
return {
transform: [
{ translateX: cond(horizontalAnim, translate, 0) },
{ translateY: cond(horizontalAnim, 0, translate) },
horizontalAnim.value
? { translateX: _translate }
: { translateY: _translate },
],
}),
[horizontalAnim, translate]
);
};
}, [dragInProgress, translate]);
const updateCellMeasurements = useCallback(() => {
const updateCellMeasurements = useStableCallback(() => {
const onSuccess: MeasureLayoutOnSuccessCallback = (x, y, w, h) => {
if (isWeb && horizontal) x += scrollOffsetRef.current;
if (isWeb && horizontal) x += scrollOffset.value;
const cellOffset = horizontal ? x : y;
const cellSize = horizontal ? w : h;
cellDataRef.current.set(key, {
measurements: { size: cellSize, offset: cellOffset },
});
size.setValue(cellSize);
offset.setValue(cellOffset);
size.value = cellSize;
offset.value = cellOffset;
};
const onFail = () => {
@ -92,70 +97,54 @@ function CellRendererComponent<T>(props: Props<T>) {
}
};
// findNodeHandle is being deprecated. This is no longer necessary if using reanimated v2
// remove once v1 is no longer supported
const containerNode = containerRef.current;
const viewNode = isReanimatedV2
? viewRef.current
: viewRef.current?.getNode();
//@ts-ignore
const nodeHandle = isReanimatedV2
? containerNode
: findNodeHandle(containerNode);
const viewNode = viewRef.current;
const nodeHandle = containerNode;
if (viewNode && nodeHandle) {
//@ts-ignore
viewNode.measureLayout(nodeHandle, onSuccess, onFail);
}
}, [
cellDataRef,
horizontal,
index,
key,
offset,
propsRef,
size,
scrollOffsetRef,
containerRef,
]);
});
useEffect(() => {
if (isWeb) {
// onLayout isn't called on web when the cell index changes, so we manually re-measure
updateCellMeasurements();
requestAnimationFrame(() => {
updateCellMeasurements();
});
}
}, [index, updateCellMeasurements]);
const onCellLayout = useCallback(
(e: LayoutChangeEvent) => {
updateCellMeasurements();
if (onLayout) onLayout(e);
},
[updateCellMeasurements, onLayout]
);
const onCellLayout = useStableCallback((e: LayoutChangeEvent) => {
updateCellMeasurements();
if (onLayout) onLayout(e);
});
// changing zIndex crashes android:
const baseStyle = useMemo(() => {
return {
elevation: isActive ? 1 : 0,
zIndex: isActive ? 999 : 0,
flexDirection: horizontal ? ("row" as const) : ("column" as const),
};
}, [isActive, horizontal]);
// changing zIndex may crash android, but seems to work ok as of RN 68:
// https://github.com/facebook/react-native/issues/28751
return (
<Animated.View
{...props}
{...rest}
ref={viewRef}
onLayout={onCellLayout}
style={[
isAndroid && { elevation: isActive ? 1 : 0 },
{ flexDirection: horizontal ? "row" : "column" },
(isWeb || isIOS) && { zIndex: isActive ? 999 : 0 },
props.style,
baseStyle,
dragInProgress ? animStyle : { transform: [] },
]}
pointerEvents={activeKey ? "none" : "auto"}
>
<Animated.View
{...props}
// Including both animated styles and non-animated styles causes react-native-web
// to ignore updates in non-animated styles. Solution is to separate animated styles from non-animated styles
style={[props.style, style]}
>
<CellProvider isActive={isActive}>{children}</CellProvider>
</Animated.View>
<CellProvider isActive={isActive}>{children}</CellProvider>
</Animated.View>
);
}

View File

@ -1,53 +1,32 @@
import React, {
ForwardedRef,
useCallback,
useLayoutEffect,
useMemo,
useState,
} from "react";
import React, { useCallback, useLayoutEffect, useMemo, useState } from "react";
import { ListRenderItem, FlatListProps, LayoutChangeEvent } from "react-native";
import {
ListRenderItem,
FlatListProps,
NativeScrollEvent,
NativeSyntheticEvent,
LayoutChangeEvent,
} from "react-native";
import {
PanGestureHandler,
State as GestureState,
FlatList,
PanGestureHandlerGestureEvent,
PanGestureHandlerStateChangeEvent,
Gesture,
GestureDetector,
} from "react-native-gesture-handler";
import Animated, {
and,
block,
call,
cond,
eq,
event,
greaterThan,
neq,
not,
onChange,
or,
set,
sub,
runOnJS,
useAnimatedReaction,
useAnimatedScrollHandler,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import CellRendererComponent from "./CellRendererComponent";
import { DEFAULT_PROPS, isWeb } from "../constants";
import PlaceholderItem from "./PlaceholderItem";
import RowItem from "./RowItem";
import ScrollOffsetListener from "./ScrollOffsetListener";
import { DraggableFlatListProps } from "../types";
import { useAutoScroll } from "../hooks/useAutoScroll";
import { useNode } from "../hooks/useNode";
import PropsProvider from "../context/propsContext";
import AnimatedValueProvider, {
useAnimatedValues,
} from "../context/animatedValueContext";
import RefProvider, { useRefs } from "../context/refContext";
import DraggableFlatListProvider from "../context/draggableFlatListContext";
import { useAutoScroll } from "../hooks/useAutoScroll";
import { useStableCallback } from "../hooks/useStableCallback";
import ScrollOffsetListener from "./ScrollOffsetListener";
import { typedMemo } from "../utils";
type RNGHFlatListProps<T> = Animated.AnimateProps<
FlatListProps<T> & {
@ -64,27 +43,26 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
const {
cellDataRef,
containerRef,
flatListRef,
isTouchActiveRef,
flatlistRef,
keyToIndexRef,
panGestureHandlerRef,
propsRef,
scrollOffsetRef,
animationConfigRef,
} = useRefs<T>();
const {
activationDistance,
activeCellOffset,
activeCellSize,
activeIndexAnim,
containerSize,
disabled,
panGestureState,
resetTouchedCell,
scrollOffset,
scrollViewSize,
spacerIndexAnim,
touchAbsolute,
touchInit,
horizontalAnim,
placeholderOffset,
touchTranslate,
autoScrollDistance,
panGestureState,
isTouchActiveNative,
disabled,
} = useAnimatedValues();
const {
@ -95,15 +73,12 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
const [activeKey, setActiveKey] = useState<string | null>(null);
const keyExtractor = useCallback(
(item: T, index: number) => {
if (propsRef.current.keyExtractor)
return propsRef.current.keyExtractor(item, index);
else
throw new Error("You must provide a keyExtractor to DraggableFlatList");
},
[propsRef]
);
const keyExtractor = useStableCallback((item: T, index: number) => {
if (!props.keyExtractor) {
throw new Error("You must provide a keyExtractor to DraggableFlatList");
}
return props.keyExtractor(item, index);
});
useLayoutEffect(() => {
props.data.forEach((d, i) => {
@ -112,71 +87,59 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
});
}, [props.data, keyExtractor, keyToIndexRef]);
const drag = useCallback(
(activeKey: string) => {
if (!isTouchActiveRef.current.js) return;
const index = keyToIndexRef.current.get(activeKey);
const cellData = cellDataRef.current.get(activeKey);
if (cellData) {
activeCellOffset.setValue(
cellData.measurements.offset - scrollOffsetRef.current
);
activeCellSize.setValue(cellData.measurements.size);
}
// Reset hover state whenever data changes
useMemo(() => {
requestAnimationFrame(() => {
activeIndexAnim.value = -1;
spacerIndexAnim.value = -1;
touchTranslate.value = 0;
activeCellSize.value = -1;
activeCellOffset.value = -1;
});
}, [props.data]);
const { onDragBegin } = propsRef.current;
if (index !== undefined) {
spacerIndexAnim.setValue(index);
activeIndexAnim.setValue(index);
setActiveKey(activeKey);
onDragBegin?.(index);
}
},
[
isTouchActiveRef,
keyToIndexRef,
cellDataRef,
propsRef,
activeCellOffset,
scrollOffsetRef,
activeCellSize,
spacerIndexAnim,
activeIndexAnim,
]
);
const drag = useStableCallback((activeKey: string) => {
if (disabled.value) return;
const index = keyToIndexRef.current.get(activeKey);
const cellData = cellDataRef.current.get(activeKey);
if (cellData) {
activeCellOffset.value = cellData.measurements.offset;
activeCellSize.value = cellData.measurements.size;
}
const autoScrollNode = useAutoScroll();
const { onDragBegin } = propsRef.current;
if (index !== undefined) {
spacerIndexAnim.value = index;
activeIndexAnim.value = index;
setActiveKey(activeKey);
onDragBegin?.(index);
}
});
const onContainerLayout = ({
nativeEvent: { layout },
}: LayoutChangeEvent) => {
containerSize.setValue(props.horizontal ? layout.width : layout.height);
const { width, height } = layout;
containerSize.value = props.horizontal ? width : height;
props.onContainerLayout?.({ layout, containerRef });
};
const onListContentSizeChange = (w: number, h: number) => {
scrollViewSize.setValue(props.horizontal ? w : h);
scrollViewSize.value = props.horizontal ? w : h;
props.onContentSizeChange?.(w, h);
};
const onContainerTouchStart = () => {
isTouchActiveRef.current.js = true;
isTouchActiveRef.current.native.setValue(1);
if (!disabled.value) {
isTouchActiveNative.value = true;
}
return false;
};
const onContainerTouchEnd = () => {
isTouchActiveRef.current.js = false;
isTouchActiveRef.current.native.setValue(0);
isTouchActiveNative.value = false;
};
let dynamicProps = {};
if (activationDistanceProp) {
const activeOffset = [-activationDistanceProp, activationDistanceProp];
dynamicProps = props.horizontal
? { activeOffsetX: activeOffset }
: { activeOffsetY: activeOffset };
}
const extraData = useMemo(
() => ({
activeKey,
@ -188,8 +151,9 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
const renderItem: ListRenderItem<T> = useCallback(
({ item, index }) => {
const key = keyExtractor(item, index);
if (index !== keyToIndexRef.current.get(key))
if (index !== keyToIndexRef.current.get(key)) {
keyToIndexRef.current.set(key, index);
}
return (
<RowItem
@ -204,26 +168,13 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
[props.renderItem, props.extraData, drag, keyExtractor]
);
const resetHoverState = useCallback(() => {
activeIndexAnim.setValue(-1);
spacerIndexAnim.setValue(-1);
touchAbsolute.setValue(0);
disabled.setValue(0);
requestAnimationFrame(() => {
setActiveKey(null);
});
}, [activeIndexAnim, spacerIndexAnim, touchAbsolute, disabled]);
const onRelease = ([index]: readonly number[]) => {
// This shouldn't be necessary but seems to fix a bug where sometimes
// native values wouldn't update
isTouchActiveRef.current.native.setValue(0);
const onRelease = useStableCallback((index: number) => {
props.onRelease?.(index);
};
});
const onDragEnd = useCallback(
([from, to]: readonly number[]) => {
const { onDragEnd, data } = propsRef.current;
const onDragEnd = useStableCallback(
({ from, to }: { from: number; to: number }) => {
const { onDragEnd, data } = props;
if (onDragEnd) {
const newData = [...data];
if (from !== to) {
@ -231,173 +182,138 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
newData.splice(to, 0, data[from]);
}
onDragEnd({ from, to, data: newData });
if (isWeb) {
// prevent flicker
setActiveKey(null);
} else {
requestAnimationFrame(() => {
setActiveKey(null);
});
}
}
resetHoverState();
}
);
// Handle case where user ends drag without moving their finger.
useAnimatedReaction(
() => {
return isTouchActiveNative.value;
},
[resetHoverState, propsRef]
(cur, prev) => {
if (cur !== prev && !cur) {
const hasMoved = !!touchTranslate.value;
if (!hasMoved && activeIndexAnim.value >= 0 && !disabled.value) {
runOnJS(onDragEnd)({
from: activeIndexAnim.value,
to: spacerIndexAnim.value,
});
}
}
},
[isTouchActiveNative, onDragEnd]
);
const onGestureRelease = useNode(
cond(
greaterThan(activeIndexAnim, -1),
[
set(disabled, 1),
set(isTouchActiveRef.current.native, 0),
call([activeIndexAnim], onRelease),
],
[call([activeIndexAnim], resetHoverState), resetTouchedCell]
)
);
const gestureDisabled = useSharedValue(false);
const onPanStateChange = useMemo(
() =>
event([
{
nativeEvent: ({
state,
x,
y,
}: PanGestureHandlerStateChangeEvent["nativeEvent"]) =>
block([
cond(and(neq(state, panGestureState), not(disabled)), [
cond(
or(
eq(state, GestureState.BEGAN), // Called on press in on Android, NOT on ios!
// GestureState.BEGAN may be skipped on fast swipes
and(
eq(state, GestureState.ACTIVE),
neq(panGestureState, GestureState.BEGAN)
)
),
[
set(touchAbsolute, props.horizontal ? x : y),
set(touchInit, touchAbsolute),
]
),
cond(eq(state, GestureState.ACTIVE), [
set(
activationDistance,
sub(props.horizontal ? x : y, touchInit)
),
set(touchAbsolute, props.horizontal ? x : y),
]),
]),
cond(neq(panGestureState, state), [
set(panGestureState, state),
cond(
or(
eq(state, GestureState.END),
eq(state, GestureState.CANCELLED),
eq(state, GestureState.FAILED)
),
onGestureRelease
),
]),
]),
},
]),
[
activationDistance,
props.horizontal,
panGestureState,
disabled,
onGestureRelease,
touchAbsolute,
touchInit,
]
);
const panGesture = Gesture.Pan()
.onBegin((evt) => {
gestureDisabled.value = disabled.value;
if (gestureDisabled.value) return;
panGestureState.value = evt.state;
})
.onUpdate((evt) => {
if (gestureDisabled.value) return;
panGestureState.value = evt.state;
const translation = horizontalAnim.value
? evt.translationX
: evt.translationY;
touchTranslate.value = translation;
})
.onEnd((evt) => {
if (gestureDisabled.value) return;
// Set touch val to current translate val
isTouchActiveNative.value = false;
const translation = horizontalAnim.value
? evt.translationX
: evt.translationY;
const onPanGestureEvent = useMemo(
() =>
event([
{
nativeEvent: ({
x,
y,
}: PanGestureHandlerGestureEvent["nativeEvent"]) =>
cond(
and(
greaterThan(activeIndexAnim, -1),
eq(panGestureState, GestureState.ACTIVE),
not(disabled)
),
[set(touchAbsolute, props.horizontal ? x : y)]
),
},
]),
[
activeIndexAnim,
disabled,
panGestureState,
props.horizontal,
touchAbsolute,
]
);
touchTranslate.value = translation + autoScrollDistance.value;
panGestureState.value = evt.state;
const scrollHandler = useMemo(() => {
// Web doesn't seem to like animated events
const webOnScroll = ({
nativeEvent: {
contentOffset: { x, y },
// Only call onDragEnd if actually dragging a cell
if (activeIndexAnim.value === -1 || disabled.value) return;
disabled.value = true;
runOnJS(onRelease)(activeIndexAnim.value);
const springTo = placeholderOffset.value - activeCellOffset.value;
touchTranslate.value = withSpring(
springTo,
animationConfigRef.current,
() => {
runOnJS(onDragEnd)({
from: activeIndexAnim.value,
to: spacerIndexAnim.value,
});
disabled.value = false;
}
);
})
.onTouchesDown(() => {
runOnJS(onContainerTouchStart)();
})
.onTouchesUp(() => {
// Turning this into a worklet causes timing issues. We want it to run
// just after the finger lifts.
runOnJS(onContainerTouchEnd)();
});
if (props.hitSlop) panGesture.hitSlop(props.hitSlop);
if (activationDistanceProp) {
const activeOffset = [-activationDistanceProp, activationDistanceProp];
if (props.horizontal) {
panGesture.activeOffsetX(activeOffset);
} else {
panGesture.activeOffsetY(activeOffset);
}
}
const onScroll = useStableCallback((scrollOffset: number) => {
props.onScrollOffsetChange?.(scrollOffset);
});
const scrollHandler = useAnimatedScrollHandler(
{
onScroll: (evt) => {
scrollOffset.value = horizontalAnim.value
? evt.contentOffset.x
: evt.contentOffset.y;
runOnJS(onScroll)(scrollOffset.value);
},
}: NativeSyntheticEvent<NativeScrollEvent>) => {
scrollOffset.setValue(props.horizontal ? x : y);
};
},
[horizontalAnim]
);
const mobileOnScroll = event([
{
nativeEvent: ({ contentOffset }: NativeScrollEvent) =>
block([
set(
scrollOffset,
props.horizontal ? contentOffset.x : contentOffset.y
),
autoScrollNode,
]),
},
]);
return isWeb ? webOnScroll : mobileOnScroll;
}, [autoScrollNode, props.horizontal, scrollOffset]);
useAutoScroll();
return (
<DraggableFlatListProvider
activeKey={activeKey}
onDragEnd={onDragEnd}
keyExtractor={keyExtractor}
horizontal={!!props.horizontal}
>
<PanGestureHandler
ref={panGestureHandlerRef}
hitSlop={dragHitSlop}
onHandlerStateChange={onPanStateChange}
onGestureEvent={onPanGestureEvent}
simultaneousHandlers={props.simultaneousHandlers}
{...dynamicProps}
>
<GestureDetector gesture={panGesture}>
<Animated.View
style={props.containerStyle}
ref={containerRef}
onLayout={onContainerLayout}
onTouchEnd={onContainerTouchEnd}
onStartShouldSetResponderCapture={onContainerTouchStart}
//@ts-ignore
onClick={onContainerTouchEnd}
>
<ScrollOffsetListener
scrollOffset={scrollOffset}
onScrollOffsetChange={([offset]) => {
scrollOffsetRef.current = offset;
props.onScrollOffsetChange?.(offset);
}}
/>
{!!props.renderPlaceholder && (
{props.renderPlaceholder && (
<PlaceholderItem renderPlaceholder={props.renderPlaceholder} />
)}
<AnimatedFlatList
{...props}
data={props.data}
CellRendererComponent={CellRendererComponent}
ref={flatListRef}
ref={flatlistRef}
onContentSizeChange={onListContentSizeChange}
scrollEnabled={!activeKey && scrollEnabled}
renderItem={renderItem}
@ -408,37 +324,35 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
simultaneousHandlers={props.simultaneousHandlers}
removeClippedSubviews={false}
/>
<Animated.Code dependencies={[]}>
{() =>
block([
onChange(
isTouchActiveRef.current.native,
cond(not(isTouchActiveRef.current.native), onGestureRelease)
),
])
}
</Animated.Code>
{!!props.onScrollOffsetChange && (
<ScrollOffsetListener
onScrollOffsetChange={props.onScrollOffsetChange}
scrollOffset={scrollOffset}
/>
)}
</Animated.View>
</PanGestureHandler>
</GestureDetector>
</DraggableFlatListProvider>
);
}
function DraggableFlatList<T>(
props: DraggableFlatListProps<T>,
ref: React.ForwardedRef<FlatList<T>>
ref?: React.ForwardedRef<FlatList<T>> | null
) {
return (
<PropsProvider {...props}>
<AnimatedValueProvider>
<RefProvider flatListRef={ref}>
<DraggableFlatListInner {...props} />
<MemoizedInner {...props} />
</RefProvider>
</AnimatedValueProvider>
</PropsProvider>
);
}
const MemoizedInner = typedMemo(DraggableFlatListInner);
// Generic forwarded ref type assertion taken from:
// https://fettblog.eu/typescript-react-generic-forward-refs/#option-1%3A-type-assertion
export default React.forwardRef(DraggableFlatList) as <T>(

View File

@ -1,13 +1,18 @@
import React, { useMemo, useRef, useState } from "react";
import React, { useRef, useState } from "react";
import { findNodeHandle, LogBox } from "react-native";
import Animated, { add } from "react-native-reanimated";
import Animated, {
useDerivedValue,
useSharedValue,
} from "react-native-reanimated";
import { DraggableFlatListProps } from "../types";
import DraggableFlatList from "../components/DraggableFlatList";
import { useNestableScrollContainerContext } from "../context/nestableScrollContainerContext";
import { useSafeNestableScrollContainerContext } from "../context/nestableScrollContainerContext";
import { useNestedAutoScroll } from "../hooks/useNestedAutoScroll";
import { typedMemo } from "../utils";
import { useStableCallback } from "../hooks/useStableCallback";
import { FlatList } from "react-native-gesture-handler";
export function NestableDraggableFlatListInner<T>(
function NestableDraggableFlatListInner<T>(
props: DraggableFlatListProps<T>,
ref?: React.ForwardedRef<FlatList<T>>
) {
@ -23,57 +28,75 @@ export function NestableDraggableFlatListInner<T>(
}
const {
containerRef,
scrollableRef,
outerScrollOffset,
setOuterScrollEnabled,
} = useNestableScrollContainerContext();
} = useSafeNestableScrollContainerContext();
const listVerticalOffset = useMemo(() => new Animated.Value<number>(0), []);
const viewRef = useRef<Animated.View>(null);
const listVerticalOffset = useSharedValue(0);
const [animVals, setAnimVals] = useState({});
const defaultHoverOffset = useSharedValue(0);
const [listHoverOffset, setListHoverOffset] = useState(defaultHoverOffset);
useNestedAutoScroll(animVals);
const hoverOffset = useDerivedValue(() => {
return listHoverOffset.value + listVerticalOffset.value;
}, [listHoverOffset]);
const onListContainerLayout = async () => {
const viewNode = viewRef.current;
const nodeHandle = findNodeHandle(containerRef.current);
useNestedAutoScroll({
...animVals,
hoverOffset,
});
const onListContainerLayout = useStableCallback(async ({ containerRef }) => {
const nodeHandle = findNodeHandle(scrollableRef.current);
const onSuccess = (_x: number, y: number) => {
listVerticalOffset.setValue(y);
listVerticalOffset.value = y;
};
const onFail = () => {
console.log("## nested draggable list measure fail");
};
//@ts-ignore
viewNode.measureLayout(nodeHandle, onSuccess, onFail);
};
containerRef.current.measureLayout(nodeHandle, onSuccess, onFail);
});
const onDragBegin: DraggableFlatListProps<T>["onDragBegin"] = useStableCallback(
(params) => {
setOuterScrollEnabled(false);
props.onDragBegin?.(params);
}
);
const onDragEnd: DraggableFlatListProps<T>["onDragEnd"] = useStableCallback(
(params) => {
setOuterScrollEnabled(true);
props.onDragEnd?.(params);
}
);
const onAnimValInit: DraggableFlatListProps<T>["onAnimValInit"] = useStableCallback(
(params) => {
setListHoverOffset(params.hoverOffset);
setAnimVals({
...params,
hoverOffset,
});
props.onAnimValInit?.(params);
}
);
return (
<Animated.View ref={viewRef} onLayout={onListContainerLayout}>
<DraggableFlatList
ref={ref}
activationDistance={20}
autoscrollSpeed={50}
scrollEnabled={false}
{...props}
outerScrollOffset={outerScrollOffset}
onDragBegin={(...args) => {
setOuterScrollEnabled(false);
props.onDragBegin?.(...args);
}}
onDragEnd={(...args) => {
props.onDragEnd?.(...args);
setOuterScrollEnabled(true);
}}
onAnimValInit={(animVals) => {
setAnimVals({
...animVals,
hoverAnim: add(animVals.hoverAnim, listVerticalOffset),
});
props.onAnimValInit?.(animVals);
}}
/>
</Animated.View>
<DraggableFlatList
ref={ref}
onContainerLayout={onListContainerLayout}
activationDistance={props.activationDistance || 20}
scrollEnabled={false}
{...props}
outerScrollOffset={outerScrollOffset}
onDragBegin={onDragBegin}
onDragEnd={onDragEnd}
onAnimValInit={onAnimValInit}
/>
);
}

View File

@ -1,61 +1,65 @@
import React, { useMemo } from "react";
import { NativeScrollEvent, ScrollViewProps } from "react-native";
import React from "react";
import { LayoutChangeEvent, ScrollViewProps } from "react-native";
import { ScrollView } from "react-native-gesture-handler";
import Animated, { block, set } from "react-native-reanimated";
import Animated, { useAnimatedScrollHandler } from "react-native-reanimated";
import {
NestableScrollContainerProvider,
useNestableScrollContainerContext,
useSafeNestableScrollContainerContext,
} from "../context/nestableScrollContainerContext";
import { useStableCallback } from "../hooks/useStableCallback";
const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView);
function NestableScrollContainerInner(props: ScrollViewProps) {
const {
outerScrollOffset,
containerRef,
containerSize,
scrollViewSize,
scrollableRef,
outerScrollEnabled,
} = useNestableScrollContainerContext();
} = useSafeNestableScrollContainerContext();
const onScroll = useMemo(
() =>
Animated.event([
{
nativeEvent: ({ contentOffset }: NativeScrollEvent) =>
block([set(outerScrollOffset, contentOffset.y)]),
},
]),
[]
);
const onScroll = useAnimatedScrollHandler({
onScroll: ({ contentOffset }) => {
outerScrollOffset.value = contentOffset.y;
},
});
const onLayout = useStableCallback((event: LayoutChangeEvent) => {
const {
nativeEvent: { layout },
} = event;
containerSize.value = layout.height;
});
const onContentSizeChange = useStableCallback((w: number, h: number) => {
scrollViewSize.value = h;
props.onContentSizeChange?.(w, h);
});
return (
<Animated.View
ref={containerRef}
onLayout={({ nativeEvent: { layout } }) => {
containerSize.setValue(layout.height);
}}
>
<AnimatedScrollView
{...props}
onContentSizeChange={(w, h) => {
scrollViewSize.setValue(h);
props.onContentSizeChange?.(w, h);
}}
scrollEnabled={outerScrollEnabled}
ref={scrollableRef}
scrollEventThrottle={1}
onScroll={onScroll}
/>
</Animated.View>
<AnimatedScrollView
{...props}
onLayout={onLayout}
onContentSizeChange={onContentSizeChange}
scrollEnabled={outerScrollEnabled}
ref={scrollableRef}
scrollEventThrottle={1}
onScroll={onScroll}
/>
);
}
export function NestableScrollContainer(props: ScrollViewProps) {
return (
<NestableScrollContainerProvider>
<NestableScrollContainerInner {...props} />
</NestableScrollContainerProvider>
);
}
export const NestableScrollContainer = React.forwardRef(
(props: ScrollViewProps, forwardedRef?: React.ForwardedRef<ScrollView>) => {
return (
<NestableScrollContainerProvider
forwardedRef={
(forwardedRef as React.MutableRefObject<ScrollView>) || undefined
}
>
<NestableScrollContainerInner {...props} />
</NestableScrollContainerProvider>
);
}
);

View File

@ -1,19 +1,13 @@
import React, { useCallback, useState } from "react";
import React, { useMemo, useState } from "react";
import { StyleSheet } from "react-native";
import Animated, {
call,
useCode,
onChange,
greaterThan,
cond,
sub,
block,
runOnJS,
useAnimatedReaction,
useAnimatedStyle,
} from "react-native-reanimated";
import { useAnimatedValues } from "../context/animatedValueContext";
import { useDraggableFlatListContext } from "../context/draggableFlatListContext";
import { useProps } from "../context/propsContext";
import { useRefs } from "../context/refContext";
import { useNode } from "../hooks/useNode";
import { RenderPlaceholder } from "../types";
import { typedMemo } from "../utils";
@ -22,70 +16,57 @@ type Props<T> = {
};
function PlaceholderItem<T>({ renderPlaceholder }: Props<T>) {
const [size, setSize] = useState(0);
const {
activeCellSize,
placeholderOffset,
spacerIndexAnim,
horizontalAnim,
scrollOffset,
} = useAnimatedValues();
const [placeholderSize, setPlaceholderSize] = useState(0);
const { keyToIndexRef, propsRef } = useRefs<T>();
const { activeKey, horizontal } = useDraggableFlatListContext();
const { activeKey } = useDraggableFlatListContext();
const { horizontal } = useProps();
const onPlaceholderIndexChange = useCallback(
(index: number) => {
propsRef.current.onPlaceholderIndexChange?.(index);
// Size does not seem to be respected when it is an animated style
useAnimatedReaction(
() => {
return activeCellSize.value;
},
[propsRef]
(cur, prev) => {
if (cur !== prev) {
runOnJS(setSize)(cur);
}
}
);
useCode(
() =>
block([
onChange(
activeCellSize,
call([activeCellSize], ([size]) => {
// Using animated values to set height caused a bug where item wouldn't correctly update
// so instead we mirror the animated value in component state.
setPlaceholderSize(size);
})
),
onChange(
spacerIndexAnim,
call([spacerIndexAnim], ([i]) => {
onPlaceholderIndexChange(i);
if (i === -1) setPlaceholderSize(0);
})
),
]),
[]
);
const translateKey = horizontal ? "translateX" : "translateY";
const sizeKey = horizontal ? "width" : "height";
const opacity = useNode(cond(greaterThan(spacerIndexAnim, -1), 1, 0));
const activeIndex = activeKey
? keyToIndexRef.current.get(activeKey)
: undefined;
const activeItem =
activeIndex === undefined ? null : propsRef.current?.data[activeIndex];
const animStyle = {
opacity,
[sizeKey]: placeholderSize,
transform: ([
{ [translateKey]: sub(placeholderOffset, scrollOffset) },
] as unknown) as Animated.AnimatedTransform,
};
const animStyle = useAnimatedStyle(() => {
const offset = placeholderOffset.value - scrollOffset.value
return {
opacity: size >= 0 ? 1 : 0,
overflow: 'hidden',
transform: [
horizontalAnim.value
? { translateX: offset }
: { translateY: offset },
],
};
}, [spacerIndexAnim, placeholderOffset, horizontalAnim, scrollOffset, size]);
const extraStyle = useMemo(() => {
return horizontal ? { width: size } : { height: size };
}, [horizontal, size])
return (
<Animated.View
pointerEvents={activeKey ? "auto" : "none"}
style={[StyleSheet.absoluteFill, animStyle]}
style={[StyleSheet.absoluteFill, animStyle, extraStyle]}
>
{!activeItem || activeIndex === undefined
? null

View File

@ -1,6 +1,7 @@
import React, { useCallback, useRef } from "react";
import React, { useRef } from "react";
import { useDraggableFlatListContext } from "../context/draggableFlatListContext";
import { useRefs } from "../context/refContext";
import { useStableCallback } from "../hooks/useStableCallback";
import { RenderItem } from "../types";
import { typedMemo } from "../utils";
@ -22,7 +23,7 @@ function RowItem<T>(props: Props<T>) {
activeKeyRef.current = activeKey;
const { keyToIndexRef } = useRefs();
const drag = useCallback(() => {
const drag = useStableCallback(() => {
const { drag, itemKey, debug } = propsRef.current;
if (activeKeyRef.current) {
// already dragging an item, noop
@ -32,16 +33,21 @@ function RowItem<T>(props: Props<T>) {
);
}
drag(itemKey);
}, []);
});
const { renderItem, item, itemKey, extraData } = props;
const getIndex = useStableCallback(() => {
return keyToIndexRef.current.get(itemKey);
});
return (
<MemoizedInner
isActive={activeKey === itemKey}
drag={drag}
renderItem={renderItem}
item={item}
index={keyToIndexRef.current.get(itemKey)}
getIndex={getIndex}
extraData={extraData}
/>
);
@ -52,13 +58,14 @@ export default typedMemo(RowItem);
type InnerProps<T> = {
isActive: boolean;
item: T;
index?: number;
getIndex: () => number | undefined;
drag: () => void;
renderItem: RenderItem<T>;
extraData?: any;
};
function Inner<T>({ isActive, item, drag, index, renderItem }: InnerProps<T>) {
return renderItem({ isActive, item, drag, index }) as JSX.Element;
function Inner<T>({ renderItem, extraData, ...rest }: InnerProps<T>) {
return renderItem({ ...rest }) as JSX.Element;
}
const MemoizedInner = typedMemo(Inner);

View File

@ -1,19 +1,24 @@
import Animated, { call, onChange, useCode } from "react-native-reanimated";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import { typedMemo } from "../utils";
type Props = {
scrollOffset: Animated.Value<number>;
onScrollOffsetChange: (offset: readonly number[]) => void;
scrollOffset: Animated.SharedValue<number>;
onScrollOffsetChange: (offset: number) => void;
};
const ScrollOffsetListener = ({
scrollOffset,
onScrollOffsetChange,
}: Props) => {
useCode(
() => onChange(scrollOffset, call([scrollOffset], onScrollOffsetChange)),
[]
);
useAnimatedReaction(() => {
return scrollOffset.value
}, (cur, prev) => {
if (cur !== prev) {
runOnJS(onScrollOffsetChange)(cur)
}
}, [scrollOffset])
return null;
};

View File

@ -24,7 +24,6 @@ export const DEFAULT_PROPS = {
dragHitSlop: 0 as PanGestureHandlerProperties["hitSlop"],
activationDistance: 0,
dragItemOverflow: false,
outerScrollOffset: new Animated.Value<number>(0),
};
export const isIOS = Platform.OS === "ios";
@ -35,7 +34,7 @@ export const isWeb = Platform.OS === "web";
export const isReanimatedV2 = !!useSharedValue;
if (!isReanimatedV2) {
console.warn(
"Your version of react-native-reanimated is too old for react-native-draggable-flatlist. It may not work as expected."
throw new Error(
"Your version of react-native-reanimated is too old for react-native-draggable-flatlist!"
);
}

View File

@ -1,25 +1,11 @@
import React, { useContext, useEffect, useMemo } from "react";
import Animated, {
add,
and,
block,
greaterThan,
max,
min,
onChange,
set,
sub,
useCode,
useValue,
import React, { useMemo, useEffect, useCallback, useContext } from "react";
import {
useAnimatedReaction,
useDerivedValue,
useSharedValue,
} from "react-native-reanimated";
import { State as GestureState } from "react-native-gesture-handler";
import { useNode } from "../hooks/useNode";
import { useProps } from "./propsContext";
import { DEFAULT_PROPS } from "../constants";
if (!useValue) {
throw new Error("Incompatible Reanimated version (useValue not found)");
}
const AnimatedValueContext = React.createContext<
ReturnType<typeof useSetupAnimatedValues> | undefined
@ -50,86 +36,115 @@ export function useAnimatedValues() {
function useSetupAnimatedValues<T>() {
const props = useProps<T>();
const containerSize = useValue<number>(0);
const touchInit = useValue<number>(0); // Position of initial touch
const activationDistance = useValue<number>(0); // Distance finger travels from initial touch to when dragging begins
const touchAbsolute = useValue<number>(0); // Finger position on screen, relative to container
const panGestureState = useValue<GestureState>(GestureState.UNDETERMINED);
const DEFAULT_VAL = useSharedValue(0)
const isTouchActiveNative = useValue<number>(0);
const containerSize = useSharedValue(0);
const scrollViewSize = useSharedValue(0);
const disabled = useValue<number>(0);
const horizontalAnim = useValue(props.horizontal ? 1 : 0);
const activeIndexAnim = useValue<number>(-1); // Index of hovering cell
const spacerIndexAnim = useValue<number>(-1); // Index of hovered-over cell
const activeCellSize = useValue<number>(0); // Height or width of acctive cell
const activeCellOffset = useValue<number>(0); // Distance between active cell and edge of container
const isDraggingCell = useNode(
and(isTouchActiveNative, greaterThan(activeIndexAnim, -1))
const panGestureState = useSharedValue<GestureState>(
GestureState.UNDETERMINED
);
const touchTranslate = useSharedValue(0);
const scrollOffset = useValue<number>(0);
const isTouchActiveNative = useSharedValue(false);
const outerScrollOffset =
props.outerScrollOffset || DEFAULT_PROPS.outerScrollOffset;
const outerScrollOffsetSnapshot = useValue<number>(0); // Amount any outer scrollview has scrolled since last gesture event.
const outerScrollOffsetDiff = sub(
outerScrollOffset,
outerScrollOffsetSnapshot
);
const hasMoved = useSharedValue(0);
const disabled = useSharedValue(false);
const scrollViewSize = useValue<number>(0);
const horizontalAnim = useSharedValue(!!props.horizontal);
const touchCellOffset = useNode(sub(touchInit, activeCellOffset));
const activeIndexAnim = useSharedValue(-1); // Index of hovering cell
const spacerIndexAnim = useSharedValue(-1); // Index of hovered-over cell
const hoverAnimUnconstrained = useNode(
add(
outerScrollOffsetDiff,
sub(sub(touchAbsolute, activationDistance), touchCellOffset)
)
);
const activeCellSize = useSharedValue(0); // Height or width of acctive cell
const activeCellOffset = useSharedValue(0); // Distance between active cell and edge of container
const hoverAnimConstrained = useNode(
min(sub(containerSize, activeCellSize), max(0, hoverAnimUnconstrained))
);
const scrollOffset = useSharedValue(0);
const scrollInit = useSharedValue(0);
const hoverAnim = props.dragItemOverflow
? hoverAnimUnconstrained
: hoverAnimConstrained;
// If list is nested there may be an outer scrollview
const outerScrollOffset = props.outerScrollOffset || DEFAULT_VAL;
const outerScrollInit = useSharedValue(0);
const hoverOffset = useNode(add(hoverAnim, scrollOffset));
useAnimatedReaction(() => {
return activeIndexAnim.value
}, (cur, prev) => {
if (cur !== prev && cur >= 0) {
scrollInit.value = scrollOffset.value;
outerScrollInit.value = outerScrollOffset.value;
}
}, [outerScrollOffset]);
useCode(
() =>
onChange(
touchAbsolute,
// If the list is being used in "nested" mode (ie. there's an outer scrollview that contains the list)
// then we need a way to track the amound the outer list has auto-scrolled during the current touch position.
set(outerScrollOffsetSnapshot, outerScrollOffset)
),
[outerScrollOffset]
);
const placeholderOffset = useSharedValue(0);
const placeholderOffset = useValue<number>(0);
const isDraggingCell = useDerivedValue(() => {
return isTouchActiveNative.value && activeIndexAnim.value >= 0;
}, []);
const autoScrollDistance = useDerivedValue(() => {
if (!isDraggingCell.value) return 0
const innerScrollDiff = scrollOffset.value - scrollInit.value;
// If list is nested there may be an outer scroll diff
const outerScrollDiff = outerScrollOffset.value - outerScrollInit.value
const scrollDiff = innerScrollDiff + outerScrollDiff;
return scrollDiff;
}, []);
const touchPositionDiff = useDerivedValue(() => {
const extraTranslate = isTouchActiveNative.value
? autoScrollDistance.value
: 0;
return touchTranslate.value + extraTranslate;
}, []);
const touchPositionDiffConstrained = useDerivedValue(() => {
const containerMinusActiveCell =
containerSize.value - activeCellSize.value + scrollOffset.value;
const constrained = Math.min(
containerMinusActiveCell,
Math.max(
scrollOffset.value,
touchPositionDiff.value + activeCellOffset.value
)
);
// Only constrain the touch position while the finger is on the screen. This allows the active cell
// to snap above/below the fold once let go, if the drag ends at the top/bottom of the screen.
return isTouchActiveNative.value
? constrained - activeCellOffset.value
: touchPositionDiff.value;
}, []);
const hoverAnim = useDerivedValue(() => {
if (activeIndexAnim.value < 0) return 0;
return props.dragItemOverflow
? touchPositionDiff.value
: touchPositionDiffConstrained.value;
}, []);
const hoverOffset = useDerivedValue(() => {
return hoverAnim.value + activeCellOffset.value;
}, [hoverAnim, activeCellOffset]);
useDerivedValue(() => {
// Reset spacer index when we stop hovering
const isHovering = activeIndexAnim.value >= 0;
if (!isHovering && spacerIndexAnim.value >= 0) {
spacerIndexAnim.value = -1;
}
}, []);
// Note: this could use a refactor as it combines touch state + cell animation
const resetTouchedCell = useNode(
block([
set(touchAbsolute, 0),
set(touchInit, 0),
set(activeCellOffset, 0),
set(activationDistance, 0),
])
);
const resetTouchedCell = useCallback(() => {
activeCellOffset.value = 0;
hasMoved.value = 0;
}, []);
const value = useMemo(
() => ({
activationDistance,
activeCellOffset,
activeCellSize,
activeIndexAnim,
@ -137,8 +152,6 @@ function useSetupAnimatedValues<T>() {
disabled,
horizontalAnim,
hoverAnim,
hoverAnimConstrained,
hoverAnimUnconstrained,
hoverOffset,
isDraggingCell,
isTouchActiveNative,
@ -148,12 +161,11 @@ function useSetupAnimatedValues<T>() {
scrollOffset,
scrollViewSize,
spacerIndexAnim,
touchAbsolute,
touchCellOffset,
touchInit,
touchPositionDiff,
touchTranslate,
autoScrollDistance,
}),
[
activationDistance,
activeCellOffset,
activeCellSize,
activeIndexAnim,
@ -161,8 +173,6 @@ function useSetupAnimatedValues<T>() {
disabled,
horizontalAnim,
hoverAnim,
hoverAnimConstrained,
hoverAnimUnconstrained,
hoverOffset,
isDraggingCell,
isTouchActiveNative,
@ -172,12 +182,12 @@ function useSetupAnimatedValues<T>() {
scrollOffset,
scrollViewSize,
spacerIndexAnim,
touchAbsolute,
touchCellOffset,
touchInit,
touchPositionDiff,
touchTranslate,
autoScrollDistance,
]
);
useEffect(() => {
props.onAnimValInit?.(value);
}, [value]);

View File

@ -1,4 +1,5 @@
import React, { useContext, useMemo } from "react";
import { typedMemo } from "../utils";
type CellContextValue = {
isActive: boolean;
@ -13,7 +14,7 @@ type Props = {
children: React.ReactNode;
};
export default function CellProvider({ isActive, children }: Props) {
export function CellProvider({ isActive, children }: Props) {
const value = useMemo(
() => ({
isActive,
@ -23,6 +24,8 @@ export default function CellProvider({ isActive, children }: Props) {
return <CellContext.Provider value={value}>{children}</CellContext.Provider>;
}
export default typedMemo(CellProvider);
export function useIsActive() {
const value = useContext(CellContext);
if (!value) {

View File

@ -2,7 +2,6 @@ import React, { useContext, useMemo } from "react";
type Props<T> = {
activeKey: string | null;
onDragEnd: ([from, to]: readonly number[]) => void;
keyExtractor: (item: T, index: number) => string;
horizontal: boolean;
children: React.ReactNode;
@ -16,7 +15,6 @@ const DraggableFlatListContext = React.createContext<
export default function DraggableFlatListProvider<T>({
activeKey,
onDragEnd,
keyExtractor,
horizontal,
children,
@ -25,10 +23,9 @@ export default function DraggableFlatListProvider<T>({
() => ({
activeKey,
keyExtractor,
onDragEnd,
horizontal,
}),
[activeKey, onDragEnd, keyExtractor, horizontal]
[activeKey, keyExtractor, horizontal]
);
return (

View File

@ -1,6 +1,6 @@
import React, { useContext, useMemo, useRef, useState } from "react";
import { ScrollView } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
import Animated, { useSharedValue } from "react-native-reanimated";
type NestableScrollContainerContextVal = ReturnType<
typeof useSetupNestableScrollContextValue
@ -9,13 +9,17 @@ const NestableScrollContainerContext = React.createContext<
NestableScrollContainerContextVal | undefined
>(undefined);
function useSetupNestableScrollContextValue() {
function useSetupNestableScrollContextValue({
forwardedRef,
}: {
forwardedRef?: React.MutableRefObject<ScrollView>;
}) {
const [outerScrollEnabled, setOuterScrollEnabled] = useState(true);
const scrollViewSize = useMemo(() => new Animated.Value<number>(0), []);
const scrollableRef = useRef<ScrollView>(null);
const outerScrollOffset = useMemo(() => new Animated.Value<number>(0), []);
const containerRef = useRef<Animated.View>(null);
const containerSize = useMemo(() => new Animated.Value<number>(0), []);
const scrollViewSize = useSharedValue(0);
const scrollableRefInner = useRef<ScrollView>(null);
const scrollableRef = forwardedRef || scrollableRefInner;
const outerScrollOffset = useSharedValue(0);
const containerSize = useSharedValue(0);
const contextVal = useMemo(
() => ({
@ -24,7 +28,6 @@ function useSetupNestableScrollContextValue() {
outerScrollOffset,
scrollViewSize,
scrollableRef,
containerRef,
containerSize,
}),
[outerScrollEnabled]
@ -35,10 +38,12 @@ function useSetupNestableScrollContextValue() {
export function NestableScrollContainerProvider({
children,
forwardedRef,
}: {
children: React.ReactNode;
forwardedRef?: React.MutableRefObject<ScrollView>;
}) {
const contextVal = useSetupNestableScrollContextValue();
const contextVal = useSetupNestableScrollContextValue({ forwardedRef });
return (
<NestableScrollContainerContext.Provider value={contextVal}>
{children}
@ -48,9 +53,14 @@ export function NestableScrollContainerProvider({
export function useNestableScrollContainerContext() {
const value = useContext(NestableScrollContainerContext);
return value;
}
export function useSafeNestableScrollContainerContext() {
const value = useNestableScrollContainerContext();
if (!value) {
throw new Error(
"useNestableScrollContainerContext must be called from within NestableScrollContainerContext Provider!"
"useSafeNestableScrollContainerContext must be called within a NestableScrollContainerContext.Provider"
);
}
return value;

View File

@ -1,25 +1,19 @@
import React, { useContext } from "react";
import { useMemo, useRef } from "react";
import { FlatList, PanGestureHandler } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
import { FlatList } from "react-native-gesture-handler";
import Animated, { WithSpringConfig } from "react-native-reanimated";
import { DEFAULT_PROPS } from "../constants";
import { useProps } from "./propsContext";
import { useAnimatedValues } from "./animatedValueContext";
import { CellData, DraggableFlatListProps } from "../types";
type RefContextValue<T> = {
propsRef: React.MutableRefObject<DraggableFlatListProps<T>>;
animationConfigRef: React.MutableRefObject<Animated.SpringConfig>;
animationConfigRef: React.MutableRefObject<WithSpringConfig>;
cellDataRef: React.MutableRefObject<Map<string, CellData>>;
keyToIndexRef: React.MutableRefObject<Map<string, number>>;
containerRef: React.RefObject<Animated.View>;
flatListRef: React.RefObject<FlatList<T>> | React.ForwardedRef<FlatList<T>>;
panGestureHandlerRef: React.RefObject<PanGestureHandler>;
scrollOffsetRef: React.MutableRefObject<number>;
isTouchActiveRef: React.MutableRefObject<{
native: Animated.Value<number>;
js: boolean;
}>;
flatlistRef: React.RefObject<FlatList<T>> | React.ForwardedRef<FlatList<T>>;
scrollViewRef: React.RefObject<Animated.ScrollView>;
};
const RefContext = React.createContext<RefContextValue<any> | undefined>(
undefined
@ -30,7 +24,7 @@ export default function RefProvider<T>({
flatListRef,
}: {
children: React.ReactNode;
flatListRef: React.ForwardedRef<FlatList<T>>;
flatListRef?: React.ForwardedRef<FlatList<T>> | null;
}) {
const value = useSetupRefs<T>({ flatListRef });
return <RefContext.Provider value={value}>{children}</RefContext.Provider>;
@ -49,45 +43,48 @@ export function useRefs<T>() {
function useSetupRefs<T>({
flatListRef: flatListRefProp,
}: {
flatListRef: React.ForwardedRef<FlatList<T>>;
flatListRef?: React.ForwardedRef<FlatList<T>> | null;
}) {
const props = useProps<T>();
const { animationConfig = DEFAULT_PROPS.animationConfig } = props;
const { isTouchActiveNative } = useAnimatedValues();
const propsRef = useRef(props);
propsRef.current = props;
const animConfig = {
...DEFAULT_PROPS.animationConfig,
...animationConfig,
} as Animated.SpringConfig;
} as WithSpringConfig;
const animationConfigRef = useRef(animConfig);
animationConfigRef.current = animConfig;
const cellDataRef = useRef(new Map<string, CellData>());
const keyToIndexRef = useRef(new Map<string, number>());
const containerRef = useRef<Animated.View>(null);
const flatListRefInner = useRef<FlatList<T>>(null);
const flatListRef = flatListRefProp || flatListRefInner;
const panGestureHandlerRef = useRef<PanGestureHandler>(null);
const scrollOffsetRef = useRef(0);
const isTouchActiveRef = useRef({
native: isTouchActiveNative,
js: false,
});
const flatlistRefInternal = useRef<FlatList<T>>(null);
const flatlistRef = flatListRefProp || flatlistRefInternal;
const scrollViewRef = useRef<Animated.ScrollView>(null);
// useEffect(() => {
// // This is a workaround for the fact that RN does not respect refs passed in
// // to renderScrollViewComponent underlying ScrollView (currently not used but
// // may need to add if we want to use reanimated scrollTo in the future)
// //@ts-ignore
// const scrollRef = flatlistRef.current?.getNativeScrollRef();
// if (!scrollViewRef.current) {
// //@ts-ignore
// scrollViewRef(scrollRef);
// }
// }, []);
const refs = useMemo(
() => ({
animationConfigRef,
cellDataRef,
containerRef,
flatListRef,
isTouchActiveRef,
flatlistRef,
keyToIndexRef,
panGestureHandlerRef,
propsRef,
scrollOffsetRef,
scrollViewRef,
}),
[]
);

View File

@ -1,32 +1,17 @@
import { useRef } from "react";
import Animated, {
abs,
add,
and,
block,
call,
cond,
eq,
greaterOrEq,
lessOrEq,
max,
not,
onChange,
or,
set,
sub,
useCode,
useValue,
import {
runOnJS,
useAnimatedReaction,
useDerivedValue,
useSharedValue,
} from "react-native-reanimated";
import { FlatList, State as GestureState } from "react-native-gesture-handler";
import { DEFAULT_PROPS, SCROLL_POSITION_TOLERANCE } from "../constants";
import { useNode } from "../hooks/useNode";
import { useProps } from "../context/propsContext";
import { useAnimatedValues } from "../context/animatedValueContext";
import { useRefs } from "../context/refContext";
export function useAutoScroll<T>() {
const { flatListRef } = useRefs<T>();
export function useAutoScroll() {
const { flatlistRef } = useRefs();
const {
autoscrollThreshold = DEFAULT_PROPS.autoscrollThreshold,
autoscrollSpeed = DEFAULT_PROPS.autoscrollSpeed,
@ -36,206 +21,99 @@ export function useAutoScroll<T>() {
scrollOffset,
scrollViewSize,
containerSize,
hoverAnim,
isDraggingCell,
activeCellSize,
panGestureState,
hoverOffset,
activeIndexAnim,
} = useAnimatedValues();
const isScrolledUp = useNode(
lessOrEq(sub(scrollOffset, SCROLL_POSITION_TOLERANCE), 0)
);
const isScrolledDown = useNode(
greaterOrEq(
add(scrollOffset, containerSize, SCROLL_POSITION_TOLERANCE),
scrollViewSize
)
);
const hoverScreenOffset = useDerivedValue(() => {
return hoverOffset.value - scrollOffset.value;
}, []);
const distToTopEdge = useNode(max(0, hoverAnim));
const distToBottomEdge = useNode(
max(0, sub(containerSize, add(hoverAnim, activeCellSize)))
);
const isScrolledUp = useDerivedValue(() => {
return scrollOffset.value - SCROLL_POSITION_TOLERANCE <= 0;
}, []);
const isAtTopEdge = useNode(lessOrEq(distToTopEdge, autoscrollThreshold));
const isAtBottomEdge = useNode(
lessOrEq(distToBottomEdge, autoscrollThreshold!)
);
const isScrolledDown = useDerivedValue(() => {
return (
scrollOffset.value + containerSize.value + SCROLL_POSITION_TOLERANCE >=
scrollViewSize.value
);
}, []);
const isAtEdge = useNode(or(isAtBottomEdge, isAtTopEdge));
const autoscrollParams = [
distToTopEdge,
distToBottomEdge,
scrollOffset,
isScrolledUp,
isScrolledDown,
];
const distToTopEdge = useDerivedValue(() => {
return Math.max(0, hoverScreenOffset.value);
}, []);
const targetScrollOffset = useValue<number>(0);
const resolveAutoscroll = useRef<(params: readonly number[]) => void>();
const distToBottomEdge = useDerivedValue(() => {
const hoverPlusActiveCell = hoverScreenOffset.value + activeCellSize.value;
return Math.max(0, containerSize.value - hoverPlusActiveCell);
}, []);
const isAutoScrollInProgressNative = useValue<number>(0);
const isAutoScrollInProgress = useRef({
js: false,
native: isAutoScrollInProgressNative,
const isAtTopEdge = useDerivedValue(() => {
return distToTopEdge.value <= autoscrollThreshold;
});
const isDraggingCellJS = useRef(false);
useCode(
() =>
block([
onChange(
isDraggingCell,
call([isDraggingCell], ([v]) => {
isDraggingCellJS.current = !!v;
})
),
]),
[]
const isAtBottomEdge = useDerivedValue(() => {
return distToBottomEdge.value <= autoscrollThreshold;
}, []);
const scrollTarget = useSharedValue(0);
const dragIsActive = useDerivedValue(() => {
return activeIndexAnim.value >= 0;
}, []);
useAnimatedReaction(
() => {
return dragIsActive.value;
},
(cur, prev) => {
if (cur && !prev) {
scrollTarget.value = scrollOffset.value;
}
}
);
// Ensure that only 1 call to autoscroll is active at a time
const autoscrollLooping = useRef(false);
const shouldAutoScroll = useDerivedValue(() => {
const scrollTargetDiff = Math.abs(scrollTarget.value - scrollOffset.value);
const hasScrolledToTarget = scrollTargetDiff < SCROLL_POSITION_TOLERANCE;
const onAutoscrollComplete = (params: readonly number[]) => {
isAutoScrollInProgress.current.js = false;
resolveAutoscroll.current?.(params);
};
const isAtEdge = isAtTopEdge.value || isAtBottomEdge.value;
const topDisabled = isAtTopEdge.value && isScrolledUp.value;
const bottomDisabled = isAtBottomEdge.value && isScrolledDown.value;
const isEdgeDisabled = topDisabled || bottomDisabled;
const scrollToAsync = (offset: number): Promise<readonly number[]> =>
new Promise((resolve) => {
resolveAutoscroll.current = resolve;
targetScrollOffset.setValue(offset);
isAutoScrollInProgress.current.native.setValue(1);
isAutoScrollInProgress.current.js = true;
const cellIsActive = activeIndexAnim.value >= 0;
function getFlatListNode(): FlatList<T> | null {
if (!flatListRef || !("current" in flatListRef) || !flatListRef.current)
return null;
if ("scrollToOffset" in flatListRef.current)
return flatListRef.current as FlatList<T>;
if ("getNode" in flatListRef.current) {
//@ts-ignore backwards compat
return flatListRef.current.getNode();
}
return null;
}
return hasScrolledToTarget && isAtEdge && !isEdgeDisabled && cellIsActive;
}, []);
const flatListNode = getFlatListNode();
function scrollToInternal(offset: number) {
if (flatlistRef && "current" in flatlistRef) {
flatlistRef.current?.scrollToOffset({ offset, animated: true });
}
}
flatListNode?.scrollToOffset?.({ offset });
});
useDerivedValue(() => {
if (!shouldAutoScroll.value) return;
const getScrollTargetOffset = (
distFromTop: number,
distFromBottom: number,
scrollOffset: number,
isScrolledUp: boolean,
isScrolledDown: boolean
) => {
if (isAutoScrollInProgress.current.js) return -1;
const scrollUp = distFromTop < autoscrollThreshold!;
const scrollDown = distFromBottom < autoscrollThreshold!;
if (
!(scrollUp || scrollDown) ||
(scrollUp && isScrolledUp) ||
(scrollDown && isScrolledDown)
)
return -1;
const distFromEdge = scrollUp ? distFromTop : distFromBottom;
const distFromEdge = isAtTopEdge.value
? distToTopEdge.value
: distToBottomEdge.value;
const speedPct = 1 - distFromEdge / autoscrollThreshold!;
const offset = speedPct * autoscrollSpeed;
const targetOffset = scrollUp
? Math.max(0, scrollOffset - offset)
: scrollOffset + offset;
return targetOffset;
};
const autoscroll = async (params: readonly number[]) => {
if (autoscrollLooping.current) {
return;
}
autoscrollLooping.current = true;
try {
let shouldScroll = true;
let curParams = params;
while (shouldScroll) {
const [
distFromTop,
distFromBottom,
scrollOffset,
isScrolledUp,
isScrolledDown,
] = curParams;
const targetOffset = getScrollTargetOffset(
distFromTop,
distFromBottom,
scrollOffset,
!!isScrolledUp,
!!isScrolledDown
const targetOffset = isAtTopEdge.value
? Math.max(0, scrollOffset.value - offset)
: Math.min(
scrollOffset.value + offset,
scrollViewSize.value - containerSize.value
);
const scrollingUpAtTop = !!(
isScrolledUp && targetOffset <= scrollOffset
);
const scrollingDownAtBottom = !!(
isScrolledDown && targetOffset >= scrollOffset
);
shouldScroll =
targetOffset >= 0 &&
isDraggingCellJS.current &&
!scrollingUpAtTop &&
!scrollingDownAtBottom;
if (shouldScroll) {
try {
curParams = await scrollToAsync(targetOffset);
} catch (err) {}
}
}
} finally {
autoscrollLooping.current = false;
}
};
scrollTarget.value = targetOffset;
// Reanimated scrollTo is crashing on android. use 'regular' scrollTo until figured out.
// scrollTo(scrollViewRef, targetX, targetY, true);
runOnJS(scrollToInternal)(targetOffset);
}, []);
const checkAutoscroll = useNode(
cond(
and(
isAtEdge,
not(and(isAtTopEdge, isScrolledUp)),
not(and(isAtBottomEdge, isScrolledDown)),
eq(panGestureState, GestureState.ACTIVE),
not(isAutoScrollInProgress.current.native)
),
call(autoscrollParams, autoscroll)
)
);
useCode(() => checkAutoscroll, []);
const onScrollNode = useNode(
cond(
and(
isAutoScrollInProgress.current.native,
or(
// We've scrolled to where we want to be
lessOrEq(
abs(sub(targetScrollOffset, scrollOffset)),
SCROLL_POSITION_TOLERANCE
),
// We're at the start, but still want to scroll farther up
and(isScrolledUp, lessOrEq(targetScrollOffset, scrollOffset)),
// We're at the end, but still want to scroll further down
and(isScrolledDown, greaterOrEq(targetScrollOffset, scrollOffset))
)
),
[
// Finish scrolling
set(isAutoScrollInProgress.current.native, 0),
call(autoscrollParams, onAutoscrollComplete),
]
)
);
return onScrollNode;
return null;
}

View File

@ -1,107 +1,103 @@
import Animated, {
add,
block,
call,
clockRunning,
cond,
eq,
onChange,
stopClock,
useCode,
useValue,
} from "react-native-reanimated";
import Animated, { useDerivedValue, withSpring } from "react-native-reanimated";
import { useAnimatedValues } from "../context/animatedValueContext";
import { useRefs } from "../context/refContext";
import { setupCell, springFill } from "../procs";
import { useSpring } from "./useSpring";
import { useNode } from "../hooks/useNode";
import { useDraggableFlatListContext } from "../context/draggableFlatListContext";
import { useRefs } from "../context/refContext";
type Params = {
cellIndex: Animated.Value<number>;
cellSize: Animated.Value<number>;
cellOffset: Animated.Value<number>;
cellIndex: Animated.SharedValue<number>;
cellSize: Animated.SharedValue<number>;
cellOffset: Animated.SharedValue<number>;
};
export function useCellTranslate({ cellIndex, cellSize, cellOffset }: Params) {
const {
activeIndexAnim,
activeCellSize,
hoverAnim,
scrollOffset,
hoverOffset,
spacerIndexAnim,
placeholderOffset,
isDraggingCell,
resetTouchedCell,
disabled,
hoverAnim,
} = useAnimatedValues();
const { activeKey } = useDraggableFlatListContext()
const { animationConfigRef } = useRefs();
const { onDragEnd } = useDraggableFlatListContext();
const cellSpring = useSpring({ config: animationConfigRef.current });
const { clock, state, config } = cellSpring;
const translate = useDerivedValue(() => {
if (!activeKey || activeIndexAnim.value < 0) return 0
const isAfterActive = useValue(0);
const isClockRunning = useNode(clockRunning(clock));
// Determining spacer index is hard to visualize. See diagram: https://i.imgur.com/jRPf5t3.jpg
const isBeforeActive = cellIndex.value < activeIndexAnim.value;
const isAfterActive = cellIndex.value > activeIndexAnim.value;
const runSpring = useNode(springFill(clock, state, config));
const hoverPlusActiveSize = hoverOffset.value + activeCellSize.value;
const offsetPlusHalfSize = cellOffset.value + cellSize.value / 2;
const offsetPlusSize = cellOffset.value + cellSize.value;
let result = -1;
// Even though this is the same value as hoverOffset passed via context
// the android context value lags behind the actual value on autoscroll
const cellHoverOffset = useNode(add(hoverAnim, scrollOffset));
if (isAfterActive) {
if (
hoverPlusActiveSize >= cellOffset.value &&
hoverPlusActiveSize < offsetPlusHalfSize
) {
// bottom edge of active cell overlaps top half of current cell
result = cellIndex.value - 1;
} else if (
hoverPlusActiveSize >= offsetPlusHalfSize &&
hoverPlusActiveSize < offsetPlusSize
) {
// bottom edge of active cell overlaps bottom half of current cell
result = cellIndex.value;
}
} else if (isBeforeActive) {
if (
hoverOffset.value < offsetPlusSize &&
hoverOffset.value >= offsetPlusHalfSize
) {
// top edge of active cell overlaps bottom half of current cell
result = cellIndex.value + 1;
} else if (
hoverOffset.value >= cellOffset.value &&
hoverOffset.value < offsetPlusHalfSize
) {
// top edge of active cell overlaps top half of current cell
result = cellIndex.value;
}
}
const onFinished = useNode(
cond(isClockRunning, [
stopClock(clock),
cond(eq(cellIndex, activeIndexAnim), [
resetTouchedCell,
call([activeIndexAnim, spacerIndexAnim], onDragEnd),
]),
])
);
if (result !== -1 && result !== spacerIndexAnim.value) {
spacerIndexAnim.value = result;
}
const prevTrans = useValue<number>(0);
const prevSpacerIndex = useValue<number>(-1);
const prevIsDraggingCell = useValue<number>(0);
if (spacerIndexAnim.value === cellIndex.value) {
const newPlaceholderOffset = isAfterActive
? cellSize.value + (cellOffset.value - activeCellSize.value)
: cellOffset.value;
placeholderOffset.value = newPlaceholderOffset;
}
const cellTranslate = useNode(
setupCell(
cellIndex,
cellSize,
cellOffset,
isAfterActive,
prevTrans,
prevSpacerIndex,
activeIndexAnim,
activeCellSize,
cellHoverOffset,
spacerIndexAnim,
//@ts-ignore
config.toValue,
state.position,
state.time,
state.finished,
runSpring,
onFinished,
isDraggingCell,
placeholderOffset,
prevIsDraggingCell,
clock,
disabled
)
);
// If no active cell, translation is already 0
if (activeIndexAnim.value < 0) return 0;
// This is a workaround required to continually evaluate values
useCode(
() =>
block([
onChange(cellTranslate, []),
onChange(prevTrans, []),
onChange(cellSize, []),
onChange(cellOffset, []),
]),
[]
);
// Active cell follows touch
if (cellIndex.value === activeIndexAnim.value) {
return hoverAnim.value
};
return state.position;
// Translate cell down if it is before active index and active cell has passed it.
// Translate cell up if it is after the active index and active cell has passed it.
const shouldTranslate = isAfterActive
? cellIndex.value <= spacerIndexAnim.value
: cellIndex.value >= spacerIndexAnim.value;
const translationAmt = shouldTranslate
? activeCellSize.value * (isAfterActive ? -1 : 1)
: 0;
return withSpring(translationAmt, animationConfigRef.current);
}, [activeKey]);
return translate;
}

View File

@ -1,278 +1,128 @@
import { DependencyList, useRef } from "react";
import Animated, {
abs,
add,
and,
block,
call,
cond,
eq,
greaterOrEq,
lessOrEq,
max,
not,
onChange,
or,
set,
sub,
useCode,
useValue,
runOnJS,
useAnimatedReaction,
useDerivedValue,
useSharedValue,
} from "react-native-reanimated";
import { State as GestureState } from "react-native-gesture-handler";
import { useNestableScrollContainerContext } from "../context/nestableScrollContainerContext";
import { useSafeNestableScrollContainerContext } from "../context/nestableScrollContainerContext";
import { SCROLL_POSITION_TOLERANCE } from "../constants";
function useNodeAlt<T>(node: Animated.Node<T>, deps: DependencyList = []) {
// NOTE: memoizing currently breaks animations, not sure why
// return useMemo(() => node, deps)
return node;
}
const DUMMY_VAL = new Animated.Value<number>(0);
// This is mostly copied over from the main react-native-draggable-flatlist
// useAutoScroll hook with a few notable exceptions:
// - Since Animated.Values are now coming from the caller,
// we won't guarantee they exist and default if not.
// This changes our useNode implementation since we don't want to store stale nodes.
// - Since animated values are now coming in via a callback,
// we won't guarantee they exist (and default them if not).
// - Outer scrollable is a ScrollView, not a FlatList
// TODO: see if we can combine into a single `useAutoScroll()` hook
// TODO: see if we can combine into a single shared `useAutoScroll()` hook
export function useNestedAutoScroll({
activeCellSize = DUMMY_VAL,
autoscrollSpeed = 100,
autoscrollThreshold = 30,
hoverAnim = DUMMY_VAL,
isDraggingCell = DUMMY_VAL,
panGestureState = DUMMY_VAL,
}: {
activeCellSize?: Animated.Node<number>;
export function useNestedAutoScroll(params: {
activeCellSize?: Animated.SharedValue<number>;
autoscrollSpeed?: number;
autoscrollThreshold?: number;
hoverAnim?: Animated.Node<number>;
isDraggingCell?: Animated.Node<number>;
panGestureState?: Animated.Node<GestureState | number>;
hoverOffset?: Animated.SharedValue<number>;
isDraggingCell?: Animated.SharedValue<number>;
isTouchActiveNative?: Animated.SharedValue<number>;
panGestureState?: Animated.SharedValue<GestureState | number>;
}) {
const {
outerScrollOffset,
containerSize,
scrollableRef,
scrollViewSize,
} = useNestableScrollContainerContext();
} = useSafeNestableScrollContainerContext();
const scrollOffset = outerScrollOffset;
const DUMMY_VAL = useSharedValue(0);
const isScrolledUp = useNodeAlt(
lessOrEq(sub(scrollOffset, SCROLL_POSITION_TOLERANCE), 0),
[scrollOffset]
);
const isScrolledDown = useNodeAlt(
greaterOrEq(
add(scrollOffset, containerSize, SCROLL_POSITION_TOLERANCE),
scrollViewSize
),
[scrollOffset, containerSize, scrollViewSize]
);
const {
hoverOffset = DUMMY_VAL,
activeCellSize = DUMMY_VAL,
autoscrollSpeed = 100,
autoscrollThreshold = 30,
isDraggingCell = DUMMY_VAL,
isTouchActiveNative = DUMMY_VAL,
} = params;
const distToTopEdge = useNodeAlt(max(0, sub(hoverAnim, scrollOffset)), [
hoverAnim,
scrollOffset,
]);
const distToBottomEdge = useNodeAlt(
max(
0,
sub(containerSize, add(sub(hoverAnim, scrollOffset), activeCellSize))
),
[containerSize, hoverAnim, scrollOffset, activeCellSize]
);
const hoverScreenOffset = useDerivedValue(() => {
return hoverOffset.value - outerScrollOffset.value;
}, []);
const isAtTopEdge = useNodeAlt(lessOrEq(distToTopEdge, autoscrollThreshold), [
distToTopEdge,
autoscrollThreshold,
]);
const isAtBottomEdge = useNodeAlt(
lessOrEq(distToBottomEdge, autoscrollThreshold!),
[distToBottomEdge, autoscrollThreshold]
);
const isScrolledUp = useDerivedValue(() => {
return outerScrollOffset.value - SCROLL_POSITION_TOLERANCE <= 0;
}, []);
const isAtEdge = useNodeAlt(or(isAtBottomEdge, isAtTopEdge), [
isAtBottomEdge,
isAtTopEdge,
]);
const autoscrollParams = [
distToTopEdge,
distToBottomEdge,
scrollOffset,
isScrolledUp,
isScrolledDown,
];
const isScrolledDown = useDerivedValue(() => {
return (
outerScrollOffset.value + containerSize.value + SCROLL_POSITION_TOLERANCE >=
scrollViewSize.value
);
}, []);
const targetScrollOffset = useValue<number>(0);
const resolveAutoscroll = useRef<(params: readonly number[]) => void>();
const distToTopEdge = useDerivedValue(() => {
return Math.max(0, hoverScreenOffset.value);
}, [hoverScreenOffset]);
const isAutoScrollInProgressNative = useValue<number>(0);
const distToBottomEdge = useDerivedValue(() => {
const dist = containerSize.value - (hoverScreenOffset.value + activeCellSize.value)
return Math.max(0, dist);
}, [hoverScreenOffset, activeCellSize, containerSize]);
const isAutoScrollInProgress = useRef({
js: false,
native: isAutoScrollInProgressNative,
const isAtTopEdge = useDerivedValue(() => {
return distToTopEdge.value <= autoscrollThreshold;
}, []);
const isAtBottomEdge = useDerivedValue(() => {
return distToBottomEdge.value <= autoscrollThreshold;
});
const isDraggingCellJS = useRef(false);
useCode(
() =>
block([
onChange(
isDraggingCell,
call([isDraggingCell], ([v]) => {
isDraggingCellJS.current = !!v;
})
),
]),
[isDraggingCell]
);
const scrollTarget = useSharedValue(0);
// Ensure that only 1 call to autoscroll is active at a time
const autoscrollLooping = useRef(false);
const onAutoscrollComplete = (params: readonly number[]) => {
isAutoScrollInProgress.current.js = false;
resolveAutoscroll.current?.(params);
};
const scrollToAsync = (offset: number): Promise<readonly number[]> =>
new Promise((resolve) => {
resolveAutoscroll.current = resolve;
targetScrollOffset.setValue(offset);
isAutoScrollInProgress.current.native.setValue(1);
isAutoScrollInProgress.current.js = true;
scrollableRef.current?.scrollTo?.({ y: offset });
});
const getScrollTargetOffset = (
distFromTop: number,
distFromBottom: number,
scrollOffset: number,
isScrolledUp: boolean,
isScrolledDown: boolean
) => {
if (isAutoScrollInProgress.current.js) return -1;
const scrollUp = distFromTop < autoscrollThreshold!;
const scrollDown = distFromBottom < autoscrollThreshold!;
if (
!(scrollUp || scrollDown) ||
(scrollUp && isScrolledUp) ||
(scrollDown && isScrolledDown)
)
return -1;
const distFromEdge = scrollUp ? distFromTop : distFromBottom;
const speedPct = 1 - distFromEdge / autoscrollThreshold!;
const offset = speedPct * autoscrollSpeed;
const targetOffset = scrollUp
? Math.max(0, scrollOffset - offset)
: scrollOffset + offset;
return targetOffset;
};
const autoscroll = async (params: readonly number[]) => {
if (autoscrollLooping.current) {
return;
}
autoscrollLooping.current = true;
try {
let shouldScroll = true;
let curParams = params;
while (shouldScroll) {
const [
distFromTop,
distFromBottom,
scrollOffset,
isScrolledUp,
isScrolledDown,
] = curParams;
const targetOffset = getScrollTargetOffset(
distFromTop,
distFromBottom,
scrollOffset,
!!isScrolledUp,
!!isScrolledDown
);
const scrollingUpAtTop = !!(
isScrolledUp && targetOffset <= scrollOffset
);
const scrollingDownAtBottom = !!(
isScrolledDown && targetOffset >= scrollOffset
);
shouldScroll =
targetOffset >= 0 &&
isDraggingCellJS.current &&
!scrollingUpAtTop &&
!scrollingDownAtBottom;
if (shouldScroll) {
try {
curParams = await scrollToAsync(targetOffset);
} catch (err) {}
}
useAnimatedReaction(
() => {
return isDraggingCell.value;
},
(cur, prev) => {
if (cur && !prev) {
scrollTarget.value = outerScrollOffset.value;
}
} finally {
autoscrollLooping.current = false;
},
[activeCellSize]
);
function scrollToInternal(y: number) {
scrollableRef.current?.scrollTo({ y, animated: true });
}
useDerivedValue(() => {
const isAtEdge = isAtTopEdge.value || isAtBottomEdge.value;
const topDisabled = isAtTopEdge.value && isScrolledUp.value;
const bottomDisabled = isAtBottomEdge.value && isScrolledDown.value;
const isEdgeDisabled = topDisabled || bottomDisabled;
const scrollTargetDiff = Math.abs(scrollTarget.value - outerScrollOffset.value);
const scrollInProgress = scrollTargetDiff > SCROLL_POSITION_TOLERANCE;
const shouldScroll =
isAtEdge &&
!isEdgeDisabled &&
isDraggingCell.value &&
isTouchActiveNative.value &&
!scrollInProgress;
const distFromEdge = isAtTopEdge.value
? distToTopEdge.value
: distToBottomEdge.value;
const speedPct = 1 - distFromEdge / autoscrollThreshold;
const offset = speedPct * autoscrollSpeed;
const targetOffset = isAtTopEdge.value
? Math.max(0, outerScrollOffset.value - offset)
: outerScrollOffset.value + offset;
if (shouldScroll) {
scrollTarget.value = targetOffset;
// Reanimated scrollTo is crashing on android. use 'regular' scrollTo until figured out.
// scrollTo(scrollViewRef, 0, scrollTarget.value, true)
runOnJS(scrollToInternal)(targetOffset);
}
};
}, [autoscrollSpeed, autoscrollThreshold, isDraggingCell]);
const checkAutoscroll = useNodeAlt(
cond(
and(
isAtEdge,
not(and(isAtTopEdge, isScrolledUp)),
not(and(isAtBottomEdge, isScrolledDown)),
eq(panGestureState, GestureState.ACTIVE),
not(isAutoScrollInProgress.current.native)
),
call(autoscrollParams, autoscroll)
),
[
isAtEdge,
isAtTopEdge,
isScrolledUp,
isAtBottomEdge,
isScrolledDown,
panGestureState,
]
);
const onScrollNode = useNodeAlt(
cond(
and(
isAutoScrollInProgress.current.native,
or(
// We've scrolled to where we want to be
lessOrEq(
abs(sub(targetScrollOffset, scrollOffset)),
SCROLL_POSITION_TOLERANCE
),
// We're at the start, but still want to scroll farther up
and(isScrolledUp, lessOrEq(targetScrollOffset, scrollOffset)),
// We're at the end, but still want to scroll further down
and(isScrolledDown, greaterOrEq(targetScrollOffset, scrollOffset))
)
),
[
// Finish scrolling
set(isAutoScrollInProgress.current.native, 0),
call(autoscrollParams, onAutoscrollComplete),
]
),
[
targetScrollOffset,
scrollOffset,
isScrolledUp,
isScrolledDown,
isAutoScrollInProgress.current.native,
]
);
useCode(() => checkAutoscroll, [hoverAnim]);
useCode(() => onChange(scrollOffset, onScrollNode), [hoverAnim]);
return onScrollNode;
return null;
}

View File

@ -1,10 +0,0 @@
import { useRef } from "react";
import Animated from "react-native-reanimated";
export function useNode<T>(node: Animated.Node<T>) {
const ref = useRef<Animated.Node<T> | null>(null);
if (ref.current === null) {
ref.current = node;
}
return ref.current;
}

View File

@ -1,53 +1,37 @@
import { useRef } from "react";
import Animated, {
block,
clockRunning,
cond,
onChange,
set,
startClock,
stopClock,
useCode,
useDerivedValue,
withSpring,
WithSpringConfig,
} from "react-native-reanimated";
import { DEFAULT_ANIMATION_CONFIG } from "../constants";
import { useAnimatedValues } from "../context/animatedValueContext";
import { useIsActive } from "../context/cellContext";
import { springFill } from "../procs";
import { useSpring } from "./useSpring";
type Params = {
animationConfig: Partial<Animated.SpringConfig>;
animationConfig: Partial<WithSpringConfig>;
};
export function useOnCellActiveAnimation(
{ animationConfig }: Params = { animationConfig: {} }
) {
const { clock, state, config } = useSpring({ config: animationConfig });
const animationConfigRef = useRef(animationConfig);
animationConfigRef.current = animationConfig;
const { isDraggingCell } = useAnimatedValues();
const isActive = useIsActive();
useCode(
() =>
block([
onChange(isDraggingCell, [
//@ts-ignore
set(config.toValue, cond(isDraggingCell, 1, 0)),
startClock(clock),
]),
cond(clockRunning(clock), [
springFill(clock, state, config),
cond(state.finished, [
stopClock(clock),
set(state.finished, 0),
set(state.time, 0),
set(state.velocity, 0),
]),
]),
]),
[]
);
const { isTouchActiveNative } = useAnimatedValues();
const onActiveAnim = useDerivedValue(() => {
const toVal = isActive && isTouchActiveNative.value ? 1 : 0;
return withSpring(toVal, {
...DEFAULT_ANIMATION_CONFIG,
...animationConfigRef.current,
});
}, [isActive]);
return {
isActive,
onActiveAnim: state.position,
onActiveAnim,
};
}

View File

@ -1,47 +0,0 @@
import { useMemo } from "react";
import Animated, { Clock, useValue } from "react-native-reanimated";
import { DEFAULT_ANIMATION_CONFIG } from "../constants";
type Params = {
config: Partial<Animated.SpringConfig>;
};
export function useSpring(
{ config: configParam }: Params = { config: DEFAULT_ANIMATION_CONFIG }
) {
const toValue = useValue<number>(0);
const clock = useMemo(() => new Clock(), []);
const finished = useValue<number>(0);
const velocity = useValue<number>(0);
const position = useValue<number>(0);
const time = useValue<number>(0);
const state = useMemo(
() => ({
finished,
velocity,
position,
time,
}),
[finished, velocity, position, time]
);
const config = useMemo(
() => ({
...DEFAULT_ANIMATION_CONFIG,
...configParam,
toValue,
}),
[configParam, toValue]
) as Animated.SpringConfig;
return useMemo(
() => ({
clock,
state,
config,
}),
[clock, state, config]
);
}

View File

@ -0,0 +1,18 @@
import { useRef, useCallback } from "react";
// Utility hook that returns a function that never has stale dependencies, but
// without changing identity, as a useCallback with dep array would.
// Useful for functions that depend on external state, but
// should not trigger effects when that external state changes.
export function useStableCallback<T extends (a?: any, b?: any, c?: any) => any>(
fn: T
) {
const fnRef = useRef(fn);
fnRef.current = fn;
const identityRetainingFn = useCallback(
(...args: Parameters<T>) => fnRef.current(...args),
[]
);
return identityRetainingFn as T;
}

View File

@ -1,288 +0,0 @@
import Animated, {
clockRunning,
not,
startClock,
stopClock,
} from "react-native-reanimated";
import { isWeb } from "./constants";
const {
set,
cond,
add,
sub,
block,
eq,
neq,
and,
divide,
greaterThan,
greaterOrEq,
Value,
spring,
lessThan,
lessOrEq,
multiply,
} = Animated;
if (!Animated.proc) {
throw new Error("Incompatible Reanimated version (proc not found)");
}
// clock procs don't seem to work in web, not sure if there's a perf benefit to web procs anyway?
const proc = isWeb ? <T>(cb: T) => cb : Animated.proc;
export const getIsAfterActive = proc(
(currentIndex: Animated.Node<number>, activeIndex: Animated.Node<number>) =>
greaterThan(currentIndex, activeIndex)
);
export const hardReset = proc(
(
position: Animated.Value<number>,
finished: Animated.Value<number>,
time: Animated.Value<number>,
toValue: Animated.Value<number>
) =>
block([set(position, 0), set(finished, 0), set(time, 0), set(toValue, 0)])
);
/**
* The in react-native-reanimated.d.ts definition of `proc` only has generics
* for up to 10 arguments. We cast it to accept any params to avoid errors when
* type-checking.
*/
type RetypedProc = (cb: (...params: any) => Animated.Node<number>) => typeof cb;
export const setupCell = proc(
(
currentIndex: Animated.Value<number>,
size: Animated.Node<number>,
offset: Animated.Node<number>,
isAfterActive: Animated.Value<number>,
prevToValue: Animated.Value<number>,
prevSpacerIndex: Animated.Value<number>,
activeIndex: Animated.Node<number>,
activeCellSize: Animated.Node<number>,
hoverOffset: Animated.Node<number>,
spacerIndex: Animated.Value<number>,
toValue: Animated.Value<number>,
position: Animated.Value<number>,
time: Animated.Value<number>,
finished: Animated.Value<number>,
runSpring: Animated.Node<number>,
onFinished: Animated.Node<number>,
isDraggingCell: Animated.Node<number>,
placeholderOffset: Animated.Value<number>,
prevIsDraggingCell: Animated.Value<number>,
clock: Animated.Clock,
disabled: Animated.Node<number>
) =>
block([
cond(
greaterThan(activeIndex, -1),
[
// Only update spacer if touch is not disabled.
// Fixes android bugs where state would update with invalid touch values on touch end.
cond(not(disabled), [
// Determine whether this cell is after the active cell in the list
set(isAfterActive, getIsAfterActive(currentIndex, activeIndex)),
// Determining spacer index is hard to visualize, see diagram: https://i.imgur.com/jRPf5t3.jpg
cond(
isAfterActive,
[
cond(
and(
greaterOrEq(add(hoverOffset, activeCellSize), offset),
lessThan(
add(hoverOffset, activeCellSize),
add(offset, divide(size, 2))
)
),
set(spacerIndex, sub(currentIndex, 1))
),
cond(
and(
greaterOrEq(
add(hoverOffset, activeCellSize),
add(offset, divide(size, 2))
),
lessThan(
add(hoverOffset, activeCellSize),
add(offset, size)
)
),
set(spacerIndex, currentIndex)
),
],
cond(lessThan(currentIndex, activeIndex), [
cond(
and(
lessThan(hoverOffset, add(offset, size)),
greaterOrEq(hoverOffset, add(offset, divide(size, 2)))
),
set(spacerIndex, add(currentIndex, 1))
),
cond(
and(
greaterOrEq(hoverOffset, offset),
lessThan(hoverOffset, add(offset, divide(size, 2)))
),
set(spacerIndex, currentIndex)
),
])
),
// Set placeholder offset
cond(eq(spacerIndex, currentIndex), [
set(
placeholderOffset,
cond(
isAfterActive,
add(sub(offset, activeCellSize), size),
offset
)
),
]),
]),
cond(
eq(currentIndex, activeIndex),
[
// If this cell is the active cell
cond(
isDraggingCell,
[
// Set its position to the drag position
set(position, sub(hoverOffset, offset)),
],
[
// Active item, not pressed in
// Set value hovering element will snap to once released
cond(prevIsDraggingCell, [
set(toValue, sub(placeholderOffset, offset)),
// The clock starts automatically when toValue changes, however, we need to handle the
// case where the item should snap back to its original location and toValue doesn't change
cond(eq(prevToValue, toValue), [
cond(clockRunning(clock), stopClock(clock)),
set(time, 0),
set(finished, 0),
startClock(clock),
]),
]),
]
),
],
[
// Not the active item
// Translate cell down if it is before active index and active cell has passed it.
// Translate cell up if it is after the active index and active cell has passed it.
set(
toValue,
cond(
cond(
isAfterActive,
lessOrEq(currentIndex, spacerIndex),
greaterOrEq(currentIndex, spacerIndex)
),
cond(
isAfterActive,
multiply(activeCellSize, -1),
activeCellSize
),
0
)
),
]
),
// If this cell should animate somewhere new, reset its state and start its clock
cond(neq(toValue, prevToValue), [
cond(clockRunning(clock), stopClock(clock)),
set(time, 0),
set(finished, 0),
startClock(clock),
]),
cond(neq(prevSpacerIndex, spacerIndex), [
cond(eq(spacerIndex, -1), [
// Hard reset to prevent stale state bugs
cond(clockRunning(clock), stopClock(clock)),
hardReset(position, finished, time, toValue),
]),
]),
cond(finished, [onFinished, set(time, 0), set(finished, 0)]),
set(prevSpacerIndex, spacerIndex),
set(prevToValue, toValue),
set(prevIsDraggingCell, isDraggingCell),
cond(clockRunning(clock), runSpring),
],
[
// // Reset the spacer index when drag ends
cond(neq(spacerIndex, -1), set(spacerIndex, -1)),
cond(neq(position, 0), set(position, 0)),
]
),
position,
])
);
const betterSpring = (proc as RetypedProc)(
(
finished: Animated.Value<number>,
velocity: Animated.Value<number>,
position: Animated.Value<number>,
time: Animated.Value<number>,
prevPosition: Animated.Value<number>,
toValue: Animated.Value<number>,
damping: Animated.Value<number>,
mass: Animated.Value<number>,
stiffness: Animated.Value<number>,
overshootClamping: Animated.SpringConfig["overshootClamping"],
restSpeedThreshold: Animated.Value<number>,
restDisplacementThreshold: Animated.Value<number>,
clock: Animated.Clock
) =>
spring(
clock,
{
finished,
velocity,
position,
time,
// @ts-ignore -- https://github.com/software-mansion/react-native-reanimated/blob/master/src/animations/spring.js#L177
prevPosition,
},
{
toValue,
damping,
mass,
stiffness,
overshootClamping,
restDisplacementThreshold,
restSpeedThreshold,
}
)
);
export function springFill(
clock: Animated.Clock,
state: Animated.SpringState,
config: Animated.SpringConfig
) {
return betterSpring(
state.finished,
state.velocity,
state.position,
state.time,
new Value(0),
config.toValue,
config.damping,
config.mass,
config.stiffness,
config.overshootClamping,
config.restSpeedThreshold,
config.restDisplacementThreshold,
clock
);
}

View File

@ -1,8 +1,14 @@
import React from "react";
import { FlatListProps, StyleProp, ViewStyle } from "react-native";
import {
FlatListProps,
LayoutChangeEvent,
StyleProp,
ViewPropTypes,
ViewStyle,
} from "react-native";
import { useAnimatedValues } from "./context/animatedValueContext";
import { FlatList } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
import Animated, { WithSpringConfig } from "react-native-reanimated";
import { DEFAULT_PROPS } from "./constants";
export type DragEndParams<T> = {
@ -19,7 +25,7 @@ export type DraggableFlatListProps<T> = Modify<
{
data: T[];
activationDistance?: number;
animationConfig?: Partial<Animated.SpringConfig>;
animationConfig?: Partial<WithSpringConfig>;
autoscrollSpeed?: number;
autoscrollThreshold?: number;
containerStyle?: StyleProp<ViewStyle>;
@ -34,8 +40,12 @@ export type DraggableFlatListProps<T> = Modify<
renderItem: RenderItem<T>;
renderPlaceholder?: RenderPlaceholder<T>;
simultaneousHandlers?: React.Ref<any> | React.Ref<any>[];
outerScrollOffset?: Animated.Node<number>;
outerScrollOffset?: Animated.SharedValue<number>;
onAnimValInit?: (animVals: ReturnType<typeof useAnimatedValues>) => void;
onContainerLayout?: (params: {
layout: LayoutChangeEvent["nativeEvent"]["layout"];
containerRef: React.RefObject<Animated.View>;
}) => void;
} & Partial<DefaultProps>
>;
@ -46,7 +56,7 @@ export type RenderPlaceholder<T> = (params: {
export type RenderItemParams<T> = {
item: T;
index?: number; // This is technically a "last known index" since cells don't necessarily rerender when their index changes
getIndex: () => number | undefined; // This is technically a "last known index" since cells don't necessarily rerender when their index changes
drag: () => void;
isActive: boolean;
};

View File

@ -7,8 +7,9 @@ jest.mock("react-native-reanimated", () =>
require("react-native-reanimated/mock")
);
const DummyFlatList = props => {
const [data, setData] = useState([
const DummyFlatList = (props) => {
const [data] = useState([
{ id: "1", name: "item 1" },
{ id: "2", name: "item 2" }
]);
@ -29,6 +30,7 @@ const DummyFlatList = props => {
};
describe("DraggableFlatList", () => {
const setup = propOverrides => {
const defaultProps = {
...propOverrides

View File

@ -1,15 +1,11 @@
{
"compilerOptions": {
"esModuleInterop": true,
"target": "es5",
"module": "commonjs",
"declaration": true,
"outDir": "./lib",
"strict": true,
"jsx": "react",
"lib": ["esnext"],
"skipLibCheck": true
"skipLibCheck": true,
"strict": true
},
"include": ["src"],
"exclude": ["node_modules"]
"exclude": ["node_modules"],
"include": ["src"]
}

2066
yarn.lock

File diff suppressed because it is too large Load Diff