How to Create a 2D draggable grid with react-spring: The showdown
Mukul Jain
Posted on June 13, 2021
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},
})
or just
const styles = useSpring({ opacity: toggle ? 1 : 0 })
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>
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)`
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)
);
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);
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}
...
/>
...
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]);
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"
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]);
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;
};
We just have to add this condition in our handleMouseMove
if (
parentRef.current &&
!isInside(parentRef.current, {
left: event.clientX,
top: event.clientY
})
) {
handleMouseUp();
}
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>
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.
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]
);
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));
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
)
}
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]);
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]
);
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.
- Write
useDraggable
such that it doesn't causes any re-renders - 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.
Posted on June 13, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.