feat(rewrite): convert to function components/hooks, separate concerns (#281)

* refactor to split up files, use hooks

* whoops, use correct files

* fix types

* fix translation bugs

* simplify procs

* rename

* fix issue with items getting stuck

* add cell wrappers

* re-organize state

* nicer decorators

* update types, decorators

* capitalize

* fix types

* onRef

* fix types

* types

* fix android

* exoprt useOnCellActiveAnimation

* memoize, fix some web-only bugs

* fix web measure/layout issues

* fix flicker on web

* export types

* more web fixes

* make backwards compatible with reanimated v1

* fix v2

* update readme

* update readme

* adjust android autoscroll speed

* establish min reanimated version

* fix android jumpiness on autoscroll

* update readme

* defensive getNode

Co-authored-by: computerjazz <hi@danielmerrill.com>
This commit is contained in:
Daniel Merrill 2021-09-29 12:13:58 -07:00 committed by GitHub
parent 65a8e2eee8
commit 0017d7e7ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 2138 additions and 1501 deletions

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}

112
README.md
View File

@ -32,7 +32,7 @@ yarn add react-native-draggable-flatlist
All props are spread onto underlying [FlatList](https://facebook.github.io/react-native/docs/flatlist)
| Name | Type | Description |
| :------------------------- | :---------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| :------------------------- | :---------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- |
| `data` | `T[]` | Items to be rendered. |
| `horizontal` | `boolean` | Orientation of list. |
| `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`). |
@ -43,10 +43,9 @@ All props are spread onto underlying [FlatList](https://facebook.github.io/react
| `onDragEnd` | `(params: { data: T[], from: number, to: number }) => void` | Called after animation has completed. Returns updated ordering of `data` |
| `autoscrollThreshold` | `number` | Distance from edge of container where list begins to autoscroll when dragging. |
| `autoscrollSpeed` | `number` | Determines how fast the list autoscrolls. |
| `onRef` | `(ref: React.RefObject<DraggableFlatList<T>>) => void` | Returns underlying Animated FlatList ref. |
| `onRef` | `(ref: DraggableFlatList<T>) => void` | Returns underlying Animated FlatList ref. |
| `animationConfig` | `Partial<Animated.SpringConfig>` | Configure list animations. See [reanimated spring config](https://github.com/software-mansion/react-native-reanimated/blob/master/react-native-reanimated.d.ts#L112-L120) |
| `activationDistance` | `number` | Distance a finger must travel before the gesture handler activates. Useful when using a draggable list within a TabNavigator so that the list does not capture navigator gestures. |
| `layoutInvalidationKey` | `string` | Changing this value forces a remeasure of all item layouts. Useful if item size/ordering updates after initial mount. |
| `activationDistance` | `number` | Distance a finger must travel before the gesture handler activates. Useful when using a draggable list within a TabNavigator so that the list does not capture navigator gestures. | |
| `onScrollOffsetChange` | `(offset: number) => void` | Called with scroll offset. Stand-in for `onScroll`. |
| `onPlaceholderIndexChange` | `(index: number) => void` | Called when the index of the placeholder changes |
| `dragItemOverflow` | `boolean` | If true, dragged item follows finger beyond list boundary. |
@ -55,82 +54,91 @@ All props are spread onto underlying [FlatList](https://facebook.github.io/react
| `containerStyle` | `StyleProp<ViewStyle>` | Style of the main component. |
| `simultaneousHandlers` | `React.Ref<any>` or `React.Ref<any>[]` | References to other gesture handlers, mainly useful when using this component within a `ScrollView`. See [Cross handler interactions](https://docs.swmansion.com/react-native-gesture-handler/docs/interactions/). |
## Cell Decorators
Cell Decorators are an easy way to add common hover animations. For example, wrapping `renderItem` in the `<ScaleDecorator>` component will automatically scale up the active item while hovering (see example below).
`ScaleDecorator`, `ShadowDecorator`, and `OpacityDecorator` are currently exported. Developers may create their own custom decorators using the animated values provided by the `useOnCellActiveAnimation` hook.
## Example
Example snack: https://snack.expo.io/@computerjazz/rndfl-example <br />
Example snack with scale effect on hover: https://snack.expo.io/@computerjazz/rndfl-dragwithhovereffect
Example snack: https://snack.expo.io/@computerjazz/rndfl3 <br />
```typescript
import React, { useState, useCallback } from "react";
import { View, TouchableOpacity, Text } from "react-native";
import React, { useState } from "react";
import { Text, View, StyleSheet, TouchableOpacity } from "react-native";
import DraggableFlatList, {
RenderItemParams,
ScaleDecorator,
} from "react-native-draggable-flatlist";
const NUM_ITEMS = 10;
function getColor(i: number) {
const multiplier = 255 / (NUM_ITEMS - 1);
const colorVal = i * multiplier;
return `rgb(${colorVal}, ${Math.abs(128 - colorVal)}, ${255 - colorVal})`;
}
const exampleData: Item[] = [...Array(20)].map((d, index) => {
const backgroundColor = getColor(index);
return {
key: `item-${backgroundColor}`,
label: String(index),
backgroundColor
};
});
type Item = {
key: string;
label: string;
height: number;
width: number;
backgroundColor: string;
};
function Example() {
const [data, setData] = useState(exampleData);
const initialData: Item[] = [...Array(NUM_ITEMS)].map((d, index) => {
const backgroundColor = getColor(index);
return {
key: `item-${index}`,
label: String(index) + "",
height: 100,
width: 60 + Math.random() * 40,
backgroundColor,
};
});
const renderItem = useCallback(
({ item, index, drag, isActive }: RenderItemParams<Item>) => {
return (
export default function App() {
const [data, setData] = useState(initialData);
const renderItem = ({ item, drag, isActive }: RenderItemParams<Item>) => {
return (
<ScaleDecorator>
<TouchableOpacity
style={{
height: 100,
backgroundColor: isActive ? "red" : item.backgroundColor,
alignItems: "center",
justifyContent: "center",
}}
onLongPress={drag}
disabled={isActive}
style={[
styles.rowItem,
{ backgroundColor: isActive ? "red" : item.backgroundColor },
]}
>
<Text
style={{
fontWeight: "bold",
color: "white",
fontSize: 32,
}}
>
{item.label}
</Text>
<Text style={styles.text}>{item.label}</Text>
</TouchableOpacity>
);
},
[]
);
</ScaleDecorator>
);
};
return (
<View style={{ flex: 1 }}>
<DraggableFlatList
data={data}
renderItem={renderItem}
keyExtractor={(item, index) => `draggable-item-${item.key}`}
onDragEnd={({ data }) => setData(data)}
/>
</View>
<DraggableFlatList
data={data}
onDragEnd={({ data }) => setData(data)}
keyExtractor={(item) => item.key}
renderItem={renderItem}
/>
);
}
export default Example;
const styles = StyleSheet.create({
rowItem: {
height: 100,
width: 100,
alignItems: "center",
justifyContent: "center",
},
text: {
color: "white",
fontSize: 24,
fontWeight: "bold",
textAlign: "center",
},
});
```

View File

@ -6,7 +6,8 @@
"types": "lib/index.d.ts",
"scripts": {
"test": "jest",
"build": "tsc"
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"husky": {
"hooks": {

View File

@ -0,0 +1,93 @@
import React from "react";
import Animated, {
interpolate,
interpolateNode,
multiply,
} from "react-native-reanimated";
import { useNode } from "../hooks/useNode";
export { useOnCellActiveAnimation } from "../hooks/useOnCellActiveAnimation";
import { useOnCellActiveAnimation } from "../hooks/useOnCellActiveAnimation";
type ScaleProps = {
activeScale?: number;
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 scale = isActive ? animScale : 1;
return (
<Animated.View
style={{ transform: [{ scaleX: scale }, { scaleY: scale }] }}
>
{children}
</Animated.View>
);
};
type ShadowProps = {
children: React.ReactNode;
elevation?: number;
radius?: number;
color?: string;
opacity?: number;
};
export const ShadowDecorator = ({
elevation = 10,
color = "black",
opacity = 0.25,
radius = 5,
children,
}: ShadowProps) => {
const { isActive, onActiveAnim } = useOnCellActiveAnimation();
const shadowOpacity = useNode(multiply(onActiveAnim, opacity));
const style = {
elevation: isActive ? elevation : 0,
shadowRadius: isActive ? radius : 0,
shadowColor: isActive ? color : "transparent",
shadowOpacity: isActive ? shadowOpacity : 0,
};
return <Animated.View style={style}>{children}</Animated.View>;
};
type OpacityProps = {
activeOpacity?: number;
children: React.ReactNode;
};
export const OpacityDecorator = ({
activeOpacity = 0.25,
children,
}: OpacityProps) => {
const { isActive, onActiveAnim } = useOnCellActiveAnimation();
const opacity = useNode(
interpolateFn(onActiveAnim, {
inputRange: [0, 1],
outputRange: [1, activeOpacity],
})
);
const style = {
opacity: isActive ? opacity : 1,
};
return <Animated.View style={style}>{children}</Animated.View>;
};

View File

@ -0,0 +1,159 @@
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react";
import {
findNodeHandle,
LayoutChangeEvent,
MeasureLayoutOnSuccessCallback,
} from "react-native";
import Animated, { cond, useValue } from "react-native-reanimated";
import { useDraggableFlatListContext } from "../context/DraggableFlatListContext";
import { isAndroid, isIOS, isReanimatedV2, 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";
type Props<T> = {
item: T;
index: number;
children: React.ReactNode;
onLayout: (e: LayoutChangeEvent) => void;
};
function CellRendererComponent<T>(props: Props<T>) {
const { item, index, onLayout, children } = props;
const currentIndexAnim = useValue(index);
useLayoutEffect(() => {
currentIndexAnim.setValue(index);
}, [index, currentIndexAnim]);
const viewRef = useRef<Animated.View>(null);
const { cellDataRef, propsRef, scrollOffsetRef, containerRef } = useRefs<T>();
const { horizontalAnim } = useAnimatedValues();
const {
activeKey,
keyExtractor,
horizontal,
} = useDraggableFlatListContext<T>();
const key = keyExtractor(item, index);
const offset = useValue<number>(-1);
const size = useValue<number>(-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 isActive = activeKey === key;
const style = useMemo(
() => ({
transform: [
{ translateX: cond(horizontalAnim, translate, 0) },
{ translateY: cond(horizontalAnim, 0, translate) },
],
}),
[horizontalAnim, translate]
);
const updateCellMeasurements = useCallback(() => {
const onSuccess: MeasureLayoutOnSuccessCallback = (x, y, w, h) => {
if (isWeb && horizontal) x += scrollOffsetRef.current;
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);
};
const onFail = () => {
if (propsRef.current?.debug) {
console.log(`## on measure fail, index: ${index}`);
}
};
// 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);
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();
}
}, [index, updateCellMeasurements]);
const onCellLayout = useCallback(
(e: LayoutChangeEvent) => {
updateCellMeasurements();
onLayout(e);
},
[updateCellMeasurements, onLayout]
);
// changing zIndex crashes android:
// https://github.com/facebook/react-native/issues/28751
return (
<Animated.View
{...props}
ref={viewRef}
onLayout={onCellLayout}
style={[
isAndroid && { elevation: isActive ? 1 : 0 },
{ flexDirection: horizontal ? "row" : "column" },
(isWeb || isIOS) && { zIndex: isActive ? 999 : 0 },
]}
pointerEvents={activeKey ? "none" : "auto"}
>
<Animated.View
// Including both animated styles and non-animated styles causes react-native-web
// to ignore updates in non-animated styles. Solution is to separate anima
style={style}
>
<CellProvider isActive={isActive}>{children}</CellProvider>
</Animated.View>
</Animated.View>
);
}
export default typedMemo(CellRendererComponent);

View File

@ -0,0 +1,435 @@
import React, { useCallback, useLayoutEffect, useMemo, useState } from "react";
import {
ListRenderItem,
FlatListProps,
NativeScrollEvent,
NativeSyntheticEvent,
} from "react-native";
import {
PanGestureHandler,
State as GestureState,
FlatList,
PanGestureHandlerGestureEvent,
PanGestureHandlerStateChangeEvent,
} from "react-native-gesture-handler";
import Animated, {
and,
block,
call,
cond,
eq,
event,
greaterThan,
neq,
not,
onChange,
or,
set,
sub,
} from "react-native-reanimated";
import CellRendererComponent from "./CellRendererComponent";
import { DEFAULT_PROPS, isReanimatedV2, 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";
type RNGHFlatListProps<T> = Animated.AnimateProps<
FlatListProps<T> & {
ref: React.Ref<FlatList<T>>;
simultaneousHandlers?: React.Ref<any> | React.Ref<any>[];
}
>;
const AnimatedFlatList = (Animated.createAnimatedComponent(
FlatList
) as unknown) as <T>(props: RNGHFlatListProps<T>) => React.ReactElement;
function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
const {
cellDataRef,
containerRef,
flatlistRef,
isTouchActiveRef,
keyToIndexRef,
panGestureHandlerRef,
propsRef,
scrollOffsetRef,
} = useRefs<T>();
const {
activationDistance,
activeCellOffset,
activeCellSize,
activeIndexAnim,
containerSize,
disabled,
hasMoved,
panGestureState,
resetTouchedCell,
scrollOffset,
scrollViewSize,
spacerIndexAnim,
touchAbsolute,
touchInit,
} = useAnimatedValues();
const {
dragHitSlop = DEFAULT_PROPS.dragHitSlop,
scrollEnabled = DEFAULT_PROPS.scrollEnabled,
activationDistance: activationDistanceProp = DEFAULT_PROPS.activationDistance,
} = props;
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]
);
useLayoutEffect(() => {
props.data.forEach((d, i) => {
const key = keyExtractor(d, i);
keyToIndexRef.current.set(key, i);
});
}, [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);
}
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 autoScrollNode = useAutoScroll();
const onContainerLayout = () => {
const containerNode = isReanimatedV2
? containerRef.current
: containerRef.current?.getNode();
//@ts-ignore
containerNode?.measure((_x, _y, w, h) => {
containerSize.setValue(props.horizontal ? w : h);
});
};
const onListContentSizeChange = (w: number, h: number) => {
scrollViewSize.setValue(props.horizontal ? w : h);
props.onContentSizeChange?.(w, h);
};
const onContainerTouchStart = () => {
isTouchActiveRef.current.js = true;
isTouchActiveRef.current.native.setValue(1);
return false;
};
const onContainerTouchEnd = () => {
isTouchActiveRef.current.js = false;
isTouchActiveRef.current.native.setValue(0);
};
let dynamicProps = {};
if (activationDistanceProp) {
const activeOffset = [-activationDistanceProp, activationDistanceProp];
dynamicProps = props.horizontal
? { activeOffsetX: activeOffset }
: { activeOffsetY: activeOffset };
}
const extraData = useMemo(
() => ({
activeKey,
extraData: props.extraData,
}),
[activeKey, props.extraData]
);
const renderItem: ListRenderItem<T> = useCallback(
({ item, index }) => {
return (
<RowItem
item={item}
itemKey={keyExtractor(item, index)}
renderItem={props.renderItem}
drag={drag}
extraData={props.extraData}
/>
);
},
[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);
props.onRelease?.(index);
};
const onDragEnd = useCallback(
([from, to]: readonly number[]) => {
const { onDragEnd, data } = propsRef.current;
if (onDragEnd) {
const newData = [...data];
if (from !== to) {
newData.splice(from, 1);
newData.splice(to, 0, data[from]);
}
onDragEnd({ from, to, data: newData });
}
resetHoverState();
},
[resetHoverState, propsRef]
);
const onGestureRelease = useNode(
cond(
greaterThan(activeIndexAnim, -1),
[
set(disabled, 1),
set(isTouchActiveRef.current.native, 0),
call([activeIndexAnim], onRelease),
cond(not(hasMoved), call([activeIndexAnim], resetHoverState)),
],
[call([activeIndexAnim], resetHoverState), resetTouchedCell]
)
);
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 onPanGestureEvent = useMemo(
() =>
event([
{
nativeEvent: ({
x,
y,
}: PanGestureHandlerGestureEvent["nativeEvent"]) =>
cond(
and(
greaterThan(activeIndexAnim, -1),
eq(panGestureState, GestureState.ACTIVE),
not(disabled)
),
[
cond(not(hasMoved), set(hasMoved, 1)),
set(touchAbsolute, props.horizontal ? x : y),
]
),
},
]),
[
activeIndexAnim,
disabled,
hasMoved,
panGestureState,
props.horizontal,
touchAbsolute,
]
);
const scrollHandler = useMemo(() => {
// Web doesn't seem to like animated events
const webOnScroll = ({
nativeEvent: {
contentOffset: { x, y },
},
}: NativeSyntheticEvent<NativeScrollEvent>) => {
scrollOffset.setValue(props.horizontal ? x : y);
};
const mobileOnScroll = event([
{
nativeEvent: ({ contentOffset }: NativeScrollEvent) =>
block([
set(
scrollOffset,
props.horizontal ? contentOffset.x : contentOffset.y
),
autoScrollNode,
]),
},
]);
return isWeb ? webOnScroll : mobileOnScroll;
}, [autoScrollNode, props.horizontal, scrollOffset]);
return (
<DraggableFlatListProvider
activeKey={activeKey}
onDragEnd={onDragEnd}
keyExtractor={keyExtractor}
horizontal={!!props.horizontal}
>
<PanGestureHandler
ref={panGestureHandlerRef}
hitSlop={dragHitSlop}
onHandlerStateChange={onPanStateChange}
onGestureEvent={onPanGestureEvent}
simultaneousHandlers={props.simultaneousHandlers}
{...dynamicProps}
>
<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);
}}
/>
<PlaceholderItem renderPlaceholder={props.renderPlaceholder} />
<AnimatedFlatList
{...props}
CellRendererComponent={CellRendererComponent}
ref={flatlistRef}
onContentSizeChange={onListContentSizeChange}
scrollEnabled={!activeKey && scrollEnabled}
renderItem={renderItem}
extraData={extraData}
keyExtractor={keyExtractor}
onScroll={scrollHandler}
scrollEventThrottle={1}
simultaneousHandlers={props.simultaneousHandlers}
removeClippedSubviews={false}
/>
<Animated.Code dependencies={[]}>
{() =>
block([
onChange(
isTouchActiveRef.current.native,
cond(not(isTouchActiveRef.current.native), onGestureRelease)
),
])
}
</Animated.Code>
</Animated.View>
</PanGestureHandler>
</DraggableFlatListProvider>
);
}
export default function DraggableFlatList<T>(props: DraggableFlatListProps<T>) {
return (
<PropsProvider {...props}>
<AnimatedValueProvider>
<RefProvider>
<DraggableFlatListInner {...props} />
</RefProvider>
</AnimatedValueProvider>
</PropsProvider>
);
}

View File

@ -0,0 +1,89 @@
import React, { useCallback } from "react";
import { StyleSheet } from "react-native";
import Animated, {
call,
set,
useCode,
useValue,
onChange,
greaterThan,
cond,
} 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";
type Props<T> = {
renderPlaceholder?: RenderPlaceholder<T>;
};
function PlaceholderItem<T>({ renderPlaceholder }: Props<T>) {
const {
activeCellSize,
placeholderScreenOffset,
spacerIndexAnim,
} = useAnimatedValues();
const { keyToIndexRef, propsRef } = useRefs<T>();
const { activeKey } = useDraggableFlatListContext();
const { horizontal } = useProps();
// for some reason using placeholderScreenOffset directly is buggy
const translate = useValue(0);
const onPlaceholderIndexChange = useCallback(
(index: number) => {
propsRef.current.onPlaceholderIndexChange?.(index);
},
[propsRef]
);
useCode(
() =>
onChange(
spacerIndexAnim,
call([spacerIndexAnim], ([i]) => {
onPlaceholderIndexChange(i);
})
),
[]
);
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]: activeCellSize,
transform: ([
{ [translateKey]: translate },
] as unknown) as Animated.AnimatedTransform,
};
return (
<Animated.View
pointerEvents={activeKey ? "auto" : "none"}
style={[StyleSheet.absoluteFill, animStyle]}
>
{!activeItem || activeIndex === undefined
? null
: renderPlaceholder?.({ item: activeItem, index: activeIndex })}
<Animated.Code>
{() => set(translate, placeholderScreenOffset)}
</Animated.Code>
</Animated.View>
);
}
export default typedMemo(PlaceholderItem);

