From 0017d7e7cee7f1cedff32fb06f9a204d302d898f Mon Sep 17 00:00:00 2001 From: Daniel Merrill Date: Wed, 29 Sep 2021 12:13:58 -0700 Subject: [PATCH] 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 --- .vscode/settings.json | 3 + README.md | 112 ++- package.json | 3 +- src/components/CellDecorators.tsx | 93 ++ src/components/CellRendererComponent.tsx | 159 +++ src/components/DraggableFlatList.tsx | 435 ++++++++ src/components/PlaceholderItem.tsx | 89 ++ src/components/RowItem.tsx | 62 ++ src/components/ScrollOffsetListener.tsx | 20 + src/constants.ts | 31 + src/context/animatedValueContext.tsx | 165 +++ src/context/cellContext.tsx | 32 + src/context/draggableFlatListContext.tsx | 49 + src/context/propsContext.tsx | 24 + src/context/refContext.tsx | 93 ++ src/hooks/useAutoScroll.tsx | 241 +++++ src/hooks/useCellTranslate.tsx | 107 ++ src/hooks/useNode.tsx | 10 + src/hooks/useOnCellActiveAnimation.ts | 53 + src/hooks/useSpring.tsx | 47 + src/index.tsx | 1169 +--------------------- src/procs.ts | 287 ++++++ src/procs.tsx | 282 ------ src/types.ts | 68 ++ src/utils.ts | 5 + 25 files changed, 2138 insertions(+), 1501 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 src/components/CellDecorators.tsx create mode 100644 src/components/CellRendererComponent.tsx create mode 100644 src/components/DraggableFlatList.tsx create mode 100644 src/components/PlaceholderItem.tsx create mode 100644 src/components/RowItem.tsx create mode 100644 src/components/ScrollOffsetListener.tsx create mode 100644 src/constants.ts create mode 100644 src/context/animatedValueContext.tsx create mode 100644 src/context/cellContext.tsx create mode 100644 src/context/draggableFlatListContext.tsx create mode 100644 src/context/propsContext.tsx create mode 100644 src/context/refContext.tsx create mode 100644 src/hooks/useAutoScroll.tsx create mode 100644 src/hooks/useCellTranslate.tsx create mode 100644 src/hooks/useNode.tsx create mode 100644 src/hooks/useOnCellActiveAnimation.ts create mode 100644 src/hooks/useSpring.tsx create mode 100644 src/procs.ts delete mode 100644 src/procs.tsx create mode 100644 src/types.ts create mode 100644 src/utils.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..25fa621 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/README.md b/README.md index d508565..ba1ea2c 100644 --- a/README.md +++ b/README.md @@ -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>) => void` | Returns underlying Animated FlatList ref. | +| `onRef` | `(ref: DraggableFlatList) => void` | Returns underlying Animated FlatList ref. | | `animationConfig` | `Partial` | 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` | Style of the main component. | | `simultaneousHandlers` | `React.Ref` or `React.Ref[]` | References to other gesture handlers, mainly useful when using this component within a `ScrollView`. See [Cross handler interactions](https://docs.swmansion.com/react-native-gesture-handler/docs/interactions/). | +## Cell Decorators + +Cell Decorators are an easy way to add common hover animations. For example, wrapping `renderItem` in the `` component will automatically scale up the active item while hovering (see example below). + +`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
-Example snack with scale effect on hover: https://snack.expo.io/@computerjazz/rndfl-dragwithhovereffect +Example snack: https://snack.expo.io/@computerjazz/rndfl3
```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) => { - return ( +export default function App() { + const [data, setData] = useState(initialData); + + const renderItem = ({ item, drag, isActive }: RenderItemParams) => { + return ( + - - {item.label} - + {item.label} - ); - }, - [] - ); + + ); + }; return ( - - `draggable-item-${item.key}`} - onDragEnd={({ data }) => setData(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", + }, +}); ``` diff --git a/package.json b/package.json index 4d103a7..e544ecf 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "types": "lib/index.d.ts", "scripts": { "test": "jest", - "build": "tsc" + "build": "tsc", + "typecheck": "tsc --noEmit" }, "husky": { "hooks": { diff --git a/src/components/CellDecorators.tsx b/src/components/CellDecorators.tsx new file mode 100644 index 0000000..f1c9a5b --- /dev/null +++ b/src/components/CellDecorators.tsx @@ -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 ( + + {children} + + ); +}; + +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 {children}; +}; + +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 {children}; +}; diff --git a/src/components/CellRendererComponent.tsx b/src/components/CellRendererComponent.tsx new file mode 100644 index 0000000..a81971e --- /dev/null +++ b/src/components/CellRendererComponent.tsx @@ -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 = { + item: T; + index: number; + children: React.ReactNode; + onLayout: (e: LayoutChangeEvent) => void; +}; + +function CellRendererComponent(props: Props) { + const { item, index, onLayout, children } = props; + + const currentIndexAnim = useValue(index); + + useLayoutEffect(() => { + currentIndexAnim.setValue(index); + }, [index, currentIndexAnim]); + + const viewRef = useRef(null); + const { cellDataRef, propsRef, scrollOffsetRef, containerRef } = useRefs(); + + const { horizontalAnim } = useAnimatedValues(); + const { + activeKey, + keyExtractor, + horizontal, + } = useDraggableFlatListContext(); + + const key = keyExtractor(item, index); + const offset = useValue(-1); + const size = useValue(-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 ( + + + {children} + + + ); +} + +export default typedMemo(CellRendererComponent); diff --git a/src/components/DraggableFlatList.tsx b/src/components/DraggableFlatList.tsx new file mode 100644 index 0000000..1ed7e93 --- /dev/null +++ b/src/components/DraggableFlatList.tsx @@ -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 = Animated.AnimateProps< + FlatListProps & { + ref: React.Ref>; + simultaneousHandlers?: React.Ref | React.Ref[]; + } +>; + +const AnimatedFlatList = (Animated.createAnimatedComponent( + FlatList +) as unknown) as (props: RNGHFlatListProps) => React.ReactElement; + +function DraggableFlatListInner(props: DraggableFlatListProps) { + const { + cellDataRef, + containerRef, + flatlistRef, + isTouchActiveRef, + keyToIndexRef, + panGestureHandlerRef, + propsRef, + scrollOffsetRef, + } = useRefs(); + 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(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 = useCallback( + ({ item, index }) => { + return ( + + ); + }, + [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) => { + 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 ( + + + + { + scrollOffsetRef.current = offset; + props.onScrollOffsetChange?.(offset); + }} + /> + + + + {() => + block([ + onChange( + isTouchActiveRef.current.native, + cond(not(isTouchActiveRef.current.native), onGestureRelease) + ), + ]) + } + + + + + ); +} + +export default function DraggableFlatList(props: DraggableFlatListProps) { + return ( + + + + + + + + ); +} diff --git a/src/components/PlaceholderItem.tsx b/src/components/PlaceholderItem.tsx new file mode 100644 index 0000000..51ddc86 --- /dev/null +++ b/src/components/PlaceholderItem.tsx @@ -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 = { + renderPlaceholder?: RenderPlaceholder; +}; + +function PlaceholderItem({ renderPlaceholder }: Props) { + const { + activeCellSize, + placeholderScreenOffset, + spacerIndexAnim, + } = useAnimatedValues(); + const { keyToIndexRef, propsRef } = useRefs(); + + 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 ( + + {!activeItem || activeIndex === undefined + ? null + : renderPlaceholder?.({ item: activeItem, index: activeIndex })} + + {() => set(translate, placeholderScreenOffset)} + + + ); +} + +export default typedMemo(PlaceholderItem); diff --git a/src/components/RowItem.tsx b/src/components/RowItem.tsx new file mode 100644 index 0000000..f2b4cc5 --- /dev/null +++ b/src/components/RowItem.tsx @@ -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 = { + extraData?: any; + drag: (itemKey: string) => void; + item: T; + renderItem: RenderItem; + itemKey: string; + debug?: boolean; +}; + +function RowItem(props: Props) { + 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 ( + + ); +} + +export default typedMemo(RowItem); + +type InnerProps = { + isActive: boolean; + item: T; + index?: number; + drag: () => void; + renderItem: RenderItem; +}; + +function Inner({ isActive, item, drag, index, renderItem }: InnerProps) { + return renderItem({ isActive, item, drag, index }) as JSX.Element; +} +const MemoizedInner = typedMemo(Inner); diff --git a/src/components/ScrollOffsetListener.tsx b/src/components/ScrollOffsetListener.tsx new file mode 100644 index 0000000..2b98f1f --- /dev/null +++ b/src/components/ScrollOffsetListener.tsx @@ -0,0 +1,20 @@ +import Animated, { call, onChange, useCode } from "react-native-reanimated"; +import { typedMemo } from "../utils"; + +type Props = { + scrollOffset: Animated.Value; + onScrollOffsetChange: (offset: readonly number[]) => void; +}; + +const ScrollOffsetListener = ({ + scrollOffset, + onScrollOffsetChange, +}: Props) => { + useCode( + () => onChange(scrollOffset, call([scrollOffset], onScrollOffsetChange)), + [] + ); + return null; +}; + +export default typedMemo(ScrollOffsetListener); diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..65da1e0 --- /dev/null +++ b/src/constants.ts @@ -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; diff --git a/src/context/animatedValueContext.tsx b/src/context/animatedValueContext.tsx new file mode 100644 index 0000000..ff4c1db --- /dev/null +++ b/src/context/animatedValueContext.tsx @@ -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 | undefined +>(undefined); + +export default function AnimatedValueProvider({ + children, +}: { + children: React.ReactNode; +}) { + const value = useSetupAnimatedValues(); + return ( + + {children} + + ); +} + +export function useAnimatedValues() { + const value = useContext(AnimatedValueContext); + if (!value) { + throw new Error( + "useAnimatedValues must be called from within AnimatedValueProvider!" + ); + } + return value; +} + +function useSetupAnimatedValues() { + const props = useProps(); + const containerSize = useValue(0); + + const touchInit = useValue(0); // Position of initial touch + const activationDistance = useValue(0); // Distance finger travels from initial touch to when dragging begins + const touchAbsolute = useValue(0); // Finger position on screen, relative to container + const panGestureState = useValue(GestureState.UNDETERMINED); + + const isTouchActiveNative = useValue(0); + + const hasMoved = useValue(0); + const disabled = useValue(0); + + const horizontalAnim = useValue(props.horizontal ? 1 : 0); + + const activeIndexAnim = useValue(-1); // Index of hovering cell + const spacerIndexAnim = useValue(-1); // Index of hovered-over cell + + const activeCellSize = useValue(0); // Height or width of acctive cell + const activeCellOffset = useValue(0); // Distance between active cell and edge of container + + const isDraggingCell = useNode( + and(isTouchActiveNative, greaterThan(activeIndexAnim, -1)) + ); + + const scrollOffset = useValue(0); + + const scrollViewSize = useValue(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(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; +} diff --git a/src/context/cellContext.tsx b/src/context/cellContext.tsx new file mode 100644 index 0000000..3d9112f --- /dev/null +++ b/src/context/cellContext.tsx @@ -0,0 +1,32 @@ +import React, { useContext, useMemo } from "react"; + +type CellContextValue = { + isActive: boolean; +}; + +const CellContext = React.createContext( + undefined +); + +type Props = { + isActive: boolean; + children: React.ReactNode; +}; + +export default function CellProvider({ isActive, children }: Props) { + const value = useMemo( + () => ({ + isActive, + }), + [isActive] + ); + return {children}; +} + +export function useIsActive() { + const value = useContext(CellContext); + if (!value) { + throw new Error("useIsActive must be called from within CellProvider!"); + } + return value.isActive; +} diff --git a/src/context/draggableFlatListContext.tsx b/src/context/draggableFlatListContext.tsx new file mode 100644 index 0000000..e77b33e --- /dev/null +++ b/src/context/draggableFlatListContext.tsx @@ -0,0 +1,49 @@ +import React, { useContext, useMemo } from "react"; + +type Props = { + activeKey: string | null; + onDragEnd: ([from, to]: readonly number[]) => void; + keyExtractor: (item: T, index: number) => string; + horizontal: boolean; + children: React.ReactNode; +}; + +type DraggableFlatListContextValue = Omit, "children">; + +const DraggableFlatListContext = React.createContext< + DraggableFlatListContextValue | undefined +>(undefined); + +export default function DraggableFlatListProvider({ + activeKey, + onDragEnd, + keyExtractor, + horizontal, + children, +}: Props) { + const value = useMemo( + () => ({ + activeKey, + keyExtractor, + onDragEnd, + horizontal, + }), + [activeKey, onDragEnd, keyExtractor, horizontal] + ); + + return ( + + {children} + + ); +} + +export function useDraggableFlatListContext() { + const value = useContext(DraggableFlatListContext); + if (!value) { + throw new Error( + "useDraggableFlatListContext must be called within DraggableFlatListProvider" + ); + } + return value as DraggableFlatListContextValue; +} diff --git a/src/context/propsContext.tsx b/src/context/propsContext.tsx new file mode 100644 index 0000000..6e1f32f --- /dev/null +++ b/src/context/propsContext.tsx @@ -0,0 +1,24 @@ +import React, { useContext } from "react"; +import { DraggableFlatListProps } from "../types"; + +const PropsContext = React.createContext< + DraggableFlatListProps | undefined +>(undefined); + +type Props = DraggableFlatListProps & { children: React.ReactNode }; + +export default function PropsProvider({ children, ...props }: Props) { + return ( + {children} + ); +} + +export function useProps() { + const value = useContext(PropsContext) as + | DraggableFlatListProps + | undefined; + if (!value) { + throw new Error("useProps must be called from within PropsProvider!"); + } + return value; +} diff --git a/src/context/refContext.tsx b/src/context/refContext.tsx new file mode 100644 index 0000000..f51782e --- /dev/null +++ b/src/context/refContext.tsx @@ -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 = { + propsRef: React.MutableRefObject>; + animationConfigRef: React.MutableRefObject; + cellDataRef: React.MutableRefObject>; + keyToIndexRef: React.MutableRefObject>; + containerRef: React.RefObject; + flatlistRef: React.RefObject>; + panGestureHandlerRef: React.RefObject; + scrollOffsetRef: React.MutableRefObject; + isTouchActiveRef: React.MutableRefObject<{ + native: Animated.Value; + js: boolean; + }>; +}; +const RefContext = React.createContext | undefined>( + undefined +); + +export default function RefProvider({ + children, +}: { + children: React.ReactNode; +}) { + const value = useSetupRefs(); + return {children}; +} + +export function useRefs() { + const value = useContext(RefContext); + if (!value) { + throw new Error( + "useRefs must be called from within a RefContext.Provider!" + ); + } + return value as RefContextValue; +} + +function useSetupRefs() { + const props = useProps(); + 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()); + const keyToIndexRef = useRef(new Map()); + const containerRef = useRef(null); + const flatlistRef = useRef>(null); + const panGestureHandlerRef = useRef(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; +} diff --git a/src/hooks/useAutoScroll.tsx b/src/hooks/useAutoScroll.tsx new file mode 100644 index 0000000..99e6c06 --- /dev/null +++ b/src/hooks/useAutoScroll.tsx @@ -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(0); + const resolveAutoscroll = useRef<(params: readonly number[]) => void>(); + + const isAutoScrollInProgressNative = useValue(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 => + new Promise((resolve) => { + resolveAutoscroll.current = resolve; + targetScrollOffset.setValue(offset); + isAutoScrollInProgress.current.native.setValue(1); + isAutoScrollInProgress.current.js = true; + + const flatlistNode: FlatList | 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; +} diff --git a/src/hooks/useCellTranslate.tsx b/src/hooks/useCellTranslate.tsx new file mode 100644 index 0000000..0580e7e --- /dev/null +++ b/src/hooks/useCellTranslate.tsx @@ -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; + cellSize: Animated.Value; + cellOffset: Animated.Value; +}; + +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(0); + const prevSpacerIndex = useValue(-1); + const prevIsDraggingCell = useValue(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; +} diff --git a/src/hooks/useNode.tsx b/src/hooks/useNode.tsx new file mode 100644 index 0000000..1c00f4a --- /dev/null +++ b/src/hooks/useNode.tsx @@ -0,0 +1,10 @@ +import { useRef } from "react"; +import Animated from "react-native-reanimated"; + +export function useNode(node: Animated.Node) { + const ref = useRef | null>(null); + if (ref.current === null) { + ref.current = node; + } + return ref.current; +} diff --git a/src/hooks/useOnCellActiveAnimation.ts b/src/hooks/useOnCellActiveAnimation.ts new file mode 100644 index 0000000..3892453 --- /dev/null +++ b/src/hooks/useOnCellActiveAnimation.ts @@ -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; +}; + +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, + }; +} diff --git a/src/hooks/useSpring.tsx b/src/hooks/useSpring.tsx new file mode 100644 index 0000000..1af7ce4 --- /dev/null +++ b/src/hooks/useSpring.tsx @@ -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; +}; + +export function useSpring( + { config: configParam }: Params = { config: DEFAULT_ANIMATION_CONFIG } +) { + const toValue = useValue(0); + const clock = useMemo(() => new Clock(), []); + + const finished = useValue(0); + const velocity = useValue(0); + const position = useValue(0); + const time = useValue(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] + ); +} diff --git a/src/index.tsx b/src/index.tsx index 76e9fd8..1b0bc90 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,1167 +1,4 @@ -import React from "react"; -import { - Platform, - StyleSheet, - FlatListProps, - findNodeHandle, - ViewStyle, - FlatList as RNFlatList, - NativeScrollEvent, - StyleProp, - LayoutChangeEvent, -} from "react-native"; -import { - PanGestureHandler, - State as GestureState, - FlatList, - PanGestureHandlerStateChangeEvent, - PanGestureHandlerGestureEvent, - PanGestureHandlerProperties, -} from "react-native-gesture-handler"; -import Animated from "react-native-reanimated"; -import { springFill, setupCell } from "./procs"; - -const AnimatedFlatList = Animated.createAnimatedComponent(FlatList) as ( - props: Animated.AnimateProps< - FlatListProps & { - ref: React.Ref>; - simultaneousHandlers?: React.Ref | React.Ref[]; - } - > -) => React.ReactElement; - -const { - Value, - abs, - set, - cond, - add, - sub, - event, - block, - eq, - neq, - and, - or, - call, - onChange, - greaterThan, - greaterOrEq, - lessOrEq, - not, - Clock, - clockRunning, - startClock, - stopClock, - spring, - defined, - min, - max, - debug, -} = Animated; - -// Fire onScrollComplete when within this many -// px of target offset -const scrollPositionTolerance = 2; -const defaultAnimationConfig = { - damping: 20, - mass: 0.2, - stiffness: 100, - overshootClamping: false, - restSpeedThreshold: 0.2, - restDisplacementThreshold: 0.2, -}; - -const defaultProps = { - autoscrollThreshold: 30, - autoscrollSpeed: 100, - animationConfig: defaultAnimationConfig, - scrollEnabled: true, - dragHitSlop: 0 as PanGestureHandlerProperties["hitSlop"], - activationDistance: 0, - dragItemOverflow: false, -}; - -type DefaultProps = Readonly; - -export type DragEndParams = { - data: T[]; - from: number; - to: number; -}; - -export type RenderItemParams = { - 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; -}; - -type Modify = Omit & R; -export type DraggableFlatListProps = Modify< - FlatListProps, - { - autoscrollSpeed?: number; - autoscrollThreshold?: number; - data: T[]; - onRef?: (ref: React.RefObject>) => void; - onDragBegin?: (index: number) => void; - onRelease?: (index: number) => void; - onDragEnd?: (params: DragEndParams) => void; - renderItem: (params: RenderItemParams) => React.ReactNode; - renderPlaceholder?: (params: { item: T; index: number }) => React.ReactNode; - animationConfig: Partial; - activationDistance?: number; - debug?: boolean; - layoutInvalidationKey?: string; - onScrollOffsetChange?: (scrollOffset: number) => void; - onPlaceholderIndexChange?: (placeholderIndex: number) => void; - containerStyle?: StyleProp; - dragItemOverflow?: boolean; - simultaneousHandlers?: React.Ref | React.Ref[]; - } & Partial ->; - -type State = { - activeKey: string | null; - hoverComponent: React.ReactNode | null; -}; - -type CellData = { - size: Animated.Value; - offset: Animated.Value; - measurements: { - size: number; - offset: number; - }; - style: Animated.AnimateStyle; - currentIndex: Animated.Value; - onLayout: () => void; - onUnmount: () => void; -}; - -// Run callback on next paint: -// https://stackoverflow.com/questions/26556436/react-after-render-code -function onNextFrame(callback: () => void) { - setTimeout(function () { - requestAnimationFrame(callback); - }); -} - -class DraggableFlatList extends React.Component< - DraggableFlatListProps, - State -> { - state: State = { - activeKey: null, - hoverComponent: null, - }; - - containerRef = React.createRef(); - flatlistRef = React.createRef>(); - panGestureHandlerRef = React.createRef(); - - containerSize = new Value(0); - - touchInit = new Value(0); // Position of initial touch - activationDistance = new Value(0); // Distance finger travels from initial touch to when dragging begins - touchAbsolute = new Value(0); // Finger position on screen, relative to container - panGestureState = new Value(GestureState.UNDETERMINED); - - isPressedIn = { - native: new Value(0), - js: false, - }; - - hasMoved = new Value(0); - disabled = new Value(0); - - activeIndex = new Value(-1); // Index of hovering cell - spacerIndex = new Value(-1); // Index of hovered-over cell - isHovering = greaterThan(this.activeIndex, -1); - - activeCellSize = new Value(0); // Height or width of acctive cell - activeCellOffset = new Value(0); // Distance between active cell and edge of container - - scrollOffset = new Value(0); - scrollViewSize = new Value(0); - isScrolledUp = lessOrEq(sub(this.scrollOffset, scrollPositionTolerance), 0); - isScrolledDown = greaterOrEq( - add(this.scrollOffset, this.containerSize, scrollPositionTolerance), - this.scrollViewSize - ); - - touchCellOffset = sub(this.touchInit, this.activeCellOffset); // Distance between touch point and edge of cell - hoverAnimUnconstrained = sub( - sub(this.touchAbsolute, this.activationDistance), - this.touchCellOffset - ); - hoverAnimConstrained = min( - sub(this.containerSize, this.activeCellSize), - max(0, this.hoverAnimUnconstrained) - ); - - hoverAnim = this.props.dragItemOverflow - ? this.hoverAnimUnconstrained - : this.hoverAnimConstrained; - hoverOffset = add(this.hoverAnim, this.scrollOffset); - - placeholderOffset = new Value(0); - placeholderPos = sub(this.placeholderOffset, this.scrollOffset); - - hoverTo = new Value(0); - hoverClock = new Clock(); - hoverAnimState = { - finished: new Value(0), - velocity: new Value(0), - position: new Value(0), - time: new Value(0), - }; - - hoverAnimConfig = { - ...defaultAnimationConfig, - ...this.props.animationConfig, - toValue: this.hoverTo, - }; - - distToTopEdge = max(0, this.hoverAnim); - distToBottomEdge = max( - 0, - sub(this.containerSize, add(this.hoverAnim, this.activeCellSize)) - ); - - cellAnim = new Map< - string, - { - config: Animated.SpringConfig; - state: Animated.SpringState; - clock: Animated.Clock; - } - >(); - cellData = new Map(); - cellRefs = new Map>(); - - moveEndParams = [this.activeIndex, this.spacerIndex]; - - // Note: this could use a refactor as it combines touch state + cell animation - resetTouchedCell = [ - set(this.touchAbsolute, this.hoverAnimConfig.toValue), - set(this.touchInit, 0), - set(this.activeCellOffset, 0), - set(this.activationDistance, 0), - set(this.hoverAnimState.position, this.hoverAnimConfig.toValue), - set(this.hoverAnimState.time, 0), - set(this.hoverAnimState.finished, 0), - set(this.hoverAnimState.velocity, 0), - set(this.hasMoved, 0), - ]; - - keyToIndex = new Map(); - - isAutoScrollInProgress = { - native: new Value(0), - js: false, - }; - - queue: (() => void | Promise)[] = []; - - static getDerivedStateFromProps(props: DraggableFlatListProps) { - return { - extraData: props.extraData, - }; - } - - static defaultProps = defaultProps; - - constructor(props: DraggableFlatListProps) { - super(props); - const { data, onRef } = props; - data.forEach((item, index) => { - const key = this.keyExtractor(item, index); - this.keyToIndex.set(key, index); - }); - onRef && onRef(this.flatlistRef); - } - - dataKeysHaveChanged = (a: T[], b: T[]) => { - const lengthHasChanged = a.length !== b.length; - if (lengthHasChanged) return true; - - const aKeys = a.map((d, i) => this.keyExtractor(d, i)); - const bKeys = b.map((d, i) => this.keyExtractor(d, i)); - - const sameKeys = aKeys.every((k) => bKeys.includes(k)); - return !sameKeys; - }; - - componentDidUpdate = async ( - prevProps: DraggableFlatListProps, - prevState: State - ) => { - const layoutInvalidationKeyHasChanged = - prevProps.layoutInvalidationKey !== this.props.layoutInvalidationKey; - const dataHasChanged = prevProps.data !== this.props.data; - if (layoutInvalidationKeyHasChanged || dataHasChanged) { - this.props.data.forEach((item, index) => { - const key = this.keyExtractor(item, index); - this.keyToIndex.set(key, index); - }); - // Remeasure on next paint - this.updateCellData(this.props.data); - onNextFrame(this.flushQueue); - - if ( - layoutInvalidationKeyHasChanged || - this.dataKeysHaveChanged(prevProps.data, this.props.data) - ) { - this.queue.push(() => this.measureAll(this.props.data)); - } - } - - if (!prevState.activeKey && this.state.activeKey) { - const index = this.keyToIndex.get(this.state.activeKey); - if (index !== undefined) { - this.spacerIndex.setValue(index); - this.activeIndex.setValue(index); - this.isPressedIn.native.setValue(1); - } - const cellData = this.cellData.get(this.state.activeKey); - if (cellData) { - this.activeCellOffset.setValue(sub(cellData.offset, this.scrollOffset)); - this.activeCellSize.setValue(cellData.measurements.size); - } - } - }; - - flushQueue = async () => { - this.queue.forEach((fn) => fn()); - this.queue = []; - }; - - resetHoverState = () => { - this.activeIndex.setValue(-1); - this.spacerIndex.setValue(-1); - this.touchAbsolute.setValue(0); - this.disabled.setValue(0); - if (this.state.hoverComponent !== null || this.state.activeKey !== null) { - this.setState({ - hoverComponent: null, - activeKey: null, - }); - } - }; - - drag = (hoverComponent: React.ReactNode, activeKey: string) => { - if (this.state.hoverComponent) { - // We can't drag more than one row at a time - // TODO: Put action on queue? - if (this.props.debug) console.log("## Can't set multiple active items"); - } else { - this.isPressedIn.js = true; - - this.setState( - { - activeKey, - hoverComponent, - }, - () => { - const index = this.keyToIndex.get(activeKey); - const { onDragBegin } = this.props; - if (index !== undefined && onDragBegin) { - onDragBegin(index); - } - } - ); - } - }; - - onRelease = ([index]: readonly number[]) => { - const { onRelease } = this.props; - this.isPressedIn.js = false; - onRelease && onRelease(index); - }; - - onDragEnd = ([from, to]: readonly number[]) => { - const { onDragEnd } = this.props; - if (onDragEnd) { - const { data } = this.props; - let newData = [...data]; - if (from !== to) { - newData.splice(from, 1); - newData.splice(to, 0, data[from]); - } - - onDragEnd({ from, to, data: newData }); - } - - const lo = Math.min(from, to) - 1; - const hi = Math.max(from, to) + 1; - for (let i = lo; i < hi; i++) { - this.queue.push(() => { - const item = this.props.data[i]; - if (!item) return; - const key = this.keyExtractor(item, i); - return this.measureCell(key); - }); - } - this.resetHoverState(); - }; - - updateCellData = (data: T[] = []) => - data.forEach((item: T, index: number) => { - const key = this.keyExtractor(item, index); - const cell = this.cellData.get(key); - if (cell) cell.currentIndex.setValue(index); - }); - - setCellData = (key: string, index: number) => { - const clock = new Clock(); - const currentIndex = new Value(index); - - const config = { - ...this.hoverAnimConfig, - toValue: new Value(0), - }; - - const state = { - position: new Value(0), - velocity: new Value(0), - time: new Value(0), - finished: new Value(0), - }; - - this.cellAnim.set(key, { clock, state, config }); - - const initialized = new Value(0); - const size = new Value(0); - const offset = new Value(0); - const isAfterActive = new Value(0); - const translate = new Value(0); - - const runSrping = cond( - clockRunning(clock), - springFill(clock, state, config) - ); - const onHasMoved = startClock(clock); - const onChangeSpacerIndex = cond(clockRunning(clock), stopClock(clock)); - const onFinished = stopClock(clock); - - const prevTrans = new Value(0); - const prevSpacerIndex = new Value(-1); - - const anim = setupCell( - currentIndex, - initialized, - size, - offset, - isAfterActive, - translate, - prevTrans, - prevSpacerIndex, - this.activeIndex, - this.activeCellSize, - this.hoverOffset, - this.scrollOffset, - this.isHovering, - this.hoverTo, - this.hasMoved, - this.spacerIndex, - config.toValue, - state.position, - state.time, - state.finished, - runSrping, - onHasMoved, - onChangeSpacerIndex, - onFinished, - this.isPressedIn.native, - this.placeholderOffset - ); - - const transform = this.props.horizontal - ? [{ translateX: anim }] - : [{ translateY: anim }]; - - const style = { - transform, - }; - - const cellData = { - initialized, - currentIndex, - size, - offset, - style, - onLayout: () => { - if (this.state.activeKey !== key) this.measureCell(key); - }, - onUnmount: () => initialized.setValue(0), - measurements: { - size: 0, - offset: 0, - }, - }; - this.cellData.set(key, cellData); - }; - - measureAll = (data: T[]) => { - data.forEach((d, i) => { - const key = this.keyExtractor(d, i); - this.measureCell(key); - }); - }; - - measureCell = (key: string): Promise => { - return new Promise((resolve) => { - const { horizontal } = this.props; - - const onSuccess = (x: number, y: number, w: number, h: number) => { - const { activeKey } = this.state; - const isHovering = activeKey !== null; - const cellData = this.cellData.get(key); - const thisKeyIndex = this.keyToIndex.get(key); - const activeKeyIndex = activeKey - ? this.keyToIndex.get(activeKey) - : undefined; - const baseOffset = horizontal ? x : y; - let extraOffset = 0; - if ( - thisKeyIndex !== undefined && - activeKeyIndex !== undefined && - activeKey - ) { - const isAfterActive = thisKeyIndex > activeKeyIndex; - const activeCellData = this.cellData.get(activeKey); - if (isHovering && isAfterActive && activeCellData) { - extraOffset = activeCellData.measurements.size; - } - } - - const size = horizontal ? w : h; - const offset = baseOffset + extraOffset; - - if (this.props.debug) - console.log( - `measure key ${key}: width ${w} height ${h} x ${x} y ${y} size ${size} offset ${offset}` - ); - - if (cellData) { - cellData.size.setValue(size); - cellData.offset.setValue(offset); - cellData.measurements.size = size; - cellData.measurements.offset = offset; - } - - // remeasure on next layout if hovering - if (isHovering) this.queue.push(() => this.measureCell(key)); - resolve(); - }; - - const onFail = () => { - if (this.props.debug) console.log("## measureLayout fail!", key); - }; - - const ref = this.cellRefs.get(key); - const viewNode = ref && ref.current && ref.current.getNode(); - const flatListNode = this.flatlistRef.current; - - if (viewNode && flatListNode) { - // @ts-ignore - const nodeHandle = findNodeHandle(flatListNode); - if (nodeHandle) viewNode.measureLayout(nodeHandle, onSuccess, onFail); - } else { - let reason = !ref - ? "no ref" - : !flatListNode - ? "no flatlist node" - : "invalid ref"; - if (this.props.debug) - console.log(`## can't measure ${key} reason: ${reason}`); - this.queue.push(() => this.measureCell(key)); - return resolve(); - } - }); - }; - - keyExtractor = (item: T, index: number) => { - if (this.props.keyExtractor) return this.props.keyExtractor(item, index); - else - throw new Error("You must provide a keyExtractor to DraggableFlatList"); - }; - - onContainerLayout: (event: LayoutChangeEvent) => void = ({ - nativeEvent: { - layout: { width, height }, - }, - }) => { - const { horizontal } = this.props; - this.containerSize.setValue(horizontal ? width : height); - }; - - onListContentSizeChange = (w: number, h: number) => { - this.scrollViewSize.setValue(this.props.horizontal ? w : h); - if (this.props.onContentSizeChange) this.props.onContentSizeChange(w, h); - }; - - targetScrollOffset = new Value(0); - resolveAutoscroll?: (scrollParams: readonly number[]) => void; - - onAutoscrollComplete = (params: readonly number[]) => { - this.isAutoScrollInProgress.js = false; - if (this.resolveAutoscroll) this.resolveAutoscroll(params); - }; - - scrollToAsync = (offset: number): Promise => - new Promise((resolve) => { - this.resolveAutoscroll = resolve; - this.targetScrollOffset.setValue(offset); - this.isAutoScrollInProgress.native.setValue(1); - this.isAutoScrollInProgress.js = true; - const flatlistRef = this.flatlistRef.current; - if (flatlistRef) { - const flatlistNode: FlatList | null = - "scrollToOffset" in flatlistRef - ? flatlistRef - : "getNode" in flatlistRef - ? //@ts-ignore backwards compat - flatlistRef.getNode() - : null; - flatlistNode?.scrollToOffset?.({ offset }); - } - }); - - getScrollTargetOffset = ( - distFromTop: number, - distFromBottom: number, - scrollOffset: number, - isScrolledUp: boolean, - isScrolledDown: boolean - ) => { - if (this.isAutoScrollInProgress.js) return -1; - const { autoscrollThreshold, autoscrollSpeed } = this.props; - 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 = - Platform.OS === "ios" ? autoscrollSpeed! : autoscrollSpeed! / 10; - const offset = speedPct * speed; - const targetOffset = scrollUp - ? Math.max(0, scrollOffset - offset) - : scrollOffset + offset; - return targetOffset; - }; - - // Ensure that only 1 call to autoscroll is active at a time - autoscrollLooping = false; - autoscroll = async (params: readonly number[]) => { - if (this.autoscrollLooping) { - return; - } - this.autoscrollLooping = true; - try { - let shouldScroll = true; - let curParams = params; - while (shouldScroll) { - const [ - distFromTop, - distFromBottom, - scrollOffset, - isScrolledUp, - isScrolledDown, - ] = curParams; - const targetOffset = this.getScrollTargetOffset( - distFromTop, - distFromBottom, - scrollOffset, - !!isScrolledUp, - !!isScrolledDown - ); - const scrollingUpAtTop = !!( - isScrolledUp && targetOffset <= scrollOffset - ); - const scrollingDownAtBottom = !!( - isScrolledDown && targetOffset >= scrollOffset - ); - shouldScroll = - targetOffset >= 0 && - this.isPressedIn.js && - !scrollingUpAtTop && - !scrollingDownAtBottom; - - if (shouldScroll) { - curParams = await this.scrollToAsync(targetOffset); - } - } - } finally { - this.autoscrollLooping = false; - } - }; - - isAtTopEdge = lessOrEq(this.distToTopEdge, this.props.autoscrollThreshold!); - isAtBottomEdge = lessOrEq( - this.distToBottomEdge, - this.props.autoscrollThreshold! - ); - isAtEdge = or(this.isAtBottomEdge, this.isAtTopEdge); - - autoscrollParams = [ - this.distToTopEdge, - this.distToBottomEdge, - this.scrollOffset, - this.isScrolledUp, - this.isScrolledDown, - ]; - - checkAutoscroll = cond( - and( - this.isAtEdge, - not(and(this.isAtTopEdge, this.isScrolledUp)), - not(and(this.isAtBottomEdge, this.isScrolledDown)), - eq(this.panGestureState, GestureState.ACTIVE), - not(this.isAutoScrollInProgress.native) - ), - call(this.autoscrollParams, this.autoscroll) - ); - - onScroll = event([ - { - nativeEvent: ({ contentOffset }: NativeScrollEvent) => - block([ - set( - this.scrollOffset, - this.props.horizontal ? contentOffset.x : contentOffset.y - ), - cond( - and( - this.isAutoScrollInProgress.native, - or( - // We've scrolled to where we want to be - lessOrEq( - abs(sub(this.targetScrollOffset, this.scrollOffset)), - scrollPositionTolerance - ), - // We're at the start, but still want to scroll farther up - and( - this.isScrolledUp, - lessOrEq(this.targetScrollOffset, this.scrollOffset) - ), - // We're at the end, but still want to scroll further down - and( - this.isScrolledDown, - greaterOrEq(this.targetScrollOffset, this.scrollOffset) - ) - ) - ), - [ - // Finish scrolling - set(this.isAutoScrollInProgress.native, 0), - call(this.autoscrollParams, this.onAutoscrollComplete), - ] - ), - ]), - }, - ]); - - onGestureRelease = [ - cond( - this.isHovering, - [ - set(this.disabled, 1), - cond(defined(this.hoverClock), [ - cond(clockRunning(this.hoverClock), [stopClock(this.hoverClock)]), - set(this.hoverAnimState.position, this.hoverAnim), - startClock(this.hoverClock), - ]), - [ - call([this.activeIndex], this.onRelease), - cond( - not(this.hasMoved), - call([this.activeIndex], this.resetHoverState) - ), - ], - ], - [call([this.activeIndex], this.resetHoverState), this.resetTouchedCell] - ), - ]; - - onPanStateChange = event([ - { - nativeEvent: ({ - state, - x, - y, - }: PanGestureHandlerStateChangeEvent["nativeEvent"]) => - cond(and(neq(state, this.panGestureState), not(this.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(this.panGestureState, GestureState.BEGAN) - ) - ), - [ - set(this.touchAbsolute, this.props.horizontal ? x : y), - set(this.touchInit, this.touchAbsolute), - ] - ), - cond(eq(state, GestureState.ACTIVE), [ - set( - this.activationDistance, - sub(this.props.horizontal ? x : y, this.touchInit) - ), - set(this.touchAbsolute, this.props.horizontal ? x : y), - ]), - cond( - neq(this.panGestureState, state), - set(this.panGestureState, state) - ), - cond( - or( - eq(state, GestureState.END), - eq(state, GestureState.CANCELLED), - eq(state, GestureState.FAILED) - ), - this.onGestureRelease - ), - ]), - }, - ]); - - onPanGestureEvent = event([ - { - nativeEvent: ({ x, y }: PanGestureHandlerGestureEvent["nativeEvent"]) => - cond( - and( - this.isHovering, - eq(this.panGestureState, GestureState.ACTIVE), - not(this.disabled) - ), - [ - cond(not(this.hasMoved), set(this.hasMoved, 1)), - [set(this.touchAbsolute, this.props.horizontal ? x : y)], - ] - ), - }, - ]); - - hoverComponentTranslate = cond( - clockRunning(this.hoverClock), - this.hoverAnimState.position, - this.hoverAnim - ); - - hoverComponentOpacity = and( - this.isHovering, - neq(this.panGestureState, GestureState.CANCELLED) - ); - - renderHoverComponent = () => { - const { hoverComponent } = this.state; - const { horizontal } = this.props; - - return ( - - {hoverComponent} - - ); - }; - - renderItem = ({ item, index }: { item: T; index: number }) => { - const key = this.keyExtractor(item, index); - if (index !== this.keyToIndex.get(key)) this.keyToIndex.set(key, index); - const { renderItem } = this.props; - if (!this.cellData.get(key)) this.setCellData(key, index); - const { onUnmount } = this.cellData.get(key) || { - onUnmount: () => { - if (this.props.debug) console.log("## error, no cellData"); - }, - }; - return ( - - ); - }; - - renderOnPlaceholderIndexChange = () => ( - - {() => - block([ - onChange( - this.spacerIndex, - call([this.spacerIndex], ([spacerIndex]) => - this.props.onPlaceholderIndexChange!(spacerIndex) - ) - ), - ]) - } - - ); - - renderPlaceholder = () => { - const { renderPlaceholder, horizontal } = this.props; - const { activeKey } = this.state; - if (!activeKey || !renderPlaceholder) return null; - const activeIndex = this.keyToIndex.get(activeKey); - if (activeIndex === undefined) return null; - const activeItem = this.props.data[activeIndex]; - const translateKey = horizontal ? "translateX" : "translateY"; - const sizeKey = horizontal ? "width" : "height"; - const style = { - ...StyleSheet.absoluteFillObject, - [sizeKey]: this.activeCellSize, - transform: [ - { [translateKey]: this.placeholderPos }, - ] as Animated.AnimatedTransform, - }; - - return ( - - {renderPlaceholder({ item: activeItem, index: activeIndex })} - - ); - }; - - CellRendererComponent = (cellProps: any) => { - const { item, index, children, onLayout } = cellProps; - const { horizontal } = this.props; - const { activeKey } = this.state; - const key = this.keyExtractor(item, index); - if (!this.cellData.get(key)) this.setCellData(key, index); - const cellData = this.cellData.get(key); - if (!cellData) return null; - const { style, onLayout: onCellLayout } = cellData; - let ref = this.cellRefs.get(key); - if (!ref) { - ref = React.createRef(); - this.cellRefs.set(key, ref); - } - const isActiveCell = activeKey === key; - return ( - - - - {children} - - - - ); - }; - - renderDebug() { - return ( - - {() => - block([ - onChange( - this.spacerIndex, - debug("spacerIndex: ", this.spacerIndex) - ), - ]) - } - - ); - } - - onContainerTouchEnd = () => { - this.isPressedIn.native.setValue(0); - }; - - render() { - const { - dragHitSlop, - scrollEnabled, - horizontal, - activationDistance, - onScrollOffsetChange, - renderPlaceholder, - onPlaceholderIndexChange, - containerStyle, - simultaneousHandlers, - } = this.props; - - const { hoverComponent } = this.state; - let dynamicProps = {}; - if (activationDistance) { - const activeOffset = [-activationDistance, activationDistance]; - dynamicProps = horizontal - ? { activeOffsetX: activeOffset } - : { activeOffsetY: activeOffset }; - } - return ( - - - {!!onPlaceholderIndexChange && this.renderOnPlaceholderIndexChange()} - {!!renderPlaceholder && this.renderPlaceholder()} - - {!!hoverComponent && this.renderHoverComponent()} - - {() => - block([ - onChange( - this.isPressedIn.native, - cond(not(this.isPressedIn.native), this.onGestureRelease) - ), - // This onChange handles autoscroll checking BUT it also ensures that - // hover translation is continually evaluated. Removing it causes a flicker. - onChange(this.hoverComponentTranslate, this.checkAutoscroll), - cond(clockRunning(this.hoverClock), [ - spring( - this.hoverClock, - this.hoverAnimState, - this.hoverAnimConfig - ), - cond(eq(this.hoverAnimState.finished, 1), [ - this.resetTouchedCell, - stopClock(this.hoverClock), - call(this.moveEndParams, this.onDragEnd), - set(this.hasMoved, 0), - ]), - ]), - ]) - } - - {onScrollOffsetChange && ( - - {() => - onChange( - this.scrollOffset, - call([this.scrollOffset], ([offset]) => - onScrollOffsetChange(offset) - ) - ) - } - - )} - {!!this.props.debug && this.renderDebug()} - - - ); - } -} - +import DraggableFlatList from "./components/DraggableFlatList"; +export * from "./components/CellDecorators"; +export * from "./types"; export default DraggableFlatList; - -type RowItemProps = { - extraData?: any; - drag: (hoverComponent: React.ReactNode, itemKey: string) => void; - keyToIndex: Map; - item: T; - renderItem: (params: RenderItemParams) => React.ReactNode; - itemKey: string; - onUnmount: () => void; - debug?: boolean; -}; - -class RowItem extends React.PureComponent> { - drag = () => { - const { drag, renderItem, item, keyToIndex, itemKey, debug } = this.props; - const hoverComponent = renderItem({ - isActive: true, - item, - index: keyToIndex.get(itemKey), - drag: () => { - if (debug) - console.log("## attempt to call drag() on hovering component"); - }, - }); - drag(hoverComponent, itemKey); - }; - - componentWillUnmount() { - this.props.onUnmount(); - } - - render() { - const { renderItem, item, keyToIndex, itemKey } = this.props; - return renderItem({ - isActive: false, - item, - index: keyToIndex.get(itemKey), - drag: this.drag, - }); - } -} - -const styles = StyleSheet.create({ - flex: { - flex: 1, - }, - hoverComponentVertical: { - position: "absolute", - left: 0, - right: 0, - }, - hoverComponentHorizontal: { - position: "absolute", - bottom: 0, - top: 0, - }, -}); diff --git a/src/procs.ts b/src/procs.ts new file mode 100644 index 0000000..e565b78 --- /dev/null +++ b/src/procs.ts @@ -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 ? (cb: T) => cb : Animated.proc; + +export const getIsAfterActive = proc( + (currentIndex: Animated.Node, activeIndex: Animated.Node) => + greaterThan(currentIndex, activeIndex) +); + +export const hardReset = proc( + ( + position: Animated.Value, + finished: Animated.Value, + time: Animated.Value, + toValue: Animated.Value + ) => + 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) => typeof cb; + +export const setupCell = proc( + ( + currentIndex: Animated.Value, + size: Animated.Node, + offset: Animated.Node, + isAfterActive: Animated.Value, + prevToValue: Animated.Value, + prevSpacerIndex: Animated.Value, + activeIndex: Animated.Node, + activeCellSize: Animated.Node, + hoverOffset: Animated.Node, + spacerIndex: Animated.Value, + toValue: Animated.Value, + position: Animated.Value, + time: Animated.Value, + finished: Animated.Value, + runSpring: Animated.Node, + onFinished: Animated.Node, + isDraggingCell: Animated.Node, + placeholderOffset: Animated.Value, + prevIsDraggingCell: Animated.Value, + clock: Animated.Clock, + disabled: Animated.Node + ) => + 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, + velocity: Animated.Value, + position: Animated.Value, + time: Animated.Value, + prevPosition: Animated.Value, + toValue: Animated.Value, + damping: Animated.Value, + mass: Animated.Value, + stiffness: Animated.Value, + overshootClamping: Animated.SpringConfig["overshootClamping"], + restSpeedThreshold: Animated.Value, + restDisplacementThreshold: Animated.Value, + 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 + ); +} diff --git a/src/procs.tsx b/src/procs.tsx deleted file mode 100644 index fad01dd..0000000 --- a/src/procs.tsx +++ /dev/null @@ -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 = (cb: T) => cb; - proc = procStub; -} - -export const getIsAfterActive = proc( - (currentIndex: Animated.Node, activeIndex: Animated.Node) => - greaterThan(currentIndex, activeIndex) -); - -export const getCellStart = proc( - ( - isAfterActive: Animated.Node, - offset: Animated.Node, - activeCellSize: Animated.Node, - scrollOffset: Animated.Node - ) => - sub(cond(isAfterActive, sub(offset, activeCellSize), offset), scrollOffset) -); - -export const getOnChangeTranslate = proc( - ( - translate: Animated.Node, - isAfterActive: Animated.Node, - initialized: Animated.Value, - toValue: Animated.Value, - isPressedIn: Animated.Node - ) => - block([ - cond(or(not(isAfterActive), initialized), [], set(initialized, 1)), - cond(isPressedIn, set(toValue, translate)), - ]) -); - -export const hardReset = proc( - ( - position: Animated.Value, - finished: Animated.Value, - time: Animated.Value, - toValue: Animated.Value - ) => - 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) => typeof cb; - -export const setupCell = proc( - ( - currentIndex: Animated.Value, - initialized: Animated.Value, - size: Animated.Node, - offset: Animated.Node, - isAfterActive: Animated.Value, - translate: Animated.Value, - prevTrans: Animated.Value, - prevSpacerIndex: Animated.Value, - activeIndex: Animated.Node, - activeCellSize: Animated.Node, - hoverOffset: Animated.Node, - scrollOffset: Animated.Node, - isHovering: Animated.Node, - hoverTo: Animated.Value, - hasMoved: Animated.Value, - spacerIndex: Animated.Value, - toValue: Animated.Value, - position: Animated.Value, - time: Animated.Value, - finished: Animated.Value, - runSpring: Animated.Node, - onHasMoved: Animated.Node, - onChangeSpacerIndex: Animated.Node, - onFinished: Animated.Node, - isPressedIn: Animated.Node, - placeholderOffset: Animated.Value - ) => - 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, - velocity: Animated.Value, - position: Animated.Value, - time: Animated.Value, - prevPosition: Animated.Value, - toValue: Animated.Value, - damping: Animated.Value, - mass: Animated.Value, - stiffness: Animated.Value, - overshootClamping: Animated.SpringConfig["overshootClamping"], - restSpeedThreshold: Animated.Value, - restDisplacementThreshold: Animated.Value, - 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 - ); -} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..c4705ce --- /dev/null +++ b/src/types.ts @@ -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 = { + data: T[]; + from: number; + to: number; +}; +type Modify = Omit & R; + +type DefaultProps = Readonly; + +export type DraggableFlatListProps = Modify< + FlatListProps, + { + data: T[]; + activationDistance?: number; + animationConfig?: Partial; + autoscrollSpeed?: number; + autoscrollThreshold?: number; + containerStyle?: StyleProp; + debug?: boolean; + dragItemOverflow?: boolean; + keyExtractor: (item: T, index: number) => string; + onDragBegin?: (index: number) => void; + onDragEnd?: (params: DragEndParams) => void; + onPlaceholderIndexChange?: (placeholderIndex: number) => void; + onRef?: (ref: FlatList) => void; + onRelease?: (index: number) => void; + onScrollOffsetChange?: (scrollOffset: number) => void; + renderItem: RenderItem; + renderPlaceholder?: RenderPlaceholder; + simultaneousHandlers?: React.Ref | React.Ref[]; + } & Partial +>; + +export type RenderPlaceholder = (params: { + item: T; + index: number; +}) => JSX.Element; + +export type RenderItemParams = { + 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 = (params: RenderItemParams) => React.ReactNode; + +export type AnimatedFlatListType = ( + props: Animated.AnimateProps< + FlatListProps & { + ref: React.Ref>; + simultaneousHandlers?: React.Ref | React.Ref[]; + } + > +) => React.ReactElement; + +export type CellData = { + measurements: { + size: number; + offset: number; + }; +}; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..7989006 --- /dev/null +++ b/src/utils.ts @@ -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: (c: T) => T = React.memo;