DEV Community

loading...
Cover image for How to Create a 2D draggable grid with react-spring: The showdown

How to Create a 2D draggable grid with react-spring: The showdown

Mukul Jain
Javascript Engineer
・9 min read

Welcome to the final part of the series! In the last part we had a grid with every block moving separately, today we will convert it into a defined grid, where each block can only replace another block and on dragging over other blocks grid will re-arrange it self to make appropriate space for this one.

Take a glance to final piece older code demo and motivate yourself.

We will be using react-spring for this purpose so install it locally or add it to code sandbox. Though we are using react-spring you can easily replace it with other library or plain react!

What is React Spring

React spring is one of the most popular React animation library, it is spring-physics, to give essence of real world interaction. All the API's are pretty simple and similar, like you want to move something.

const styles = useSpring({
    from: { x: 0, ...otherCSSPropertiesYouWantAnimate},
    to: { x: 100, ...sameProperties},
  })

Enter fullscreen mode Exit fullscreen mode

or just

const styles = useSpring({ opacity: toggle ? 1 : 0 })
Enter fullscreen mode Exit fullscreen mode

as you might have guess styles contains the css to move something, react-spring also provides element creator (factory) out of the box to consume these styles property as animated, you can create any HTML element using it, these play well with libraries like styled-component or with React components.

import { useSpring, animated } from 'react-spring';
...
<animated.div style={style}>
...
</animated.div>
Enter fullscreen mode Exit fullscreen mode

Replace div with animated.div in Block

// https://codesandbox.io/s/multi-block-grid-react-spring-0u80r?file=/src/Block.jsx:114-156

- const BlockWrapper = styled("div")`

+ const BlockWrapper = styled(animated.div)`
Enter fullscreen mode Exit fullscreen mode

As we saw above react-spring has a hook useSpring it works for one, for multiple elements there is another useSprings which supports multiple elements.

const [springs, setSprings] = useSprings(
    10,
    animate(rowSize, order.current)
);
Enter fullscreen mode Exit fullscreen mode

It takes 2 parameter, first the number of items and second an array with CSS properties or a function which takes an index and return the values, we will use 2nd one as it's better for fast occurring updates and we will be having a lot of updates!

Using react spring

// Grid.jsx
const  [springs, api] =  useSprings(10,  animate);
Enter fullscreen mode Exit fullscreen mode

10 is length of block as before and animate will be the function we will use to animate individual block, it get's index as a param, let's just create what we had before but in react spring context.

// Grid.jsx
const  animate  =  (index)  =>  {
  // we will move this piece out and will call it grid generator
  const  col  =  Math.floor(index  %  blockInRow);
  const  row  =  Math.floor(index  /  blockInRow);
  return  { x:  col  *  120  +  col  *  8, y:  120  *  row  +  row  *  8  };
};

...