View File

@ -0,0 +1,62 @@
import React, { useCallback, useRef } from "react";
import { useDraggableFlatListContext } from "../context/DraggableFlatListContext";
import { useRefs } from "../context/RefContext";
import { RenderItem } from "../types";
import { typedMemo } from "../utils";
type Props<T> = {
extraData?: any;
drag: (itemKey: string) => void;
item: T;
renderItem: RenderItem<T>;
itemKey: string;
debug?: boolean;
};
function RowItem<T>(props: Props<T>) {
const propsRef = useRef(props);
propsRef.current = props;
const { activeKey } = useDraggableFlatListContext();
const activeKeyRef = useRef(activeKey);
activeKeyRef.current = activeKey;
const { keyToIndexRef } = useRefs();
const drag = useCallback(() => {
const { drag, itemKey, debug } = propsRef.current;
if (activeKeyRef.current) {
// already dragging an item, noop
if (debug)
console.log(
"## attempt to drag item while another item is already active, noop"
);
}
drag(itemKey);
}, []);
const { renderItem, item, itemKey } = props;
return (
<MemoizedInner
isActive={activeKey === itemKey}
drag={drag}
renderItem={renderItem}
item={item}
index={keyToIndexRef.current.get(itemKey)}
/>
);
}
export default typedMemo(RowItem);
type InnerProps<T> = {
isActive: boolean;
item: T;
index?: number;
drag: () => void;
renderItem: RenderItem<T>;
};
function Inner<T>({ isActive, item, drag, index, renderItem }: InnerProps<T>) {
return renderItem({ isActive, item, drag, index }) as JSX.Element;
}
const MemoizedInner = typedMemo(Inner);

