table: animate swapping columns

gohomewho

Gohomewho

Posted on December 31, 2022

table: animate swapping columns

Currently, when columns swap, they swap instantly.
columns swap instantly

In this series, we are going to add animation to make it look nicer. First, let's think about how we are going to implement it. When we swap columns, we can't update them immediately. We need to calculate how to animate them and update their DOM orders once the animation ends. It seems to work, but does it work in all situations? What if we swap very fast before the previous swapping ends? Since their DOM orders don't update until animation ends and we heavily rely on their indexes to swap columns, it is hard to tell if this will work without really testing it out.

We can give it a try when we have a thought, but it seems a lot of work to do and doesn't guarantee to work. We can think if there are other solutions before jumping right into it.

Let's think an alternative. What if we update columns immediately just like we've done but add animation to them later? This way, their DOM orders will be correct. Their indexes will be correct. Which means that swapping will still be correct. But how do we apply animation? We are no longer animating columns "TO" their new positions. We will need to animate columns "FROM" their old positions. This solution seems to be easier, so we will go this route. We can apply the animation by using a technique called FLIP.

To do FLIP, we need to calculate the amount to invert from new position to old position, so the element will look like it is moving from old position to new position. We can use .getBoundingClientRect() before and after swap to help us calculate how much to invert.

The code we have so far.

makeColumnsSwappable.js



// already got `firstTargetRect` somewhere
const firstTargetRect = firstTarget.getBoundingClientRect()

// ...

function handleMove(e) {
  // ...

  const secondTarget = document.elementFromPoint(newCursorX, lockedY)
  const secondTargetIndex = columnElements.indexOf(secondTarget)
  // when we get `secondTarget ` on "pointermove"
  // save its rect infomation
  const secondTargetRect = secondTarget.getBoundingClientRect()

  // ...

  swapColumns(columnsContainer, swapColumnInfo)

  elementsToPatch.forEach((columnsContainer) => {
    swapColumns(columnsContainer, swapColumnInfo)
  })

  // after swapColumns(columnsContainer, swapColumnInfo)
  // `firstTarget` and `secondTarget` will be on new position
  // so we use `.getBoundingClientRect()` again to get the 
  // newest rect information
  const newFirstTargetRect = firstTarget.getBoundingClientRect()
  const newSecondTargetRect = secondTarget.getBoundingClientRect()

  // old position subtract new position will be 
  // the amount to invert
  const firstTargetInvert = firstTargetRect.x - newFirstTargetRect.x
  const secondTargetInvert = secondTargetRect.x - newSecondTargetRect.x
}


Enter fullscreen mode Exit fullscreen mode

After having firstTargetInvert and secondTargetInvert, we can FLIP columns with Element.animate()



// follow right after the code above

// animate firstTarget
firstTarget.animate([
  // from old position
  { transform: `translateX(${firstTargetInvert}px)` },
  // to new position
  { transform: `translateX(0px)` },
], {
  easing: 'ease-in-out',
  duration: 1000
})

// animate secondTarget
secondTarget.animate([
  // from old position
  { transform: `translateX(${secondTargetInvert}px)` },
  // to new position
  { transform: `translateX(0px)` },
], {
  easing: 'ease-in-out',
  duration: 1000
})


Enter fullscreen mode Exit fullscreen mode

The animation doesn't look right. The columns flash like the bug we fixed in series 3.

flash animation

This is because that we only checked how to swap column inside swapColumns function. We didn't check if we need to call swapColumns at all. Then we animate columns right after calling swapColumns without confirming do we really need to animate columns. The flashes tell us that some code is being run unnecessary frequently. So, we should add more check points before the code can reach swapColumns and animation.



