3

The snippet below contains an array of 10 items. I'm able to drag and drop the list items and even able to achieve some basic animations when grabbing the list item:

const App = () => {
  const [myArray, setMyArray] = React.useState([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
  const [draggedIndex, setDraggedIndex] = React.useState(-1);

  const onDragStart = (e, index) => {
    setDraggedIndex(index);
    const emptyDiv = document.createElement('div');
    emptyDiv.style.width = '0px';
    emptyDiv.style.height = '0px';
    e.dataTransfer.setDragImage(emptyDiv, 0, 0);
    e.currentTarget.className = 'draggable';
  };

  const onMouseDown = (e) => {
    e.currentTarget.className = 'draggable';
  };
  const onMouseUp = (e) => {
    e.currentTarget.className = 'listItem';
  };

  const onDragOver = (e, index) => {
    e.preventDefault();
    if (draggedIndex === -1 || draggedIndex === index) {
      return;
    }
    let items = myArray.filter((item, i) => i !== draggedIndex);
    items.splice(index, 0, myArray[draggedIndex]);
    setMyArray(items);
    setDraggedIndex(index);
  };

  const onDragEnd = (e) => {
    setDraggedIndex(-1);
    e.target.className = 'listItem';
  };

  return (
    <div className="App">
      {myArray.map((x, i) => (
        <div
          className="listItem"
          draggable
          key={x}
          onDragStart={(e) => onDragStart(e, i)}
          onDragOver={(e) => onDragOver(e, i)}
          onDragEnd={onDragEnd}
          onMouseDown={onMouseDown}
          onMouseUp={onMouseUp}
        >
          <h3>hello - {x}</h3>
        </div>
      ))}
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById('root'));
.App {
  text-align: center;
  align-items: center;
  display: flex;
  flex-direction: column;
}
.listItem {
  border: 2px solid black;
  margin: 5px;
  width: 400px;
  cursor: grab;
  transform: scale(100%);
  transition: transform 0.3s ease-in-out;
}

.draggable {
  border: 2px solid green;
  margin: 5px;
  width: 400px;
  cursor: grab;
  transform: scale(108%);
  transition: transform 0.3s ease-in-out;
}

.listItem:-moz-drag-over {
  cursor: pointer;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.production.min.js"></script>
<div id="root"></div>

Can I get help with CSS animations to make the list item movements smoother during dragging so it looks less choppy? The goal is to achieve the following effect - when I drag an item it would smoothly reposition itself up/down, and the item that is being dragged over would smoothly move in the opposite direction.

EDIT 1:

Please check the code snippet, run it, and try to drag the list items to understand my requirements.

Basically, what I want is to animate the transition of the dragged item depending on which direction (up or down) the item is being dragged. In theory, while dragging an item up, it could apply a class something like .dragged-up and that class would have animation/transition that would create an illusion that that item moving from the lower to the higher position.

The same principle could be applied to the items above and below the item being dragged. For example, If the item that is being dragged over moves from the top to the bottom, a different class could be applied, something like .listItem-down, and that class could contain an opposite animation. Also, I suspect it would need to have a lower z-index so it would appear below the dragged item.

Not sure if it's the most efficient approach and if it's possible to do it that way at all. So far, while trying to implement something like this, I've been getting issues of items overlapping and as a result, the event function was being executed on the wrong div, causing some undesired effects.

Some help and a working snippet would be much appreciated!

6
  • 1
    Just to note: I haven't looked into why, and don't see any error messages, but something in your snippet is not working in current Safari (16.1); the click event is firing to give the little "pulse" animation, but the drag operations are not. Works fine in both chrome and Firefox for me. Commented Jan 20, 2023 at 21:33
  • 1
    Ah now I'm thinking about it again, and I realised that maybe Safari is firing different events on click? OnTouch instead of onClick for example Commented Jan 20, 2023 at 21:48
  • 1
    Does the solution need to use setMyArray to rearrange and also do you need to be able to set the items just by extending the array or are they allowed to be set statically? Commented Jan 20, 2023 at 22:30
  • 1
    Regarding setMyArray - initially, I thought yes, but since you asked I'm not sure if this is the best approach. It might help if I explain the bigger picture. I'm building a component that would have a dropdown and a list. A list would be populated by the values selected from the dropdown. A list of items in the dropdown would be static. The list array would be extendable and also reorderable by dragging. The component would be a part of the form. If creating a new form, we'd start from a blank list. We should also be able to load form from the API form and restore the list from the data. Commented Jan 20, 2023 at 22:55
  • 1
    Not sure if it makes sense, but ultimately the component should be able to receive items array, the user should be able to reorder items and pass the reordered data back. Let me know if you need more details Commented Jan 20, 2023 at 22:59

1 Answer 1

3
+200

This answer is inspired by this solution, and attempts to make a greatly simplified port of its main ideas to functional React components that works for the draggable elements in the use case.

In the posted example, order of items in the array is updated on every event of dragging over. To create a transition when the reorder happens, the difference before and after the change for each item can be detected, and used as the starting and ending points for the animation.

The following approach assigns a keyed ref for each item to keep track of the updates, and check for the changes in their rendered position with getBoundingClientRect in a useLayoutEffect, so that further actions can be taken before the browser repaints the screen.

In order to calculate the differences, the position of items in the last render prevPos is stored separately as another ref, so that it persists between renders. In this simplified example, only top position is checked and calculated for a difference, to create an offset for translateY to happen.

Then to arrange for the transition, requestAnimationFrame is called two times, with the first frame rendering the items in the offset positions (starting point, with offset in translateY), and the second their in new natural positions (ending point, with 0 in translateY).

While at this point useLayoutEffect already handle the animations as expected, the fact that onDragOver triggers and updates the state very often could easily cause errors in the motion display.

I tried to implement some basic debouncing for the update of the state array and introduced another useEffect to handle the debounced update, but it seems that the effects might be still occasionally unstable.

While lots of improvements could still be done, here is the experimental example:

const App = () => {
  const [myArray, setMyArray] = React.useState([
    0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
  ]);
  const [draggedKey, setDraggedKey] = React.useState(null);
  const [pendingNewKey, setPendingNewKey] = React.useState(null);
  const elRef = React.useRef({});
  const keyInAnimation = React.useRef(null);
  const prevPos = React.useRef({});

  // 👇 Attempt to debounce update of array
  React.useEffect(() => {
    if (
      pendingNewKey === null ||
      draggedKey === null ||
      draggedKey === pendingNewKey ||
      keyInAnimation.current === draggedKey
    )
      return;
    const updateArray = () => {
      setMyArray((prev) => {
        const prevIndex = prev.findIndex((x) => x === draggedKey);
        const newIndex = prev.findIndex((x) => x === pendingNewKey);
        const newArray = [...prev];
        newArray[prevIndex] = pendingNewKey;
        newArray[newIndex] = draggedKey;
        return newArray;
      });
    };
    const debouncedUpdate = setTimeout(updateArray, 100);
    return () => clearTimeout(debouncedUpdate);
  }, [pendingNewKey, draggedKey]);

  React.useLayoutEffect(() => {
    Object.entries(elRef.current).forEach(([key, el]) => {
      if (!el) return;

      // 👇 Get difference in position to calculate an offset for transition
      const { top } = el.getBoundingClientRect();
      if (!prevPos.current[key] && prevPos.current[key] !== 0)
        prevPos.current[key] = top;
      const diffTop = Math.floor(prevPos.current[key] - top);
      if (diffTop === 0 || Math.abs(diffTop) < 30) return;
      prevPos.current[key] = top;
      el.style.transform = `translateY(${diffTop}px)`;
      el.style.transition = 'scale 0.3s ease-in-out, transform 0s';

      // 👇 First frame renders offset positions, second the transition ends
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          if (!el) return;
          el.style.transform = `translateY(0px)`;
          el.style.transition =
            'scale 0.3s ease-in-out, transform 100ms ease-out';
        });
      });
    });
  }, [myArray.toString()]);

  const onDragStart = (e, key) => {
    keyInAnimation.current = key;
    setDraggedKey(key);
    const emptyDiv = document.createElement('div');
    emptyDiv.style.width = '0px';
    emptyDiv.style.height = '0px';
    e.dataTransfer.setDragImage(emptyDiv, 0, 0);
    e.currentTarget.className = 'draggable';
  };

  const onMouseDown = (e) => {
    e.currentTarget.className = 'draggable';
  };
  const onMouseUp = (e) => {
    e.currentTarget.className = 'listItem';
  };

  const onDragOver = (e, key) => {
    e.preventDefault();
    if (draggedKey === null) return;
    if (draggedKey === key) {
      keyInAnimation.current = key;
      setPendingNewKey(null);
      return;
    }
    if (keyInAnimation.current === key) {
      return;
    }
    keyInAnimation.current = key;
    setPendingNewKey(key);
    // 👇 Attempt to reduce motion error but could be unnecessary
    Object.values(elRef.current).forEach((el) => {
      if (!el) return;
      el.style.transform = `translateY(0px)`;
      el.style.transition = 'scale 0.3s ease-in-out, transform 0s';
    });
  };

  const onDragEnd = (e) => {
    setDraggedKey(null);
    setPendingNewKey(null);
    keyInAnimation.current = null;
    e.target.className = 'listItem';
  };

  return (
    <div className="App">
      {myArray.map((x) => (
        <div
          className="listItem"
          draggable
          key={x}
          onDragStart={(e) => onDragStart(e, x)}
          onDragOver={(e) => onDragOver(e, x)}
          onDragEnd={onDragEnd}
          onMouseDown={onMouseDown}
          onMouseUp={onMouseUp}
          ref={(el) => (elRef.current[x] = el)}
        >
          <h3>hello - {x}</h3>
        </div>
      ))}
    </div>
  );
};