View File

@ -0,0 +1,20 @@
import Animated, { call, onChange, useCode } from "react-native-reanimated";
import { typedMemo } from "../utils";
type Props = {
scrollOffset: Animated.Value<number>;
onScrollOffsetChange: (offset: readonly number[]) => void;
};
const ScrollOffsetListener = ({
scrollOffset,
onScrollOffsetChange,
}: Props) => {
useCode(
() => onChange(scrollOffset, call([scrollOffset], onScrollOffsetChange)),
[]
);
return null;
};
export default typedMemo(ScrollOffsetListener);

31
src/constants.ts Normal file
View File

@ -0,0 +1,31 @@
import { Platform } from "react-native";
import { PanGestureHandlerProperties } from "react-native-gesture-handler";
import Animated, { useSharedValue } from "react-native-reanimated";
// Fire onScrollComplete when within this many px of target offset
export const SCROLL_POSITION_TOLERANCE = 2;
export const DEFAULT_ANIMATION_CONFIG: Animated.WithSpringConfig = {
damping: 20,
mass: 0.2,
stiffness: 100,
overshootClamping: false,
restSpeedThreshold: 0.2,
restDisplacementThreshold: 0.2,
};
export const DEFAULT_PROPS = {
autoscrollThreshold: 30,
autoscrollSpeed: 100,
animationConfig: DEFAULT_ANIMATION_CONFIG,
scrollEnabled: true,
dragHitSlop: 0 as PanGestureHandlerProperties["hitSlop"],
activationDistance: 0,
dragItemOverflow: false,
};
export const isIOS = Platform.OS === "ios";
export const isAndroid = Platform.OS === "android";
export const isWeb = Platform.OS === "web";
// Is there a better way to check for v2?
export const isReanimatedV2 = !!useSharedValue;