function handleMove(e) {
  // ...

  // we checked if we got a correct `secondTarget`
  if (secondTargetIndex === -1)
    return

  // we checked if `firstTarget` and `secondTarget` are the same
  if (firstTarget === secondTarget)
    return

  // sometimes we only shift cursor vertically a little
  // that would also trigger "pointermove"
  // since we only care about moving left or right
  // we can make a condition to stop this unnecessary reaction
  if (newCursorX === lastCursorX)
    return

  const isMoveToLeft = newCursorX < lastCursorX
  const isMoveToRight = newCursorX > lastCursorX
  lastCursorX = newCursorX

  // add another check that we don't need to do anything 
  // if cursor is moving towards left but `secondTarget` is 
  // already on the right side of `firstTarget` or if cursor is 
  // moving towards right but `secondTarget` is already on the 
  // left side of `firstTarget`
  if (isMoveToLeft && secondTargetIndex > firstTargetIndex
    || isMoveToRight && secondTargetIndex < firstTargetIndex)
    return

  // ...
}  


Enter fullscreen mode Exit fullscreen mode

The flash is gone. But there is another problem revealed. The firstTarget column always animates from its initial position.

 raw `firstTarget` endraw  column always animates from its initial position

Let's use the latest firstTargetRect from handleMove.



function handleMove(e) {
  // ...

  // get `firstTargetRect` inside `handleMove`
  const firstTargetRect = firstTarget.getBoundingClientRect()
  const secondTargetRect = secondTarget.getBoundingClientRect()

  // ...
}


Enter fullscreen mode Exit fullscreen mode

This fix the problem.
previous bug is fixed

There is still an issue if you watch closely. If we swap firstTarget with another column before the previous animation ends, the firstTarget will take a leap. There are good news and bad news to this. The bad news is that I don't think there is a simple solution to this. Luckily, the good news is that we don't need that much animation duration. This is just a nice to have feature, so let's cut down the animation duration to 150ms.

I don't know you but this looks pretty amazing to me :D
animation duration is cut down to  raw `150ms` endraw
If someone moves cursor really fast, there will still have leaps. But it will be neglectable because if someone moves cursor too fast, they won't notice the leaps because their eyes can't keep up with the speed. I think this is the situation where we can compromise rather than adding more complexity.

Currently, we only animate the columns of the header section. Let's animate the elements in the same columns all together.



// we can put this function at the bottom of
// makeColumnsSwappable.js because it doesn't rely other states 
// created inside `makeColumnsSwappable`
function animateSwap({
  columnsContainers,
  firstTargetIndex,
  secondTargetIndex,
  firstTargetInvert,
  secondTargetInvert
}) {
  // create a function to animate columns
  function animate(element, invert) {
    element.animate([
      { transform: `translateX(${invert}px)` },
      { transform: `translateX(0px)` },
    ], {
      easing: 'ease-in-out',
      duration: 150
    })
  }

  // this is similar to how `swapColumns` works
  // we use indexes to get `firstTarget` and `secondTarget`
  // from the list of columns
  columnsContainers.forEach((columnsContainer) => {
    const columns = columnsContainer.children
    const firstTarget = columns[firstTargetIndex]
    const secondTarget = columns[secondTargetIndex]

    animate(firstTarget, firstTargetInvert)
    animate(secondTarget, secondTargetInvert)
  })
}


Enter fullscreen mode Exit fullscreen mode

Use animateSwap inside handleMove.



