mineshmshah
mineshmshah

Reputation: 480

ReactJS hooks - drag and drop with multiple useState hooks and styled-components

I am fairly new to hooks and I am trying to implement a drag and drop container component that handles onDragStart, onDrag and onDragEnd functions throughout the mouse movement. I have been trying to replicate the code found here using hooks : https://medium.com/@crazypixel/mastering-drag-drop-with-reactjs-part-01-39bed3d40a03

I have almost got it working using the code below. It is animated using styled components. The issue is it works only if you move the mouse slowly. If you move the mouse quickly the SVG or whatever is contained in this div is thrown of the screen.

I have a component.js file that looks like

import React, { useState, useEffect, useCallback } from 'react';
import { Container } from './style'

const Draggable = ({children, onDragStart, onDrag, onDragEnd, xPixels, yPixels, radius}) => {
  const [isDragging, setIsDragging] = useState(false);
  const [original, setOriginal] = useState({
    x: 0,
    y: 0
  });
  const [translate, setTranslate] = useState({
    x: xPixels,
    y: yPixels
  });
  const [lastTranslate, setLastTranslate] = useState({
    x: xPixels,
    y: yPixels
  });

  useEffect(() =>{
    setTranslate({
      x: xPixels,
      y: yPixels
    });
    setLastTranslate({
      x: xPixels,
      y: yPixels
    })
  }, [xPixels, yPixels]);

  const handleMouseMove = useCallback(({ clientX, clientY }) => {

    if (!isDragging) {
      return;
    }
    setTranslate({
      x: clientX - original.x + lastTranslate.x,
      y: clientY - original.y + lastTranslate.y
    });
  }, [isDragging, original,  lastTranslate, translate]);



  const handleMouseUp = useCallback(() => {
    window.removeEventListener('mousemove', handleMouseMove);
    window.removeEventListener('mouseup', handleMouseUp);

    setOriginal({
      x:0,
      y:0
    });
    setLastTranslate({
      x: translate.x,
      y: translate.y
    });

    setIsDragging(false);
    if (onDragEnd) {
      onDragEnd();
    }

  }, [isDragging, translate, lastTranslate]);

  useEffect(() => {
    window.addEventListener('mousemove', handleMouseMove);
    window.addEventListener('mouseup', handleMouseUp);

    return () => {
      window.removeEventListener('mousemove', handleMouseMove);
      window.removeEventListener('mouseup', handleMouseUp)
    };
  }, [handleMouseMove, handleMouseUp]);

  const handleMouseDown = ({ clientX, clientY }) =>{

    if (onDragStart) {
      onDragStart();
    }
    setOriginal({
      x: clientX,
      y: clientY
    });
    setIsDragging(true);
  };

  return(
    <Container
      onMouseDown={handleMouseDown}
      x={translate.x}
      y={translate.y}
      {...{radius}}
      isDragging={isDragging}
    >
      {children}
    </Container>
  )
};

export default Draggable

And the styled components file, styled.js looks like the following:

import styled from 'styled-components/macro';

const Container = styled.div.attrs({
  style: ({x,y, radius}) => ({
    transform: `translate(${x - radius}px, ${y - radius}px)`
  })
})`
  //cursor: grab;
  position: absolute;

  ${({isDragging}) =>
    isDragging && `

    opacity: 0.8
    cursor: grabbing
  `}
`;

export {
  Container
}

So I pass in the initial value from the parent initially. I think i am not dealing with the useEffect / useState correctly and it is not getting the information fast enough.

I would be extremely grateful if someone can help me figure out how to fix this issue. Apologies again, but I am very new to using hooks.

Thanks You :)

Upvotes: 2

Views: 5008

Answers (1)

Matt Carlotta
Matt Carlotta

Reputation: 19762

Ideally, since setState is asynchronous you'd move all your state into one object (as the medium example does). Then, you can leverage the setState callback to make sure the values that each event listener and event callback is using are up-to-date when setState is called.

I think the example in that medium article had the same jumping issue (which is probably why the example video moved the objects slowly), but without a working example, it's hard to say. That said, to resolve the issue, I removed the originalX, originalY, lastTranslateX, lastTranslateY values as they're not needed since we're leveraging the setState callback.

Furthermore, I simplified the event listeners/callbacks to:

  • mousedown => mouse left click hold sets isDragging true
  • mousemove => mouse movement updates translateX and translateY via clientX and clientY updates
  • mouseup => mouse left click release sets isDragging to false.

This ensures that only one event listener is actually transforming x and y values.

If you want to leverage this example to include multiple circles, then you'll need to either reuse the component below OR use useRef and utilize the refs to move the circle that is selected; however, that's beyond the scope of your original question.

Lastly, I also fixed a styled-components deprecation issue by restructuring the styled.div.data.attr to be a function that returns a style property with CSS, instead of an object with a style property that is a function that returns CSS.

Deprecated:

styled.div.attrs({
  style: ({ x, y, radius }) => ({
    transform: `translate(${x - radius}px, ${y - radius}px)`
  })
})`

Updated:

styled.div.attrs(({ x, y, radius }) => ({
  style: {
    transform: `translate(${x - radius}px, ${y - radius}px)`
  }
}))`

Working example:

Edit Drag and Drop Example


components/Circle

import styled from "styled-components";

const Circle = styled.div.attrs(({ x, y, radius }) => ({
  style: {
    transform: `translate(${x - radius}px, ${y - radius}px)`
  }
}))`
  cursor: grab;
  position: absolute;
  width: 25px;
  height: 25px;
  background-color: red;
  border-radius: 50%;

  ${({ isDragging }) =>
    isDragging &&
    `
    opacity: 0.8;
    cursor: grabbing;
  `}
`;

export default Circle;

components/Draggable

import React, { useState, useEffect, useCallback } from "react";
import PropTypes from "prop-types";
import Circle from "../Circle";

const Draggable = ({ position, radius }) => {
  const [state, setState] = useState({
    isDragging: false,
    translateX: position.x,
    translateY: position.y
  });

  // mouse move
  const handleMouseMove = useCallback(
    ({ clientX, clientY }) => {
      if (state.isDragging) {
        setState(prevState => ({
          ...prevState,
          translateX: clientX,
          translateY: clientY
        }));
      }
    },
    [state.isDragging]
  );

  // mouse left click release
  const handleMouseUp = useCallback(() => {
    if (state.isDragging) {
      setState(prevState => ({
        ...prevState,
        isDragging: false
      }));
    }
  }, [state.isDragging]);

  // mouse left click hold
  const handleMouseDown = useCallback(() => {
    setState(prevState => ({
      ...prevState,
      isDragging: true
    }));
  }, []);

  // adding/cleaning up mouse event listeners
  useEffect(() => {
    window.addEventListener("mousemove", handleMouseMove);
    window.addEventListener("mouseup", handleMouseUp);

    return () => {
      window.removeEventListener("mousemove", handleMouseMove);
      window.removeEventListener("mouseup", handleMouseUp);
    };
  }, [handleMouseMove, handleMouseUp]);

  return (
    <Circle
      isDragging={state.isDragging}
      onMouseDown={handleMouseDown}
      radius={radius}
      x={state.translateX}
      y={state.translateY}
    />
  );
};

// prop type schema
Draggable.propTypes = {
  position: PropTypes.shape({
    x: PropTypes.number,
    y: PropTypes.number
  }),
  radius: PropTypes.number
};

// default props if none are supplied
Draggable.defaultProps = {
  position: {
    x: 20,
    y: 20
  },
  radius: 10,
};

export default Draggable;

Upvotes: 9

Related Questions