View File

@ -0,0 +1,165 @@
import React, { useContext } from "react";
import {
add,
and,
block,
greaterThan,
max,
min,
set,
sub,
useValue,
} from "react-native-reanimated";
import { State as GestureState } from "react-native-gesture-handler";
import { useNode } from "../hooks/useNode";
import { useMemo } from "react";
import { useProps } from "./PropsContext";
if (!useValue) {
throw new Error("Incompatible Reanimated version (useValue not found)");
}
const AnimatedValueContext = React.createContext<
ReturnType<typeof useSetupAnimatedValues> | undefined
>(undefined);
export default function AnimatedValueProvider({
children,
}: {
children: React.ReactNode;
}) {
const value = useSetupAnimatedValues();
return (
<AnimatedValueContext.Provider value={value}>
{children}
</AnimatedValueContext.Provider>
);
}
export function useAnimatedValues() {
const value = useContext(AnimatedValueContext);
if (!value) {
throw new Error(
"useAnimatedValues must be called from within AnimatedValueProvider!"
);
}
return value;
}
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 isTouchActiveNative = useValue<number>(0);
const hasMoved = useValue<number>(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 scrollOffset = useValue<number>(0);
const scrollViewSize = useValue<number>(0);
const touchCellOffset = useNode(sub(touchInit, activeCellOffset));
const hoverAnimUnconstrained = useNode(
sub(sub(touchAbsolute, activationDistance), touchCellOffset)
);
const hoverAnimConstrained = useNode(
min(sub(containerSize, activeCellSize), max(0, hoverAnimUnconstrained))
);
const hoverAnim = props.dragItemOverflow
? hoverAnimUnconstrained
: hoverAnimConstrained;
const hoverOffset = useNode(add(hoverAnim, scrollOffset));
const placeholderOffset = useValue<number>(0);
const placeholderScreenOffset = useNode(sub(placeholderOffset, scrollOffset));
// 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),
set(hasMoved, 0),
])
);
const value = useMemo(
() => ({
activationDistance,
activeCellOffset,
activeCellSize,
activeIndexAnim,
containerSize,
disabled,
hasMoved,
horizontalAnim,
hoverAnim,
hoverAnimConstrained,
hoverAnimUnconstrained,
hoverOffset,
isDraggingCell,
isTouchActiveNative,
panGestureState,
placeholderOffset,
placeholderScreenOffset,
resetTouchedCell,
scrollOffset,
scrollViewSize,
spacerIndexAnim,
touchAbsolute,
touchCellOffset,
touchInit,
}),
[
activationDistance,
activeCellOffset,
activeCellSize,
activeIndexAnim,
containerSize,
disabled,
hasMoved,
horizontalAnim,
hoverAnim,
hoverAnimConstrained,
hoverAnimUnconstrained,
hoverOffset,
isDraggingCell,
isTouchActiveNative,
panGestureState,
placeholderOffset,
placeholderScreenOffset,
resetTouchedCell,
scrollOffset,
scrollViewSize,
spacerIndexAnim,
touchAbsolute,
touchCellOffset,
touchInit,
]
);
return value;
}