function handleMove(e) {
  const newCursorX = e.clientX
  let newGhostX = newCursorX + pointerOffset.x

  if (newGhostX < ghostXBoundary.min)
    newGhostX = ghostXBoundary.min
  else if (newGhostX > ghostXBoundary.max) {
    newGhostX = ghostXBoundary.max
  }

  ghost.style.left = newGhostX + 'px'

  const secondTarget = document.elementFromPoint(newCursorX, lockedY)
  // change `const` to `let`
  let secondTargetIndex = columnElements.indexOf(secondTarget)

  if (secondTargetIndex === -1)
    return

  if (firstTarget === secondTarget)
    return

  if (newCursorX === lastCursorX)
    return

  const isMoveToLeft = newCursorX < lastCursorX
  const isMoveToRight = newCursorX > lastCursorX
  lastCursorX = newCursorX

  if (isMoveToLeft && secondTargetIndex > firstTargetIndex
    || isMoveToRight && secondTargetIndex < firstTargetIndex)
    return

  const firstTargetRect = firstTarget.getBoundingClientRect()
  const secondTargetRect = secondTarget.getBoundingClientRect()

  const swapColumnInfo = {
    firstTargetIndex,
    secondTargetIndex,
    isMoveToLeft,
    isMoveToRight,
  }

  swapColumns(columnsContainer, swapColumnInfo)

  elementsToPatch.forEach((columnsContainer) => {
    swapColumns(columnsContainer, swapColumnInfo)
  })

  // we want to use indexes to get elements for animation
  // so we need to update `firstTargetIndex` and `secondTargetIndex`
  // after columns swap
  columnElements = [...columnsContainer.children]
  firstTargetIndex = columnElements.indexOf(firstTarget)
  secondTargetIndex = columnElements.indexOf(secondTarget)

  // calculate invert
  const newFirstTargetRect = firstTarget.getBoundingClientRect()
  const newSecondTargetRect = secondTarget.getBoundingClientRect()
  const firstTargetInvert = firstTargetRect.x - newFirstTargetRect.x
  const secondTargetInvert = secondTargetRect.x - newSecondTargetRect.x

  // we can use the information we get from `firstTarget` 
  // and `secondTarget` of header section, because the elements in 
  // the same column have the same structure.
  animateSwap({
    columnsContainers: [columnsContainer, ...elementsToPatch],
    firstTargetIndex,
    secondTargetIndex,
    firstTargetInvert,
    secondTargetInvert,
  })
}


Enter fullscreen mode Exit fullscreen mode

Awesome!
Sweet

We can add a little style to the firstTarget column to make it feel less prominent because we have a ghost column representing it.

makeColumnsSwappable.css



/* hide text */
.column.moving.hide-content {
  color: transparent;
}

/* hide child elements */
.column.moving.hide-content * {
  opacity: 0;
}


Enter fullscreen mode Exit fullscreen mode

We need to add .hide-content class to firstTarget after we have created ghost. Otherwise, the style from .hide-content would also be cloned to the ghost, thus we won't be able to see the content inside ghost.

makeColumnsSwappable.js



const ghost = createGhost()
// add style to hide `firstTarget` content after we have created ghost
firstTarget.classList.add('hide-content')


Enter fullscreen mode Exit fullscreen mode

Clean up .hide-content class after we've done moving.



document.addEventListener('pointerup', () => {
  // ...
  firstTarget.classList.remove('moving')
  // clean up `.hide-content`
  firstTarget.classList.remove('hide-content')
}, { once: true })


Enter fullscreen mode Exit fullscreen mode

Now we get rid of duplicate column names and the UI becomes more cleaner.
firstTarget content is hidden

One thing you might notice is that the borders don't respect our animation, they flip instantly. I found that it's again a problem caused by setting border-collapse: collapse. We can walk around like this.

Use box-shadow to act like a border. We use inset because I found that the box-shadow will disappear during animation.



my-table {
  border-collapse: collapse;
}

.my-table th,
.my-table td {
  padding: 8px;
  box-shadow: inset 0px 0px 0px 1px lightgray;
}


Enter fullscreen mode Exit fullscreen mode

replace border with box-shadow

We can confirm that this is a problem caused by border-collapse: collapse by removing its declaration.



/* .my-table {
  border-collapse: collapse;
} */

.my-table th,
.my-table td {
  padding: 8px;
  border: 1px solid lightgray;
}


Enter fullscreen mode Exit fullscreen mode

After removing border-collapse, there are small gaps between table cells, and the borders are animated.
border are animated after removing  raw `border-collapse` endraw

This is only an issue that we use table and directly insert data to th and td as an example. Remember that makeColumnsSwappable(columnsContainer, elementsToPatch) should work on any columns structured element. In a real world scenario, we would have more complex markup inside columnsContainer and elementsToPatch. If we are dealing with table, we can move the border style from th and td to their descendants. If we are not dealing with table, we don't need to worry about this.

Here is the code after I made some refactor. One thing to note is that I change the logic to calculate pointerOffset because its logic was reversed and not so intuitive.

💖 💪 🙅 🚩
gohomewho
Gohomewho

Posted on December 31, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related