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:
parent
d8a392bf4a
commit
0937df86d7
2
.npmignore
Normal file
2
.npmignore
Normal file
@ -0,0 +1,2 @@
|
||||
/Example
|
||||
/tests
|
||||
6
Example/.expo-shared/assets.json
Normal file
6
Example/.expo-shared/assets.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"e997a5256149a4b76e6bfd6cbf519c5e5a0f1d278a3d8fa1253022b03c90473b": true,
|
||||
"af683c96e0ffd2cf81287651c9433fa44debc1220ca7cb431fe482747f34a505": true,
|
||||
"12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
|
||||
"40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
|
||||
}
|
||||
14
Example/.gitignore
vendored
Normal file
14
Example/.gitignore
vendored
Normal 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
22
Example/App.tsx
Normal 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
32
Example/app.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Example/assets/fonts/SpaceMono-Regular.ttf
Executable file
BIN
Example/assets/fonts/SpaceMono-Regular.ttf
Executable file
Binary file not shown.
BIN
Example/assets/images/adaptive-icon.png
Normal file
BIN
Example/assets/images/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
Example/assets/images/favicon.png
Normal file
BIN
Example/assets/images/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
Example/assets/images/icon.png
Normal file
BIN
Example/assets/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
Example/assets/images/splash.png
Normal file
BIN
Example/assets/images/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
7
Example/babel.config.js
Normal file
7
Example/babel.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
plugins: ["react-native-reanimated/plugin"],
|
||||
};
|
||||
};
|
||||
84
Example/components/EditScreenInfo.tsx
Normal file
84
Example/components/EditScreenInfo.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
7
Example/components/StyledText.tsx
Normal file
7
Example/components/StyledText.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { Text, TextProps } from "./Themed";
|
||||
|
||||
export function MonoText(props: TextProps) {
|
||||
return (
|
||||
<Text {...props} style={[props.style, { fontFamily: "space-mono" }]} />
|
||||
);
|
||||
}
|
||||
48
Example/components/Themed.tsx
Normal file
48
Example/components/Themed.tsx
Normal 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} />;
|
||||
}
|
||||
10
Example/components/__tests__/StyledText-test.js
Normal file
10
Example/components/__tests__/StyledText-test.js
Normal 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();
|
||||
});
|
||||
19
Example/constants/Colors.ts
Normal file
19
Example/constants/Colors.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
12
Example/constants/Layout.ts
Normal file
12
Example/constants/Layout.ts
Normal 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,
|
||||
};
|
||||
33
Example/hooks/useCachedResources.ts
Normal file
33
Example/hooks/useCachedResources.ts
Normal 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;
|
||||
}
|
||||
11
Example/hooks/useColorScheme.ts
Normal file
11
Example/hooks/useColorScheme.ts
Normal 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
31
Example/metro.config.js
Normal 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,
|
||||
};
|
||||
35
Example/navigation/LinkingConfiguration.ts
Normal file
35
Example/navigation/LinkingConfiguration.ts
Normal 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;
|
||||
128
Example/navigation/index.tsx
Normal file
128
Example/navigation/index.tsx
Normal 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
50
Example/package.json
Normal 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
|
||||
}
|
||||
69
Example/screens/BasicScreen.tsx
Normal file
69
Example/screens/BasicScreen.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
68
Example/screens/HorizontalScreen.tsx
Normal file
68
Example/screens/HorizontalScreen.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
119
Example/screens/NestedScreen.tsx
Normal file
119
Example/screens/NestedScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
41
Example/screens/NotFoundScreen.tsx
Normal file
41
Example/screens/NotFoundScreen.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
169
Example/screens/SwipeableScreen.tsx
Normal file
169
Example/screens/SwipeableScreen.tsx
Normal 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
6
Example/tsconfig.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
40
Example/types.tsx
Normal file
40
Example/types.tsx
Normal 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
17
Example/utils/index.ts
Normal 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
8640
Example/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
4
babel.config.js
Normal file
@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
presets: ["module:metro-react-native-babel-preset"],
|
||||
plugins: ["react-native-reanimated/plugin"],
|
||||
};
|
||||
@ -1,3 +0,0 @@
|
||||
{
|
||||
"presets": ["module:metro-react-native-babel-preset"]
|
||||
}
|
||||
2
jest-setup.js
Normal file
2
jest-setup.js
Normal file
@ -0,0 +1,2 @@
|
||||
// see: https://github.com/software-mansion/react-native-reanimated/issues/1380
|
||||
global.__reanimatedWorkletInit = jest.fn();
|
||||
44
package.json
44
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]}>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>(
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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!"
|
||||
);
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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]
|
||||
);
|
||||
}
|
||||
18
src/hooks/useStableCallback.ts
Normal file
18
src/hooks/useStableCallback.ts
Normal 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;
|
||||
}
|
||||
288
src/procs.ts
288
src/procs.ts
@ -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
|
||||
);
|
||||
}
|
||||
20
src/types.ts
20
src/types.ts
@ -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;
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user