Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
wants to merge 1 commit into from
Closed

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

wants to merge 1 commit into from

Conversation

thefavey
Copy link

@thefavey thefavey commented Apr 18, 2024

Merci de partager ton travail Olivier !

I had the same issue as some other users: #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 thefavey marked this pull request as draft April 18, 2024 10:13
@thefavey
Copy link
Author

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;

@thefavey thefavey closed this by deleting the head repository Aug 8, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant