Search code examples
cssreactjscss-transitionsstyled-componentscss-transforms

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?


Solution

  • 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!