View File

@ -0,0 +1,32 @@
import React, { useContext, useMemo } from "react";
type CellContextValue = {
isActive: boolean;
};
const CellContext = React.createContext<CellContextValue | undefined>(
undefined
);
type Props = {
isActive: boolean;
children: React.ReactNode;
};
export default function CellProvider({ isActive, children }: Props) {
const value = useMemo(
() => ({
isActive,
}),
[isActive]
);
return <CellContext.Provider value={value}>{children}</CellContext.Provider>;
}
export function useIsActive() {
const value = useContext(CellContext);
if (!value) {
throw new Error("useIsActive must be called from within CellProvider!");
}
return value.isActive;
}

View File

@ -0,0 +1,49 @@
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;
};
type DraggableFlatListContextValue<T> = Omit<Props<T>, "children">;
const DraggableFlatListContext = React.createContext<
DraggableFlatListContextValue<any> | undefined
>(undefined);
export default function DraggableFlatListProvider<T>({
activeKey,
onDragEnd,
keyExtractor,
horizontal,
children,
}: Props<T>) {
const value = useMemo(
() => ({
activeKey,
keyExtractor,
onDragEnd,
horizontal,
}),
[activeKey, onDragEnd, keyExtractor, horizontal]
);
return (
<DraggableFlatListContext.Provider value={value}>
{children}
</DraggableFlatListContext.Provider>
);
}
export function useDraggableFlatListContext<T>() {
const value = useContext(DraggableFlatListContext);
if (!value) {
throw new Error(
"useDraggableFlatListContext must be called within DraggableFlatListProvider"
);
}
return value as DraggableFlatListContextValue<T>;
}

