table: animate swapping columns
Tags: webdev, javascript, tutorial
Published: 2022-12-31 17:34:32

Currently, when columns swap, they swap instantly.
![columns swap instantly](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/r2pjn5inreljz2v89bkg.gif)

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](https://aerotwist.com/blog/flip-your-animations/). 

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](https://github.com/ga676005/table/tree/24feb0beab7332d9261db8a8049a17f9767253be) we have so far.

makeColumnsSwappable.js
```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
}
```

After having `firstTargetInvert` and `secondTargetInvert`, we can FLIP columns with [Element.animate()](https://developer.mozilla.org/en-US/docs/Web/API/Element/animate)

```js
// 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
})
```

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

![flash animation](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1wry299fqf5se9swqx7w.gif)




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.
```js
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

  // ...
}  
```

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

![`firstTarget` column always animates from its initial position](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v01gwjyfhrjbrmk7ppn4.gif)

Let's use the latest `firstTargetRect` from `handleMove`.
```js
function handleMove(e) {
  // ...

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

  // ...
}
```

This fix the problem. 
![previous bug is fixed](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0o7zc7yx99laki4nz317.gif)


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 `150ms`](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tbz6s3onlgmaw1c7ptwp.gif)
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. 
```js
// 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)
  })
}
```

Use `animateSwap` inside `handleMove`.
```js
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,
  })
}
```

Awesome!
![Sweet](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/iz0bomvdif0jlsql2f9a.gif)

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
```css
/* hide text */
.column.moving.hide-content {
  color: transparent;
}

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

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
```js
const ghost = createGhost()
// add style to hide `firstTarget` content after we have created ghost
firstTarget.classList.add('hide-content')
```

Clean up `.hide-content` class after we've done moving.
```js
document.addEventListener('pointerup', () => {
  // ...
  firstTarget.classList.remove('moving')
  // clean up `.hide-content`
  firstTarget.classList.remove('hide-content')
}, { once: true })
```

Now we get rid of duplicate column names and the UI becomes more cleaner.
![firstTarget content is hidden](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/i4rczmu54mftmej8c922.gif)

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.
```css
my-table {
  border-collapse: collapse;
}

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

![replace border with box-shadow](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/58xugi9678k40uc9b9fi.gif)


We can confirm that this is a problem caused by `border-collapse: collapse` by removing its declaration.
```css
/* .my-table {
  border-collapse: collapse;
} */

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

After removing `border-collapse`, there are small gaps between table cells, and the borders are animated.
![border are animated after removing `border-collapse`](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/itybphpkq4krgy3x9csl.gif)

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](https://github.com/ga676005/table/commit/8f26b206c6b6f57bc1bde75d7cb1e46d28cec92d#diff-a5a12951ffd41c7ebc032678b2224d457cd32c37615a2e592fa7e9d4af2d99d1) 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.