itaydafna
itaydafna

Reputation: 2086

css transform: translate transition behaving strangely

On this sandbox, I've recreated the classic sliding-puzzle game.

On my GameBlock component, I'm using a combination of css transform: translate(x,y) and transition: transform in order to animate the sliding game-pieces:

const StyledGameBlock = styled.div<{
  index: number;
  isNextToSpace: boolean;
  backgroundColor: string;
}>`
  position: absolute;
  display: flex;
  justify-content: center;
  align-items: center;
  width: ${BLOCK_SIZE}px;
  height: ${BLOCK_SIZE}px;
  background-color: ${({ backgroundColor }) => backgroundColor};
  ${({ isNextToSpace }) => isNextToSpace && "cursor: pointer"};

  ${({ index }) => css`
    transform: translate(
      ${getX(index) * BLOCK_SIZE}px,
      ${getY(index) * BLOCK_SIZE}px
    );
  `}

  transition: transform 400ms;
`;

Basically, I'm using the block's current index on the board in order to calculate it's x and y values which change the transform: translate value of the block when it's being moved.

While this does manage to trigger a smooth transition when sliding the block to the top, to the right and to the left - for some reason, sliding the block from top to bottom doesn't transition smoothly.

Any ideas what's causing this exception?

Upvotes: 1

Views: 1111

Answers (2)

gru
gru

Reputation: 3069

Additionally to the excellent answer and explanations @Lars provided, I wanted to share visual proof that certain <GameBlock /> components are indeed unmounted or changed in order, causing the hiccup in the CSS animation.

As you can see, when focussing one of the blocks and sliding down, the element changes its position in the DOM.

screen recording

Upvotes: 2

Lars
Lars

Reputation: 3573

React, lists and keys

What you're seeing is the result of a mount/unmount of the <GameBlock /> components.
Although you're passing a key prop to the component, React is unsure that you're still rendering the same element.

If I have to guess why react is uncertain, I would put the culprit at:

  • Changing the array sort with:
     const previousSpace = gameBlocks[spaceIndex];
     gameBlocks[spaceIndex] = gameBlocks[index];
     gameBlocks[index] = previousSpace;
  • having different virtual DOM results using the conditional on isSpace:
 ({ correctIndex, currentIndex, isSpace, isNextToSpace }) => isSpace ? null : ( <GameBlock             ....    

Usually in applications, we don't mind a re-mount since it's pretty fast. When we attach an animation, we don't want any re-mounts since they mess with the css-transitions.
in order for react to be certain it's the same node and no re-mount is needed. we should take care that; between renders; the virtual dom stays mostly the same. we can achieve that not doing anything fancy in the render of the list, and passing down the same keys between renders.

Pass isSpace down

Instead of changing the the rendered DOM nodes, we want the list render to always return an equal amount of nodes, with the exact same keys for each Node, in the same order.

simply passing 'isSpace' down and styling as display:none; should do the trick.

 <GameBlock
      ...
      isSpace={isSpace}
      ...
  >

const StyledGameBlock = styled.div<{ ....}>`
   ...
  display: ${({isSpace})=> isSpace? 'none':'flex'};
   ...  
`;

Making sure to not change the arraysort

React considers the gameBlocks array to be modified, the keys are in a different order. Thus triggering unmount/mount of the rendered <GameBlock/> components. We can make sure that react considers this array to be unmodified, by only changing the properties of the items in the list and not the sort itself.

in your case, we can leave all properties as is, only changing the currentIndex for the blocks that are moved/swapped with each other.

 const onMove = useCallback(
    (index) => {
      const newSpaceIndex = gameBlocks[index].currentIndex; // the space will get the current index of the clicked block.
      const movedBlockNewIndex = gameBlocks[spaceIndex].currentIndex; // the clicked block will get the index of the space.

      setState({
        spaceIndex: spaceIndex, // the space will always have the same index in the array.
        gameBlocks: gameBlocks.map((block) => {
          const isMovingBlock = index === block.correctIndex; // check if this block is the one that was clicked
          const isSpaceBlock =
            gameBlocks[spaceIndex].currentIndex === block.currentIndex;  // check if this block is the space block. 
          let newCurrentIndex = block.currentIndex; // most blocks will stay in their spot. 
          if (isMovingBlock) {
            newCurrentIndex = movedBlockNewIndex; // the moving block will swap with the space. 
          }
          if (isSpaceBlock) {
            newCurrentIndex = newSpaceIndex; // the space will swap with the moving block
          }
          return {
            ...block,
            currentIndex: newCurrentIndex,
            isNextToSpace: getIsNextToSpace(newCurrentIndex, newSpaceIndex)
          };
        })
      });
    },
    [gameBlocks, spaceIndex]
  );


...
// we have to be sure to call onMove the with the index of the clicked block.
() => onMove(correctIndex) 

The only things we've changed are is the currentIndex of the clicked block and the space.

sandbox:

sandbox example based on your provided sandbox.

closing thoughts: I think your code was easy to read and understand, good job on that!

Upvotes: 2

Related Questions