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
>;