ReactDOM.render(<App />, document.querySelector("#root"));
.App {
  text-align: center;
  align-items: center;
  display: flex;
  flex-direction: column;
  isolation: isolate;
  gap: 15px;
}
.listItem {
  border: 2px solid black;
  margin: 5px;
  width: 400px;
  cursor: grab;
  z-index: 1;
  transition: scale 0.3s ease-in-out;
  background-color: white;
}

.draggable {
  border: 2px solid hotpink;
  margin: 5px;
  width: 400px;
  cursor: grab;
  scale: 108%;
  z-index: 10;
  transition: scale 0.3s ease-in-out;
  background-color: white;
}

.listItem:-moz-drag-over {
  cursor: pointer;
}
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.1.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.1.0/umd/react-dom.production.min.js"></script>

Sign up to request clarification or add additional context in comments.

4 Comments

The solution is solid. I don't suppose you could help me with the mouse turning red cross while I'm dragging? Can't find why it's doing so
@SebastianMeckovski It is probably because the container is not a onDragOver target, and it should be prevented by adding an event such as onDragOver={(e) => e.preventDefault()} on the container (the "App" container here). In this example though, since the dragging only take effect when dragging over an item, I think it might be fine to consider keeping the cross icon, as it does indicate the effective area for dragging over.
you were right, adding onDragOver={(e) => e.preventDefault()}. prevents the mouse from turning into a red cross, I actually prefer that. It's a step closer. Still turns into a red cross icon when hovering on the border of the list item
@SebastianMeckovski Thanks for the feedback, it seems to be fine on Firefox for me, but when I try with Chrome, it indeed shows the cross icon and flashes back. Probably due to the difference in ways browsers handle the (relatively new) drag-and-drop feature. So far I think if we add h3 { pointer-events: none } to the CSS, it would prevent this for the h3 border (of the "Hello" text box), but since we can't add the same to the whole item, not sure if it would be possible to tell some browsers to disable this behavior.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.