mgcrea / react-native-dnd

Modern and easy-to-use drag&drop library for react-native.
https://mgcrea.github.io/react-native-dnd/
MIT License
135 stars 12 forks source link

allow simultaneous handlers (e.g for ScrollViews) #22

Open thefavey opened 2 months ago

thefavey commented 2 months ago

Merci de partager ton travail Olivier !

I had the same issue as some other users: https://github.com/mgcrea/react-native-dnd/issues/17

I believe this solves the problem of the DndProvider blocking the scrolling behaviour of a ScrollView containing the DndProvider.

Maybe the simultaneous scrolling should only be enabled if activationDelay > 100 (or something)?

thefavey commented 2 months ago

Ok that doesn't quite work here.

Thought it would because it works when modifying the JS. This works:

import React, { forwardRef, useImperativeHandle, useMemo, useRef } from "react";
import { View } from "react-native";
import { Gesture, GestureDetector, State } from "react-native-gesture-handler";
import * as Haptics from "expo-haptics";
import {
  cancelAnimation,
  runOnJS,
  runOnUI,
  useAnimatedReaction,
  useSharedValue,
} from "react-native-reanimated";
import {
  DndContext,
  useSharedPoint,
  animatePointWithSpring,
  applyOffset,
  getDistance,
  includesPoint,
  overlapsRectangle,
} from "@mgcrea/react-native-dnd";