View File

@ -0,0 +1,24 @@
import React, { useContext } from "react";
import { DraggableFlatListProps } from "../types";
const PropsContext = React.createContext<
DraggableFlatListProps<any> | undefined
>(undefined);
type Props<T> = DraggableFlatListProps<T> & { children: React.ReactNode };
export default function PropsProvider<T>({ children, ...props }: Props<T>) {
return (
<PropsContext.Provider value={props}>{children}</PropsContext.Provider>
);
}
export function useProps<T>() {
const value = useContext(PropsContext) as
| DraggableFlatListProps<T>
| undefined;
if (!value) {
throw new Error("useProps must be called from within PropsProvider!");
}
return value;
}

View File

@ -0,0 +1,93 @@
import React, { useContext, useEffect } from "react";
import { useMemo, useRef } from "react";
import { FlatList, PanGestureHandler } from "react-native-gesture-handler";
import Animated 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>;
cellDataRef: React.MutableRefObject<Map<string, CellData>>;
keyToIndexRef: React.MutableRefObject<Map<string, number>>;
containerRef: React.RefObject<Animated.View>;
flatlistRef: React.RefObject<FlatList<T>>;
panGestureHandlerRef: React.RefObject<PanGestureHandler>;
scrollOffsetRef: React.MutableRefObject<number>;
isTouchActiveRef: React.MutableRefObject<{
native: Animated.Value<number>;
js: boolean;
}>;
};
const RefContext = React.createContext<RefContextValue<any> | undefined>(
undefined
);
export default function RefProvider<T>({
children,
}: {
children: React.ReactNode;
}) {
const value = useSetupRefs<T>();
return <RefContext.Provider value={value}>{children}</RefContext.Provider>;
}
export function useRefs<T>() {
const value = useContext(RefContext);
if (!value) {
throw new Error(
"useRefs must be called from within a RefContext.Provider!"
);
}
return value as RefContextValue<T>;
}
function useSetupRefs<T>() {
const props = useProps<T>();
const { onRef, animationConfig = DEFAULT_PROPS.animationConfig } = props;
const { isTouchActiveNative } = useAnimatedValues();
const propsRef = useRef(props);
propsRef.current = props;
const animConfig = {
...DEFAULT_PROPS.animationConfig,
...animationConfig,
} as Animated.SpringConfig;
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 flatlistRef = useRef<FlatList<T>>(null);
const panGestureHandlerRef = useRef<PanGestureHandler>(null);
const scrollOffsetRef = useRef(0);
const isTouchActiveRef = useRef({
native: isTouchActiveNative,
js: false,
});
useEffect(() => {
if (flatlistRef.current) onRef?.(flatlistRef.current);
}, [onRef]);
const refs = useMemo(
() => ({
animationConfigRef,
cellDataRef,
containerRef,
flatlistRef,
isTouchActiveRef,
keyToIndexRef,
panGestureHandlerRef,
propsRef,
scrollOffsetRef,
}),
[]
);
return refs;
}

241
src/hooks/useAutoScroll.tsx Normal file
View File

@ -0,0 +1,241 @@
import { useRef } from "react";
import {
abs,
add,
and,
block,
call,
cond,
eq,
greaterOrEq,
lessOrEq,
max,
not,
onChange,
or,
set,
sub,
useCode,
useValue,
} from "react-native-reanimated";
import { State as GestureState } from "react-native-gesture-handler";
import {
DEFAULT_PROPS,
SCROLL_POSITION_TOLERANCE,
isReanimatedV2,
isAndroid,
} 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() {
const { flatlistRef } = useRefs();
const {
autoscrollThreshold = DEFAULT_PROPS.autoscrollThreshold,
autoscrollSpeed = DEFAULT_PROPS.autoscrollSpeed,
} = useProps();
const {
scrollOffset,
scrollViewSize,
containerSize,
hoverAnim,
isDraggingCell,
activeCellSize,
panGestureState,
} = useAnimatedValues();
const isScrolledUp = useNode(
lessOrEq(sub(scrollOffset, SCROLL_POSITION_TOLERANCE), 0)
);
const isScrolledDown = useNode(
greaterOrEq(
add(scrollOffset, containerSize, SCROLL_POSITION_TOLERANCE),
scrollViewSize
)
);
const distToTopEdge = useNode(max(0, hoverAnim));
const distToBottomEdge = useNode(
max(0, sub(containerSize, add(hoverAnim, activeCellSize)))
);
const isAtTopEdge = useNode(lessOrEq(distToTopEdge, autoscrollThreshold));
const isAtBottomEdge = useNode(
lessOrEq(distToBottomEdge, autoscrollThreshold!)
);
const isAtEdge = useNode(or(isAtBottomEdge, isAtTopEdge));
const autoscrollParams = [
distToTopEdge,
distToBottomEdge,
scrollOffset,
isScrolledUp,
isScrolledDown,
];
const targetScrollOffset = useValue<number>(0);
const resolveAutoscroll = useRef<(params: readonly number[]) => void>();
const isAutoScrollInProgressNative = useValue<number>(0);
const isAutoScrollInProgress = useRef({
js: false,
native: isAutoScrollInProgressNative,
});
const isDraggingCellJS = useRef(false);
useCode(
() =>
block([
onChange(
isDraggingCell,
call([isDraggingCell], ([v]) => {
isDraggingCellJS.current = !!v;
})
),
]),
[]
);
// 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;
const flatlistNode: FlatList<T> | null =
"scrollToOffset" in flatlistRef
? flatlistRef
: "getNode" in flatlistRef
? //@ts-ignore backwards compat
flatlistRef.getNode()
: null;
flatlistNode?.scrollToOffset?.({ 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!;
// Android scroll speed seems much faster than ios
const speed = isAndroid ? autoscrollSpeed / 10 : autoscrollSpeed;
const offset = speedPct * speed;
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) {}
}
}
} finally {
autoscrollLooping.current = false;
}
};
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;
}

