table: animate swapping columns
Gohomewho
Posted on December 31, 2022
Currently, when columns swap, they 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
}
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
})
The animation doesn't look right. The columns flash like the bug we fixed in series 3.
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
// ...
}
The flash is gone. But there is another problem revealed. The firstTarget
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()
// ...
}
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
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)
})
}
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,
})
}
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;
}
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')
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 })
Now we get rid of duplicate column names and the UI becomes more cleaner.
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;
}
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;
}
After removing border-collapse
, there are small gaps between table cells, and the borders are animated.
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.
Posted on December 31, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.