const DndProviderWithSimul = forwardRef(function DndProvider(
  {
    children,
    simultaneousHandlers,
    springConfig = {},
    minDistance = 0,
    activationDelay = 0,
    disabled,
    hapticFeedback,
    onDragEnd,
    onBegin,
    onUpdate,
    onFinalize,
    style,
    debug,
  },
  ref
) {
  const containerRef = useRef(null);
  const draggableLayouts = useSharedValue({});
  const droppableLayouts = useSharedValue({});
  const draggableOptions = useSharedValue({});
  const droppableOptions = useSharedValue({});
  const draggableOffsets = useSharedValue({});
  const draggableRestingOffsets = useSharedValue({});
  const draggableStates = useSharedValue({});
  const draggablePendingId = useSharedValue(null);
  const draggableActiveId = useSharedValue(null);
  const droppableActiveId = useSharedValue(null);
  const draggableActiveLayout = useSharedValue(null);
  const draggableInitialOffset = useSharedPoint(0, 0);
  const draggableContentOffset = useSharedPoint(0, 0);
  const panGestureState = useSharedValue(0);
  const runFeedback = () => {
    if (hapticFeedback) {
      Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
    }
  };
  useAnimatedReaction(
    () => draggableActiveId.value,
    (next, prev) => {
      if (next !== prev) {
        // runOnJS(setActiveId)(next);
      }
      if (next !== null) {
        runOnJS(runFeedback)();
      }
    },
    []
  );
  const contextValue = useRef({
    containerRef,
    draggableLayouts,
    droppableLayouts,
    draggableOptions,
    droppableOptions,
    draggableOffsets,
    draggableRestingOffsets,
    draggableStates,
    draggablePendingId,
    draggableActiveId,
    droppableActiveId,
    panGestureState,
    draggableInitialOffset,
    draggableActiveLayout,
    draggableContentOffset,
  });
  useImperativeHandle(
    ref,
    () => {
      return {
        draggableLayouts,
        draggableOffsets,
        draggableRestingOffsets,
        draggableActiveId,
      };
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    []
  );
  const panGesture = useMemo(() => {
    const findActiveLayoutId = (point) => {
      "worklet";
      const { x, y } = point;
      const { value: layouts } = draggableLayouts;
      const { value: offsets } = draggableOffsets;
      const { value: options } = draggableOptions;
      for (const [id, layout] of Object.entries(layouts)) {
        // console.log({ [id]: floorLayout(layout.value) });
        const offset = offsets[id];
        const isDisabled = options[id].disabled;
        if (
          !isDisabled &&
          includesPoint(layout.value, {
            x: x - offset.x.value + draggableContentOffset.x.value,
            y: y - offset.y.value + draggableContentOffset.y.value,
          })
        ) {
          return id;
        }
      }
      return null;
    };
    const findDroppableLayoutId = (activeLayout) => {
      "worklet";
      const { value: layouts } = droppableLayouts;
      const { value: options } = droppableOptions;
      for (const [id, layout] of Object.entries(layouts)) {
        // console.log({ [id]: floorLayout(layout.value) });
        const isDisabled = options[id].disabled;
        if (!isDisabled && overlapsRectangle(activeLayout, layout.value)) {
          return id;
        }
      }
      return null;
    };
    // Helpers for delayed activation (eg. long press)
    let timeout = null;
    const clearActiveIdTimeout = () => {
      if (timeout) {
        clearTimeout(timeout);
      }
    };
    const setActiveId = (id, delay) => {
      timeout = setTimeout(() => {
        runOnUI(() => {
          "worklet";
          debug && console.log(`draggableActiveId.value = ${id}`);
          draggableActiveId.value = id;
          draggableStates.value[id].value = "dragging";
        })();
      }, delay);
    };
    const panGesture = Gesture.Pan()
      .onBegin((event) => {
        const { state, x, y } = event;
        debug && console.log("begin", { state, x, y });
        // Gesture is globally disabled
        if (disabled) {
          return;
        }
        // console.log("begin", { state, x, y });
        // Track current state for cancellation purposes
        panGestureState.value = state;
        const { value: layouts } = draggableLayouts;
        const { value: offsets } = draggableOffsets;
        const { value: restingOffsets } = draggableRestingOffsets;
        const { value: options } = draggableOptions;
        const { value: states } = draggableStates;
        // for (const [id, offset] of Object.entries(offsets)) {
        //   console.log({ [id]: [offset.x.value, offset.y.value] });
        // }
        // Find the active layout key under {x, y}
        const activeId = findActiveLayoutId({ x, y });
        // Check if an item was actually selected
        if (activeId !== null) {
          // Record any ongoing current offset as our initial offset for the gesture
          const activeLayout = layouts[activeId].value;
          const activeOffset = offsets[activeId];
          const restingOffset = restingOffsets[activeId];
          const { value: activeState } = states[activeId];
          draggableInitialOffset.x.value = activeOffset.x.value;
          draggableInitialOffset.y.value = activeOffset.y.value;
          // Cancel the ongoing animation if we just reactivated an acting/dragging item
          if (["dragging", "acting"].includes(activeState)) {
            cancelAnimation(activeOffset.x);
            cancelAnimation(activeOffset.y);
            // If not we should reset the resting offset to the current offset value
            // But only if the item is not currently still animating
          } else {
            // active or pending
            // Record current offset as our natural resting offset for the gesture
            restingOffset.x.value = activeOffset.x.value;
            restingOffset.y.value = activeOffset.y.value;
          }
          // Update activeId directly or with an optional delay
          const { activationDelay } = options[activeId];
          if (activationDelay > 0) {
            draggablePendingId.value = activeId;
            draggableStates.value[activeId].value = "pending";
            runOnJS(setActiveId)(activeId, activationDelay);
            // @TODO activeLayout
          } else {
            draggableActiveId.value = activeId;
            draggableActiveLayout.value = applyOffset(activeLayout, {
              x: activeOffset.x.value,
              y: activeOffset.y.value,
            });
            draggableStates.value[activeId].value = "dragging";
          }
          if (onBegin) {
            onBegin(event, { activeId, activeLayout });
          }
        }
      })
      .onUpdate((event) => {
        // console.log(draggableStates.value);
        const { state, translationX, translationY } = event;
        debug && console.log("update", { state, translationX, translationY });
        // Track current state for cancellation purposes
        panGestureState.value = state;
        const { value: activeId } = draggableActiveId;
        const { value: pendingId } = draggablePendingId;
        const { value: options } = draggableOptions;
        const { value: layouts } = draggableLayouts;
        const { value: offsets } = draggableOffsets;
        // const { value: states } = draggableStates;
        if (activeId === null) {
          // Check if we are currently waiting for activation delay
          if (pendingId !== null) {
            const { activationTolerance } = options[pendingId];
            // Check if we've moved beyond the activation tolerance
            const distance = getDistance(translationX, translationY);
            if (distance > activationTolerance) {
              runOnJS(clearActiveIdTimeout)();
              draggablePendingId.value = null;
            }
          }
          // Ignore item-free interactions
          return;
        }
        // Update our active offset to pan the active item
        const activeOffset = offsets[activeId];
        activeOffset.x.value = draggableInitialOffset.x.value + translationX;
        activeOffset.y.value = draggableInitialOffset.y.value + translationY;
        // Check potential droppable candidates
        const activeLayout = layouts[activeId].value;
        draggableActiveLayout.value = applyOffset(activeLayout, {
          x: activeOffset.x.value,
          y: activeOffset.y.value,
        });
        droppableActiveId.value = findDroppableLayoutId(
          draggableActiveLayout.value
        );
        if (onUpdate) {
          onUpdate(event, {
            activeId,
            activeLayout: draggableActiveLayout.value,
          });
        }
      })
      .onFinalize((event) => {
        const { state, velocityX, velocityY } = event;
        debug && console.log("finalize", { state, velocityX, velocityY });
        // Track current state for cancellation purposes
        panGestureState.value = state; // can be `FAILED` or `ENDED`
        const { value: activeId } = draggableActiveId;
        const { value: pendingId } = draggablePendingId;
        const { value: layouts } = draggableLayouts;
        const { value: offsets } = draggableOffsets;
        const { value: restingOffsets } = draggableRestingOffsets;
        const { value: states } = draggableStates;
        // Ignore item-free interactions
        if (activeId === null) {
          // Check if we were currently waiting for activation delay
          if (pendingId !== null) {
            runOnJS(clearActiveIdTimeout)();
            draggablePendingId.value = null;
          }
          return;
        }
        // Reset interaction-related shared state for styling purposes
        draggableActiveId.value = null;
        if (onFinalize) {
          const activeLayout = layouts[activeId].value;
          const activeOffset = offsets[activeId];
          const updatedLayout = applyOffset(activeLayout, {
            x: activeOffset.x.value,
            y: activeOffset.y.value,
          });
          onFinalize(event, { activeId, activeLayout: updatedLayout });
        }
        // Callback
        if (state !== State.FAILED && onDragEnd) {
          const { value: dropActiveId } = droppableActiveId;
          onDragEnd({
            active: draggableOptions.value[activeId],
            over:
              dropActiveId !== null
                ? droppableOptions.value[dropActiveId]
                : null,
          });
        }
        // Reset droppable
        droppableActiveId.value = null;
        // Move back to initial position
        const activeOffset = offsets[activeId];
        const restingOffset = restingOffsets[activeId];
        states[activeId].value = "acting";
        const [targetX, targetY] = [
          restingOffset.x.value,
          restingOffset.y.value,
        ];
        animatePointWithSpring(
          activeOffset,
          [targetX, targetY],
          [
            { ...springConfig, velocity: velocityX },
            { ...springConfig, velocity: velocityY },
          ],
          ([finishedX, finishedY]) => {
            // Cancel if we are interacting again with this item
            if (
              panGestureState.value !== State.END &&
              panGestureState.value !== State.FAILED &&
              states[activeId].value !== "acting"
            ) {
              return;
            }
            states[activeId].value = "resting";
            if (!finishedX || !finishedY) {
              // console.log(`${activeId} did not finish to reach ${targetX.toFixed(2)} ${currentX}`);
            }
            // for (const [id, offset] of Object.entries(offsets)) {
            //   console.log({ [id]: [offset.x.value.toFixed(2), offset.y.value.toFixed(2)] });
            // }
          }
        );
      })
      .withTestId("DndProvider.pan");
    // Duration in milliseconds of the LongPress gesture before Pan is allowed to activate.
    // If the finger is moved during that period, the gesture will fail.
    if (activationDelay > 0) {
      panGesture.activateAfterLongPress(activationDelay);
    }
    // Minimum distance the finger (or multiple finger) need to travel before the gesture activates. Expressed in points.
    if (minDistance > 0) {
      panGesture.minDistance(minDistance);
    }

    return panGesture;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [disabled]);
  return (
    <DndContext.Provider value={contextValue.current}>
      <GestureDetector
        gesture={panGesture}
        simultaneousHandlers={simultaneousHandlers}
      >
        <View
          ref={containerRef}
          collapsable={false}
          style={style}
          testID="view"
        >
          {children}
        </View>
      </GestureDetector>
    </DndContext.Provider>
  );
});

export default DndProviderWithSimul;