View File

@ -0,0 +1,107 @@
import Animated, {
add,
block,
call,
clockRunning,
cond,
eq,
onChange,
stopClock,
useCode,
useValue,
} 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";
type Params = {
cellIndex: Animated.Value<number>;
cellSize: Animated.Value<number>;
cellOffset: Animated.Value<number>;
};
export function useCellTranslate({ cellIndex, cellSize, cellOffset }: Params) {
const {
activeIndexAnim,
activeCellSize,
hoverAnim,
scrollOffset,
spacerIndexAnim,
placeholderOffset,
isDraggingCell,
resetTouchedCell,
disabled,
} = useAnimatedValues();
const { animationConfigRef } = useRefs();
const { onDragEnd } = useDraggableFlatListContext();
const cellSpring = useSpring({ config: animationConfigRef.current });
const { clock, state, config } = cellSpring;
const isAfterActive = useValue(0);
const isClockRunning = useNode(clockRunning(clock));
const runSpring = useNode(springFill(clock, state, config));
// 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));
const onFinished = useNode(
cond(isClockRunning, [
stopClock(clock),
cond(eq(cellIndex, activeIndexAnim), [
resetTouchedCell,
call([activeIndexAnim, spacerIndexAnim], onDragEnd),
]),
])
);
const prevTrans = useValue<number>(0);
const prevSpacerIndex = useValue<number>(-1);
const prevIsDraggingCell = useValue<number>(0);
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
)
);
// This is required to continually evaluate values
useCode(
() =>
block([
onChange(cellTranslate, []),
onChange(prevTrans, []),
onChange(cellSize, []),
onChange(cellOffset, []),
]),
[]
);
return state.position;
}

10
src/hooks/useNode.tsx Normal file
View File

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

View File

@ -0,0 +1,53 @@
import Animated, {
block,
clockRunning,
cond,
onChange,
set,
startClock,
stopClock,
useCode,
} from "react-native-reanimated";
import { useAnimatedValues } from "../context/AnimatedValueContext";
import { useIsActive } from "../context/CellContext";
import { springFill } from "../procs";
import { useSpring } from "./useSpring";
type Params = {
animationConfig: Partial<Animated.SpringConfig>;
};
export function useOnCellActiveAnimation(
{ animationConfig }: Params = { animationConfig: {} }
) {
const { clock, state, config } = useSpring({ config: 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),
]),
]),
]),
[]
);
return {
isActive,
onActiveAnim: state.position,
};
}

47
src/hooks/useSpring.tsx Normal file
View File

@ -0,0 +1,47 @@
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]
);
}

File diff suppressed because it is too large Load Diff

287
src/procs.ts Normal file
View File

@ -0,0 +1,287 @@
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),
]),
],
[
// Reset the spacer index when drag ends
set(spacerIndex, -1),
set(position, 0),
]
),
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),
position,
])
);
const betterSpring = (proc as RetypedProc)(
(
finished: Animated.Value<number>,
velocity: Animated.Value<number>,
position: Animated.Value<number>,
time: Animated.Value<number>,
prevPosition: Animated.Value<number>,
toValue: Animated.Value<number>,
damping: Animated.Value<number>,
mass: Animated.Value<number>,
stiffness: Animated.Value<number>,
overshootClamping: Animated.SpringConfig["overshootClamping"],
restSpeedThreshold: Animated.Value<number>,
restDisplacementThreshold: Animated.Value<number>,
clock: Animated.Clock
) =>
spring(
clock,
{
finished,
velocity,
position,
time,
// @ts-ignore -- https://github.com/software-mansion/react-native-reanimated/blob/master/src/animations/spring.js#L177
prevPosition,
},
{
toValue,
damping,
mass,
stiffness,
overshootClamping,
restDisplacementThreshold,
restSpeedThreshold,
}
)
);
export function springFill(
clock: Animated.Clock,
state: Animated.SpringState,
config: Animated.SpringConfig
) {
return betterSpring(
state.finished,
state.velocity,
state.position,
state.time,
new Value(0),
config.toValue,
config.damping,
config.mass,
config.stiffness,
config.overshootClamping,
config.restSpeedThreshold,
config.restDisplacementThreshold,
clock
);
}

View File

