react-dnd / react-dnd

Drag and Drop for React
http://react-dnd.github.io/react-dnd
MIT License
20.98k stars 1.99k forks source link

The elements twitch when swapping places #3562

Open realing29 opened 1 year ago

realing29 commented 1 year ago

Describe the bug The elements twitch when swapping places

Reproduction

https://codesandbox.io/s/github/react-dnd/react-dnd/tree/gh-pages/examples_js/04-sortable/stress-test?from-embed=&file=/src/Card.js:784-792 link on codesandbox from documentation - https://react-dnd.github.io/react-dnd/docs/api/use-drag

Steps to reproduce the behavior:

movement element

Expected behavior A clear and concise description of what you expected to happen.

Screenshots https://drive.google.com/file/d/1A9CmzmiAQILg8zOvw0XrGUAdHqxsNfNJ/view?usp=sharing

Desktop (please complete the following information):

OrionPro commented 1 year ago

I have if the height of an element has changed, for example the text is smaller than others, then when you drag it, it changes position twice, instead of once to move to its position. And all identical elements work without any problems when dragging them.

ilsemjonov commented 8 months ago

@realing29 Hi! Experiencing same issue, have you found any solution?

chekrd commented 8 months ago

Hi, the following two steps were the workaround for us.

1. Switch objects not immediately on collision, but create a smaller region the user must drag into. The red lines are computed relatively to the object corner and cursor position (it is not an invisible element or anything like this): image

The collision detection for square-ish target:

type Inset = 0 | 0.1 | 0.2 | 0.3 | 0.4 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 | 1;

/**
 * Ignores X pixels (defined as percentage) from the edge of the element.
 * The ignored area takes into account element asymmetry, so it is derived from the shortest side.
 * @param targetBoundingRect Droppable element rect
 * @param pointer User's pointer coordinates
 * @param insetRelativeToShorterSide Percentage of ignored pixels from the edge of the droppable element
 */
export function isWithinTargetInset(
  targetBoundingRect: DOMRectLike,
  pointer: XYCoord,
  insetRelativeToShorterSide: Inset,
): boolean {
  /**
   *    targetA ----------------
   *    ¦  insetA -----------  ¦
   *    ¦  ¦                ¦  ¦
   *    ¦  ¦                ¦  ¦
   *    ¦  ----------- insetC  ¦
   *    ---------------- targetC
   */
  const targetA = { x: targetBoundingRect.left, y: targetBoundingRect.top };
  const targetC = { x: targetBoundingRect.right, y: targetBoundingRect.bottom };

  const lengthOfShorterSide = Math.min(targetC.x - targetA.x, targetC.y - targetA.y);
  const ignoredPixelsPerSide = (lengthOfShorterSide * insetRelativeToShorterSide) / 2;

  const insetA = { x: targetA.x + ignoredPixelsPerSide, y: targetA.y + ignoredPixelsPerSide };
  const insetC = { x: targetC.x - ignoredPixelsPerSide, y: targetC.y - ignoredPixelsPerSide };

  return (
    pointer.x > insetA.x && pointer.x < insetC.x && pointer.y > insetA.y && pointer.y < insetC.y
  );
}

Usage:

  // dropTargetRefObject is refObject set to the drop target element
  // targetId is our identifier of the target entity that the drop target element represents

  const [, connectDropTarget] = useDrop<DragObject, never, CollectedProps>({
    hover: (dragObject: DragObject, monitor: DropTargetMonitor) => {
      const targetBoundingRect = dropTargetRefObject.current?.getBoundingClientRect();
      const pointer = monitor.getClientOffset();
      if (isWithinTargetInset(targetBoundingRect, pointer, 0.2)) {
        onMoveItem(dragObject.sourceId, targetId);
      }
    },
    // ...other props
  });

Different strategies must be used based on the target element dimensions.

2. We stopped using custom isDragging callback. Using default one helped, from the docs: https://react-dnd.github.io/react-dnd/docs/api/use-drag

isDragging(monitor): Optional. By default, only the drag source that initiated the drag operation is considered to be dragging. You can override this behavior by defining a custom isDragging method. It might return something like props.id === monitor.getItem().id. Do this if the original component may be unmounted during the dragging and later “resurrected” with a different parent. For example, when moving a card across the lists in a Kanban board, you want it to retain the dragged appearance—even though technically, the component gets unmounted and a different one gets mounted every time you move it to another list. Note: You may not call monitor.isDragging() inside this method.


Adding collision detection for elongated rectangles:

enum Direction {
  Forward = 'Forward',
  Backward = 'Backward',
}

function crossedHalfTargetHeight(
  targetBoundingRect: DOMRectLike,
  clientOffset: XYCoord,
  dragDirection: Direction,
): boolean {
  const middleY = (targetBoundingRect.bottom - targetBoundingRect.top) / 2;
  const offsetY = clientOffset.y - targetBoundingRect.top;

  return (
    (dragDirection === Direction.Forward && offsetY >= middleY) ||
    (dragDirection === Direction.Backward && offsetY <= middleY)
  );
}

const getItemsDistance = <TItem, TKey>(
  items: ReadonlyArray<TItem> | Immutable.List<TItem>,
  fromKey: TKey,
  toKey: TKey,
  getKey: (item: TItem) => TKey,
): number | null => {
  const fromIndex = items.findIndex((item: TItem) => getKey(item) === fromKey);
  const toIndex = items.findIndex((item: TItem) => getKey(item) === toKey);

  if (fromIndex < 0 || toIndex < 0) {
    return null;
  }
  return toIndex - fromIndex;
};

const getItemsDirection = <TItem, TKey>(
  items: Immutable.List<TItem> | ReadonlyArray<TItem>,
  fromKey: TKey,
  toKey: TKey,
  getKey: (item: TItem) => TKey,
): Direction | null => {
  const itemsDistance = getItemsDistance(items, fromKey, toKey, getKey);
  if (!itemsDistance) {
    return null;
  }
  return itemsDistance > 0 ? Direction.Forward : Direction.Backward;
};
// Usage in hover function:
...
hover: (dragObject: DragObject, monitor: DropTargetMonitor) => {
  const targetBoundingRect = dropTargetRefObject.current?.getBoundingClientRect();
  const pointer = monitor.getClientOffset();

  const dragDirection = getItemsDirection(
    items, // an array of all entities
    sourceId, // tragged entity id
    targetId, // hovered entity id
    getId, // a function that creates an id from an entity, for example: (item) => item.id
  );

  if (
    !!dragDirection &&
    crossedHalfTargetHeight(args.targetBoundingRect, args.pointer, dragDirection)
  ) {
    onMoveItem(dragObject.sourceId, targetId);
  }
},