diff --git a/README.md b/README.md index b4be7f33..9fb61a01 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,38 @@ Cell Decorators are an easy way to add common hover animations. For example, wra `ScaleDecorator`, `ShadowDecorator`, and `OpacityDecorator` are currently exported. Developers may create their own custom decorators using the animated values provided by the `useOnCellActiveAnimation` hook. +## Nesting DraggableFlatLists + +Use an outer `NestableScrollContainer` paired with one or more inner `NestableDraggableFlatList` components to nest multiple separate `DraggableFlatList` components within a single scrollable parent. `NestableScrollContainer` extends a `ScrollView` from `react-native-gesture-handler`, and `NestableDraggableFlatList` shares the same API as a regular `DraggableFlatList`. + +```tsx + +
+ setData1(data)} + /> +
+ setData2(data)} + /> +
+ setData3(data)} + /> + +``` + +![Nested DraggableFlatList demo](https://i.imgur.com/Kv0aj4l.gif) + ## Example Example snack: https://snack.expo.io/@computerjazz/rndfl3
diff --git a/package.json b/package.json index c598bdc5..7abd73f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-draggable-flatlist", - "version": "3.0.4", + "version": "3.0.7", "description": "A drag-and-drop-enabled FlatList component for React Native", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/src/components/DraggableFlatList.tsx b/src/components/DraggableFlatList.tsx index 1275434f..55e0d2d1 100644 --- a/src/components/DraggableFlatList.tsx +++ b/src/components/DraggableFlatList.tsx @@ -35,7 +35,7 @@ import Animated, { sub, } from "react-native-reanimated"; import CellRendererComponent from "./CellRendererComponent"; -import { DEFAULT_PROPS, isReanimatedV2, isWeb } from "../constants"; +import { DEFAULT_PROPS, isWeb } from "../constants"; import PlaceholderItem from "./PlaceholderItem"; import RowItem from "./RowItem"; import ScrollOffsetListener from "./ScrollOffsetListener"; diff --git a/src/components/NestableDraggableFlatList.tsx b/src/components/NestableDraggableFlatList.tsx new file mode 100644 index 00000000..bf45c6ea --- /dev/null +++ b/src/components/NestableDraggableFlatList.tsx @@ -0,0 +1,72 @@ +import React, { useMemo, useRef, useState } from "react"; +import { findNodeHandle, LogBox } from "react-native"; +import Animated, { add } from "react-native-reanimated"; +import DraggableFlatList, { DraggableFlatListProps } from "../index"; +import { useNestableScrollContainerContext } from "../context/nestableScrollContainerContext"; +import { useNestedAutoScroll } from "../hooks/useNestedAutoScroll"; + +export function NestableDraggableFlatList(props: DraggableFlatListProps) { + const hasSuppressedWarnings = useRef(false); + + if (!hasSuppressedWarnings.current) { + LogBox.ignoreLogs([ + "VirtualizedLists should never be nested inside plain ScrollViews with the same orientation because it can break windowing", + ]); // Ignore log notification by message + //@ts-ignore + console.reportErrorsAsExceptions = false; + hasSuppressedWarnings.current = true; + } + + const { + containerRef, + outerScrollOffset, + setOuterScrollEnabled, + } = useNestableScrollContainerContext(); + + const listVerticalOffset = useMemo(() => new Animated.Value(0), []); + const viewRef = useRef(null); + const [animVals, setAnimVals] = useState({}); + + useNestedAutoScroll(animVals); + + const onListContainerLayout = async () => { + const viewNode = viewRef.current; + const nodeHandle = findNodeHandle(containerRef.current); + + const onSuccess = (_x: number, y: number) => { + listVerticalOffset.setValue(y); + }; + const onFail = () => { + console.log("## nested draggable list measure fail"); + }; + //@ts-ignore + viewNode.measureLayout(nodeHandle, onSuccess, onFail); + }; + + return ( + + { + setOuterScrollEnabled(false); + props.onDragBegin?.(...args); + }} + onDragEnd={(...args) => { + props.onDragEnd?.(...args); + setOuterScrollEnabled(true); + }} + onAnimValInit={(animVals) => { + setAnimVals({ + ...animVals, + hoverAnim: add(animVals.hoverAnim, listVerticalOffset), + }); + props.onAnimValInit?.(animVals); + }} + /> + + ); +} diff --git a/src/components/NestableScrollContainer.tsx b/src/components/NestableScrollContainer.tsx new file mode 100644 index 00000000..e407d9f7 --- /dev/null +++ b/src/components/NestableScrollContainer.tsx @@ -0,0 +1,61 @@ +import React, { useMemo } from "react"; +import { NativeScrollEvent, ScrollViewProps } from "react-native"; +import { ScrollView } from "react-native-gesture-handler"; +import Animated, { block, set } from "react-native-reanimated"; +import { + NestableScrollContainerProvider, + useNestableScrollContainerContext, +} from "../context/nestableScrollContainerContext"; + +const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView); + +function NestableScrollContainerInner(props: ScrollViewProps) { + const { + outerScrollOffset, + containerRef, + containerSize, + scrollViewSize, + scrollableRef, + outerScrollEnabled, + } = useNestableScrollContainerContext(); + + const onScroll = useMemo( + () => + Animated.event([ + { + nativeEvent: ({ contentOffset }: NativeScrollEvent) => + block([set(outerScrollOffset, contentOffset.y)]), + }, + ]), + [] + ); + + return ( + { + containerSize.setValue(layout.height); + }} + > + { + scrollViewSize.setValue(h); + props.onContentSizeChange?.(w, h); + }} + scrollEnabled={outerScrollEnabled} + ref={scrollableRef} + scrollEventThrottle={1} + onScroll={onScroll} + /> + + ); +} + +export function NestableScrollContainer(props: ScrollViewProps) { + return ( + + + + ); +} diff --git a/src/constants.ts b/src/constants.ts index af8f4b65..1ec42c36 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -24,6 +24,7 @@ export const DEFAULT_PROPS = { dragHitSlop: 0 as PanGestureHandlerProperties["hitSlop"], activationDistance: 0, dragItemOverflow: false, + outerScrollOffset: new Animated.Value(0), }; export const isIOS = Platform.OS === "ios"; diff --git a/src/context/animatedValueContext.tsx b/src/context/animatedValueContext.tsx index 32aaebe3..99c8d5f8 100644 --- a/src/context/animatedValueContext.tsx +++ b/src/context/animatedValueContext.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from "react"; +import React, { useContext, useEffect, useMemo } from "react"; import Animated, { add, and, @@ -6,14 +6,16 @@ import Animated, { greaterThan, max, min, + onChange, set, sub, + useCode, 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"; +import { DEFAULT_PROPS } from "../constants"; if (!useValue) { throw new Error("Incompatible Reanimated version (useValue not found)"); @@ -73,12 +75,23 @@ function useSetupAnimatedValues() { const scrollOffset = useValue(0); + const outerScrollOffset = + props.outerScrollOffset || DEFAULT_PROPS.outerScrollOffset; + const outerScrollOffsetSnapshot = useValue(0); // Amount any outer scrollview has scrolled since last gesture event. + const outerScrollOffsetDiff = sub( + outerScrollOffset, + outerScrollOffsetSnapshot + ); + const scrollViewSize = useValue(0); const touchCellOffset = useNode(sub(touchInit, activeCellOffset)); const hoverAnimUnconstrained = useNode( - sub(sub(touchAbsolute, activationDistance), touchCellOffset) + add( + outerScrollOffsetDiff, + sub(sub(touchAbsolute, activationDistance), touchCellOffset) + ) ); const hoverAnimConstrained = useNode( @@ -91,6 +104,17 @@ function useSetupAnimatedValues() { const hoverOffset = useNode(add(hoverAnim, scrollOffset)); + useCode( + () => + onChange( + touchAbsolute, + // If the list is being used in "nested" mode (ie. there's an outer scrollview that contains the list) + // then we need a way to track the amound the outer list has auto-scrolled during the current touch position. + set(outerScrollOffsetSnapshot, outerScrollOffset) + ), + [outerScrollOffset] + ); + const placeholderOffset = useValue(0); // Note: this could use a refactor as it combines touch state + cell animation @@ -154,5 +178,9 @@ function useSetupAnimatedValues() { ] ); + useEffect(() => { + props.onAnimValInit?.(value); + }, [value]); + return value; } diff --git a/src/context/nestableScrollContainerContext.tsx b/src/context/nestableScrollContainerContext.tsx new file mode 100644 index 00000000..d62922a5 --- /dev/null +++ b/src/context/nestableScrollContainerContext.tsx @@ -0,0 +1,57 @@ +import React, { useContext, useMemo, useRef, useState } from "react"; +import { ScrollView } from "react-native-gesture-handler"; +import Animated from "react-native-reanimated"; + +type NestableScrollContainerContextVal = ReturnType< + typeof useSetupNestableScrollContextValue +>; +const NestableScrollContainerContext = React.createContext< + NestableScrollContainerContextVal | undefined +>(undefined); + +function useSetupNestableScrollContextValue() { + const [outerScrollEnabled, setOuterScrollEnabled] = useState(true); + const scrollViewSize = useMemo(() => new Animated.Value(0), []); + const scrollableRef = useRef(null); + const outerScrollOffset = useMemo(() => new Animated.Value(0), []); + const containerRef = useRef(null); + const containerSize = useMemo(() => new Animated.Value(0), []); + + const contextVal = useMemo( + () => ({ + outerScrollEnabled, + setOuterScrollEnabled, + outerScrollOffset, + scrollViewSize, + scrollableRef, + containerRef, + containerSize, + }), + [outerScrollEnabled] + ); + + return contextVal; +} + +export function NestableScrollContainerProvider({ + children, +}: { + children: React.ReactNode; +}) { + const contextVal = useSetupNestableScrollContextValue(); + return ( + + {children} + + ); +} + +export function useNestableScrollContainerContext() { + const value = useContext(NestableScrollContainerContext); + if (!value) { + throw new Error( + "useNestableScrollContainerContext must be called from within NestableScrollContainerContext Provider!" + ); + } + return value; +} diff --git a/src/hooks/useNestedAutoScroll.tsx b/src/hooks/useNestedAutoScroll.tsx new file mode 100644 index 00000000..97b9e9cb --- /dev/null +++ b/src/hooks/useNestedAutoScroll.tsx @@ -0,0 +1,278 @@ +import { DependencyList, useRef } from "react"; +import Animated, { + 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 { useNestableScrollContainerContext } from "../context/nestableScrollContainerContext"; +import { SCROLL_POSITION_TOLERANCE } from "../constants"; + +function useNodeAlt(node: Animated.Node, deps: DependencyList = []) { + // NOTE: memoizing currently breaks animations, not sure why + // return useMemo(() => node, deps) + return node; +} + +const DUMMY_VAL = new Animated.Value(0); + +// This is mostly copied over from the main react-native-draggable-flatlist +// useAutoScroll hook with a few notable exceptions: +// - Since Animated.Values are now coming from the caller, +// we won't guarantee they exist and default if not. +// This changes ourĀ useNode implementation since we don't want to store stale nodes. +// - Outer scrollable is a ScrollView, not a FlatList +// TODO: see if we can combine into a single `useAutoScroll()` hook + +export function useNestedAutoScroll({ + activeCellSize = DUMMY_VAL, + autoscrollSpeed = 100, + autoscrollThreshold = 30, + hoverAnim = DUMMY_VAL, + isDraggingCell = DUMMY_VAL, + panGestureState = DUMMY_VAL, +}: { + activeCellSize?: Animated.Node; + autoscrollSpeed?: number; + autoscrollThreshold?: number; + hoverAnim?: Animated.Node; + isDraggingCell?: Animated.Node; + panGestureState?: Animated.Node; +}) { + const { + outerScrollOffset, + containerSize, + scrollableRef, + scrollViewSize, + } = useNestableScrollContainerContext(); + + const scrollOffset = outerScrollOffset; + + const isScrolledUp = useNodeAlt( + lessOrEq(sub(scrollOffset, SCROLL_POSITION_TOLERANCE), 0), + [scrollOffset] + ); + const isScrolledDown = useNodeAlt( + greaterOrEq( + add(scrollOffset, containerSize, SCROLL_POSITION_TOLERANCE), + scrollViewSize + ), + [scrollOffset, containerSize, scrollViewSize] + ); + + const distToTopEdge = useNodeAlt(max(0, sub(hoverAnim, scrollOffset)), [ + hoverAnim, + scrollOffset, + ]); + const distToBottomEdge = useNodeAlt( + max( + 0, + sub(containerSize, add(sub(hoverAnim, scrollOffset), activeCellSize)) + ), + [containerSize, hoverAnim, scrollOffset, activeCellSize] + ); + + const isAtTopEdge = useNodeAlt(lessOrEq(distToTopEdge, autoscrollThreshold), [ + distToTopEdge, + autoscrollThreshold, + ]); + const isAtBottomEdge = useNodeAlt( + lessOrEq(distToBottomEdge, autoscrollThreshold!), + [distToBottomEdge, autoscrollThreshold] + ); + + const isAtEdge = useNodeAlt(or(isAtBottomEdge, isAtTopEdge), [ + 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; + }) + ), + ]), + [isDraggingCell] + ); + + // 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; + + scrollableRef.current?.scrollTo?.({ y: offset }); + }); + + const getScrollTargetOffset = ( + distFromTop: number, + distFromBottom: number, + scrollOffset: number, + isScrolledUp: boolean, + isScrolledDown: boolean + ) => { + if (isAutoScrollInProgress.current.js) return -1; + const scrollUp = distFromTop < autoscrollThreshold!; + const scrollDown = distFromBottom < autoscrollThreshold!; + if ( + !(scrollUp || scrollDown) || + (scrollUp && isScrolledUp) || + (scrollDown && isScrolledDown) + ) + return -1; + const distFromEdge = scrollUp ? distFromTop : distFromBottom; + const speedPct = 1 - distFromEdge / autoscrollThreshold!; + const offset = speedPct * autoscrollSpeed; + const targetOffset = scrollUp + ? Math.max(0, scrollOffset - offset) + : scrollOffset + offset; + return targetOffset; + }; + + const autoscroll = async (params: readonly number[]) => { + if (autoscrollLooping.current) { + return; + } + autoscrollLooping.current = true; + try { + let shouldScroll = true; + let curParams = params; + while (shouldScroll) { + const [ + distFromTop, + distFromBottom, + scrollOffset, + isScrolledUp, + isScrolledDown, + ] = curParams; + const targetOffset = getScrollTargetOffset( + distFromTop, + distFromBottom, + scrollOffset, + !!isScrolledUp, + !!isScrolledDown + ); + const scrollingUpAtTop = !!( + isScrolledUp && targetOffset <= scrollOffset + ); + const scrollingDownAtBottom = !!( + isScrolledDown && targetOffset >= scrollOffset + ); + shouldScroll = + targetOffset >= 0 && + isDraggingCellJS.current && + !scrollingUpAtTop && + !scrollingDownAtBottom; + if (shouldScroll) { + try { + curParams = await scrollToAsync(targetOffset); + } catch (err) {} + } + } + } finally { + autoscrollLooping.current = false; + } + }; + + const checkAutoscroll = useNodeAlt( + cond( + and( + isAtEdge, + not(and(isAtTopEdge, isScrolledUp)), + not(and(isAtBottomEdge, isScrolledDown)), + eq(panGestureState, GestureState.ACTIVE), + not(isAutoScrollInProgress.current.native) + ), + call(autoscrollParams, autoscroll) + ), + [ + isAtEdge, + isAtTopEdge, + isScrolledUp, + isAtBottomEdge, + isScrolledDown, + panGestureState, + ] + ); + + const onScrollNode = useNodeAlt( + cond( + and( + isAutoScrollInProgress.current.native, + or( + // We've scrolled to where we want to be + lessOrEq( + abs(sub(targetScrollOffset, scrollOffset)), + SCROLL_POSITION_TOLERANCE + ), + // We're at the start, but still want to scroll farther up + and(isScrolledUp, lessOrEq(targetScrollOffset, scrollOffset)), + // We're at the end, but still want to scroll further down + and(isScrolledDown, greaterOrEq(targetScrollOffset, scrollOffset)) + ) + ), + [ + // Finish scrolling + set(isAutoScrollInProgress.current.native, 0), + call(autoscrollParams, onAutoscrollComplete), + ] + ), + [ + targetScrollOffset, + scrollOffset, + isScrolledUp, + isScrolledDown, + isAutoScrollInProgress.current.native, + ] + ); + + useCode(() => checkAutoscroll, [hoverAnim]); + useCode(() => onChange(scrollOffset, onScrollNode), [hoverAnim]); + + return onScrollNode; +} diff --git a/src/index.tsx b/src/index.tsx index 1b0bc906..16cd51e6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,6 @@ import DraggableFlatList from "./components/DraggableFlatList"; export * from "./components/CellDecorators"; +export * from "./components/NestableDraggableFlatList"; +export * from "./components/NestableScrollContainer"; export * from "./types"; export default DraggableFlatList; diff --git a/src/types.ts b/src/types.ts index 039c5ea8..9bb66d8d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import React from "react"; import { FlatListProps, StyleProp, ViewStyle } from "react-native"; +import { useAnimatedValues } from "./context/animatedValueContext"; import { FlatList } from "react-native-gesture-handler"; import Animated from "react-native-reanimated"; import { DEFAULT_PROPS } from "./constants"; @@ -33,6 +34,8 @@ export type DraggableFlatListProps = Modify< renderItem: RenderItem; renderPlaceholder?: RenderPlaceholder; simultaneousHandlers?: React.Ref | React.Ref[]; + outerScrollOffset?: Animated.Node; + onAnimValInit?: (animVals: ReturnType) => void; } & Partial >;