@ -1,282 +0,0 @@
import Animated from "react-native-reanimated";
const {
or,
set,
cond,
add,
sub,
block,
eq,
neq,
and,
divide,
greaterThan,
greaterOrEq,
not,
Value,
spring,
lessThan,
lessOrEq,
multiply,
} = Animated;
let { proc } = Animated;
if (!proc) {
console.warn("Use reanimated > 1.3 for optimal perf");
const procStub = <T,>(cb: T) => cb;
proc = procStub;
}
export const getIsAfterActive = proc(
(currentIndex: Animated.Node<number>, activeIndex: Animated.Node<number>) =>
greaterThan(currentIndex, activeIndex)
);
export const getCellStart = proc(
(
isAfterActive: Animated.Node<number>,
offset: Animated.Node<number>,
activeCellSize: Animated.Node<number>,
scrollOffset: Animated.Node<number>
) =>
sub(cond(isAfterActive, sub(offset, activeCellSize), offset), scrollOffset)
);
export const getOnChangeTranslate = proc(
(
translate: Animated.Node<number>,
isAfterActive: Animated.Node<number>,
initialized: Animated.Value<number>,
toValue: Animated.Value<number>,
isPressedIn: Animated.Node<number>
) =>
block([
cond(or(not(isAfterActive), initialized), [], set(initialized, 1)),
cond(isPressedIn, set(toValue, translate)),
])
);
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>,
initialized: Animated.Value<number>,
size: Animated.Node<number>,
offset: Animated.Node<number>,
isAfterActive: Animated.Value<number>,
translate: Animated.Value<number>,
prevTrans: Animated.Value<number>,
prevSpacerIndex: Animated.Value<number>,
activeIndex: Animated.Node<number>,
activeCellSize: Animated.Node<number>,
hoverOffset: Animated.Node<number>,
scrollOffset: Animated.Node<number>,
isHovering: Animated.Node<number>,
hoverTo: Animated.Value<number>,
hasMoved: Animated.Value<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>,
onHasMoved: Animated.Node<number>,
onChangeSpacerIndex: Animated.Node<number>,
onFinished: Animated.Node<number>,
isPressedIn: Animated.Node<number>,
placeholderOffset: Animated.Value<number>
) =>
block([
set(isAfterActive, getIsAfterActive(currentIndex, activeIndex)),
// Determining spacer index is hard to visualize.
// see diagram here: https://i.imgur.com/jRPf5t3.jpg
cond(
isPressedIn,
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)
),
])
)
),
// 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.
cond(
neq(currentIndex, activeIndex),
set(
translate,
cond(
cond(
isAfterActive,
lessOrEq(currentIndex, spacerIndex),
greaterOrEq(currentIndex, spacerIndex)
),
cond(
isHovering,
cond(isAfterActive, multiply(activeCellSize, -1), activeCellSize),
0
),
0
)
)
),
// Set value hovering element will snap to once released
cond(
and(isHovering, eq(spacerIndex, currentIndex)),
set(
hoverTo,
sub(
offset,
scrollOffset,
cond(isAfterActive, sub(activeCellSize, size), 0) // Account for cells of differing size
)
)
),
set(toValue, translate),
cond(and(isPressedIn, neq(translate, prevTrans)), [
set(prevTrans, translate),
getOnChangeTranslate(
translate,
isAfterActive,
initialized,
toValue,
isPressedIn
),
cond(hasMoved, onHasMoved, set(position, translate)),
]),
cond(neq(prevSpacerIndex, spacerIndex), [
set(prevSpacerIndex, spacerIndex),
cond(eq(spacerIndex, -1), [
// Hard reset to prevent stale state bugs
onChangeSpacerIndex,
hardReset(position, finished, time, toValue),
]),
]),
runSpring,
cond(finished, [onFinished, set(time, 0), set(finished, 0)]),
cond(
eq(spacerIndex, currentIndex),
set(
placeholderOffset,
cond(isAfterActive, add(sub(offset, activeCellSize), size), offset)
)
),
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
);
}

68
src/types.ts Normal file
View File

@ -0,0 +1,68 @@
import React from "react";
import { FlatListProps, StyleProp, ViewStyle } from "react-native";
import { FlatList } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
import { DEFAULT_PROPS } from "./constants";
export type DragEndParams<T> = {
data: T[];
from: number;
to: number;
};
type Modify<T, R> = Omit<T, keyof R> & R;
type DefaultProps = Readonly<typeof DEFAULT_PROPS>;
export type DraggableFlatListProps<T> = Modify<
FlatListProps<T>,
{
data: T[];
activationDistance?: number;
animationConfig?: Partial<Animated.SpringConfig>;
autoscrollSpeed?: number;
autoscrollThreshold?: number;
containerStyle?: StyleProp<ViewStyle>;
debug?: boolean;
dragItemOverflow?: boolean;
keyExtractor: (item: T, index: number) => string;
onDragBegin?: (index: number) => void;
onDragEnd?: (params: DragEndParams<T>) => void;
onPlaceholderIndexChange?: (placeholderIndex: number) => void;
onRef?: (ref: FlatList<T>) => void;
onRelease?: (index: number) => void;
onScrollOffsetChange?: (scrollOffset: number) => void;
renderItem: RenderItem<T>;
renderPlaceholder?: RenderPlaceholder<T>;
simultaneousHandlers?: React.Ref<any> | React.Ref<any>[];
} & Partial<DefaultProps>
>;
export type RenderPlaceholder<T> = (params: {
item: T;
index: number;
}) => JSX.Element;
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
drag: () => void;
isActive: boolean;
};
export type RenderItem<T> = (params: RenderItemParams<T>) => React.ReactNode;
export type AnimatedFlatListType = <T>(
props: Animated.AnimateProps<
FlatListProps<T> & {
ref: React.Ref<FlatList<T>>;
simultaneousHandlers?: React.Ref<any> | React.Ref<any>[];
}
>
) => React.ReactElement;
export type CellData = {
measurements: {
size: number;
offset: number;
};
};

5
src/utils.ts Normal file
View File

@ -0,0 +1,5 @@
import React from "react";
// Fixes bug with useMemo + generic types:
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/37087#issuecomment-542793243
export const typedMemo: <T>(c: T) => T = React.memo;