{springs.map((style, index) => (
  <Block
    style={style}
    ...
  />
...
Enter fullscreen mode Exit fullscreen mode

It renders the same grid but the blocks are not draggable anymore as we are not using the coordinates from useDraggable. We are using styles from spring, handleMouseDown is already in place and we are controlling the style using the animate function so we just have to feed the coordinates to animate function! Think animate as a middleware or transformer.

Confusing ?

Initially we were using the coordinates from useDraggable to drag the block and for that we had the handleMouseMove which was updating the state in useDraggable but now we are using coordinate from useSprings via style prop, that's why block is not dragging anymore but it still had handleMouseDown in place. We will pass the coordinates from useDraggable to our animate which in turn will update the style attribute accordingly to move the block.

const animate = React.useCallback(
    (index) => {
      return {
        x: blocks[index].x,
        y: blocks[index].y,
      };
    },
    [blocks]
);

// tell the spring to update on every change
React.useEffect(()  =>  {
    api.start(animate);
},  [api,  animate]);
Enter fullscreen mode Exit fullscreen mode

Nice, blocks are moving again! You might notice a difference in speed as react spring is controlling them in a springy nature. For immediate movement we will return a extra key-value from our animate function and that will be

immediate:  (n)  => n  ===  "y"  ||   n  ===  "x"
Enter fullscreen mode Exit fullscreen mode

It tells the react spring to immediately apply these changes skipping the springy motion. We should keep our moving block always on top to do this we need to figure out which index is so we will expose that from our useDraggable hook and will use it animate

const  animate  =  React.useCallback((index)  =>  {
    return  {
        x:  blocks[index].x,
        y:  blocks[index].y,
        scale:  index  ===  movingBlockIndex  ?  1.2  :  1,
        zIndex:  index  ===  movingBlockIndex  ?  10  :  1,
        immediate:  (n)  =>  immediateMotionsProsp[n]
    };
},[blocks,  movingBlockIndex]);
Enter fullscreen mode Exit fullscreen mode

I have also added scale, so the moving block can stand out.

Check the frozen code sandbox till here.

Limiting movement of blocks to specified area

We don't want our blocks to leave the grid! for this we must stop the block movement if it goes outside of grid and for that we have check if onMouseMove the pointer is outside or inside the specified grid. We can do it using a very simple check the x of block should be more left most x of grid and less than right most x same goes for y coordinate, we can found out the coordinates of grid using getBoundingClientRect()

// https://codesandbox.io/s/multi-block-grid-react-spring-x8xbd?file=/src/isInside.js

isInside = (element, coordinate) => {
  const { left, right, bottom, top } = element.getBoundingClientRect();
  // if bottom and right not exist then it's a point
  if (!coordinate.right || !coordinate.bottom) {
    if (coordinate.left > right || coordinate.left < left) {
      return false;
    }

    if (coordinate.top > bottom || coordinate.top < top) {
      return false;
    }
  } else {
    if (
      coordinate.left < left ||
      coordinate.top < top ||
      coordinate.right > right ||
      coordinate.bottom > bottom
    ) {
      return false;
    }
  }

  return true;
};
Enter fullscreen mode Exit fullscreen mode

We just have to add this condition in our handleMouseMove

if (
  parentRef.current &&
  !isInside(parentRef.current, {
    left: event.clientX,
    top: event.clientY
  })
) {
  handleMouseUp();
}
Enter fullscreen mode Exit fullscreen mode

parentRef ? it's the ref of parent div, we can pass it to useDraggable along with totalBlocks, blockInRow.

For this to work properly we have to make some changes in our component,

const Wrapper = styled.div`
  ${({ width }) => width && `width: ${width}px;`}
  height: 480px;
  border: 1px solid red;
  overflow-y: auto;
  overflow-x: hidden;
  position: relative;
`;

const BlockContainer = styled.div`
  flex-grow: 2;
  position: relative;
  display: flex;
  flex-wrap: wrap;
  width: 100%;
  height: 100%;
  border: 1px solid black;
`;

...

<BlockContainer onMouseMove={handleMouseMove} onMouseUp={handleMouseUp}>
   <Wrapper ref={parentRef} width={blockInRow * 120 + (blockInRow - 1) * 8}>
     {springs.map((style, index) => {
       const blockIndex = blocks.current.indexOf(index);
       return (
         <Block
            ...
         />
       );
     })}
   </Wrapper>
 </BlockContainer>
Enter fullscreen mode Exit fullscreen mode

Automatic rearrangement

All the code we have written till now going to change a lot, why I didn't directly jump into this? I could have, it could have been 1 part tutorial using react-use-gesture (which is way more efficient), but we here to learn how things work not just to get things done, we started with one draggable block to grid and now we are adding re-arrangement to it, your next requirement can be something else but as you know all of it you can tweak the existing code or write by yourself!

We will no more save the coordinates of all block, but only track the current moving block coordinates and will forget about it as soon as the user is done dragging because we want a grid which re-arranges itself, makes space for the moving block.

We will use our existing grid creator function to get new position. Suppose you are moving the first block and moved it over the 4th one, now each block should move to make space for this one, as in the image block will re-arrange themselves to do this we will move the blocks in our array and will the position calculator again to get new position according to new arrangement.

Re-arrangement flow

Current Order: [A,B,C,D]

use start dragging block A, the order will remain same until block A is over any other block with at least 50% area.
As it reaches towards D, all block will re-arrange new order will be

[B,C,D,A]

We still have coordinates of block A as it is still moving, but for B,C,D we will assign them new position. We will treat like B was always was the first block and will assign it (0,0) and react-spring will take care animating it and rest of the blocks! As soon as user leave the block A it will be moved to its coordinates generated by the grid generator for position 4 or index 3.

We will also modify our useDraggable such that it takes the initial position and keep calculating the current while movement and forgets everything on mouseUp

We will start with dragging one element only and placing it back on releasing, for this we have to change the useDraggable, most of the things will remains same you can check the whole code here, important changes are

// state
{
   // block current coordinates
    block: { x: 0, y: 0 },
    // inital block positions
    blockInitial: { x: 0, y: 0 },
    // initial pointer coordinates
    initial: { x: 0, y: 0 },
    movingBlockIndex: null
}

const handleMouseDown = React.useCallback((event, block) => {
    const index = parseInt(event.target.getAttribute("data-index"), 10);
    const startingCoordinates = { x: event.clientX, y: event.clientY };
    setCoordinate((prev) => ({
        ...prev,
        block,
        blockInitial: block,
        initial: startingCoordinates,
        movingBlockIndex: index
    }));
    event.stopPropagation();
  }, []);

const handleMouseMove = React.useCallback(
    (event) => {
      if (coordinate.movingBlockIndex === null) {
        return;
      }
      const coordinates = { x: event.clientX, y: event.clientY };
      setCoordinate((prev) => {
        const diff = {
          x: coordinates.x - prev.initial.x,
          y: coordinates.y - prev.initial.y
        };
        return {
          ...prev,
          block: {
            x: prev.blockInitial.x + diff.x,
            y: prev.blockInitial.y + diff.y
          }
        };
      });
    },
    [coordinate.movingBlockIndex]
);
Enter fullscreen mode Exit fullscreen mode

Concept stills remains the same what we did for single block!

Final Piece

Now we need figure out if user is moving a block where should we create the space, no there is no API which provides the element below the current element. Instead we will calculate the new block position we will consider that if block has moved at least 50% in x, y or both directions, then it can be moved to new position.

For this, we have to create an order array to keep the order of blocks in memory for re-arranging blocks we will be updating this array and feeding it to our grid generator, the order array will contain the initial index's or id's as we saw above for [A,B,C,D], to maintain the same ref we will use useRef

const  blocks  =  React.useRef(new  Array(totalBlocks).fill(0).map((_,  i)  =>  i));
Enter fullscreen mode Exit fullscreen mode

handleMouseMove will also be modified as we need to send the initial block position and original index

// Grid.js
onMouseDown={(e) =>
  handleMouseDown(
    e,
    initialCoordinates.current[blocks.current.indexOf(index)],
    // we are keeping as source of truth, the real id
    index
  )
}
Enter fullscreen mode Exit fullscreen mode

Now on every movement we have to check if we need to re-arrange for this we will use the same useEffect as before,
I have added comment/explanation the code snippet it self.

React.useEffect(() => {
    // we will save the actual id/index in movingBlockIndex
    const oldPosition = blocks.current.indexOf(movingBlockIndex);
    if (oldPosition !== -1) {
      // coordinate travelled by the block from it's last position
      const coordinatesMoved = {
        // remember the grid generator function above ?
        // I created an array "initialCoordinates" using it for quick access
        x: movingBlock.x - initialCoordinates.current[oldPosition].x,
        y: movingBlock.y - initialCoordinates.current[oldPosition].y
      };

      // As we have width and height constant, for every block movement 
      // in y direction we are actually moving 3 block in row.
      // we are ignoring the padding here, as its impact is so less
      // that you will not even notice
      let y = Math.round(coordinatesMoved.y / 120);
      if (Math.abs(y) > 0.5) {
        y = y * blockInRow;
      }

      const x = Math.round(coordinatesMoved.x / 120);

      const newPosition = y + x + oldPosition;
      // there will be cases when block is not moved enough
      if (newPosition !== oldPosition) {
        let newOrder = [...blocks.current];
        // swaping
        const [toBeMoved] = newOrder.splice(oldPosition, 1);
        newOrder.splice(newPosition, 0, toBeMoved);
        blocks.current = newOrder;
      }
    }

    // telling the spring to animate again
    api.start(animate);
  }, [api, animate, initialCoordinates, movingBlock, movingBlockIndex]);
Enter fullscreen mode Exit fullscreen mode
const animate = React.useCallback(
  (index) => {
    // the index in order of id
    const blockIndex = blocks.current.indexOf(index);
    // the block coordinates of other blocks
    const blockCoordinate = initialCoordinates.current[blockIndex];

    return {
      x: index === movingBlockIndex ? movingBlock.x : blockCoordinate.x,
      y: index === movingBlockIndex ? movingBlock.y : blockCoordinate.y,
      scale: index === movingBlockIndex ? 1.2 : 1,
      zIndex: index === movingBlockIndex ? 10 : 1,
      immediate:
        movingBlockIndex === index
          ? (n) => immediateMotionsProsp[n]
          : undefined
    };
  },
  [movingBlock, initialCoordinates, movingBlockIndex]
);
Enter fullscreen mode Exit fullscreen mode

That's all folks, here is the final outcome.

It should be noted we are using react spring as helper here, we are not utilising full power as there are still many re-renders for each block event as our useDraggable uses the useState so it was expected and totally fine for learning what's happening behind the scene, there are two path to explore.

  1. Write useDraggable such that it doesn't causes any re-renders
  2. use react use gesture

I would suggest to go for both paths and if you are wondering why the blocks are coloured I added a function getColors which is not worth explaining in the code. Also if you will check the initial demo's code which mentioned in first part and top of this part, the code differs a lot from what we finally have, this is because it contains a lot of code for multi width blocks and while writing this blog, I refactored/simplified a lot of things!

This was a lot to grasp, I tried to make things simpler and understandable as I can, if you any doubt & feedback please let me know in the comment, we can discuss over there.

Discussion (0)