Parabolic Curve Animation With RxJS
I came across this article (written in Chinese) the other day. It was about parabolic curve animation in vanilla JS. I wondered how RxJS can implement this. Below is the result of my investigation.
Imagine we take a perspective from a slow-motion camera. What humans see as a
smooth animation is just an object that is put at different places at every
fragment of a time period. This can be expressed as a ‘stream’ of object
position. The mechanism of an animation can be simplified by somehow mapping
every fragment of a time period to a position point in space. In practice, time
cannot be fragmented indefinitely, what we want is an approximation of an
“atomic time unit”. The browser has provided us a tool to achieve this, which is
the requestAnimationFrame
API.
We can map every timestamp emitted by requestAnimationFrame
to a position
coordinate at the screen. Let’s see how we can do this in Rxjs!
Generating time sequence
// I only demonstrate the import part once,
// they will be omitted in later code.
import {interval, animationFrameScheduler, fromEvent, defer, merge} from 'rxjs'
import {map, takeWhile, tap, flatMap} from 'rxjs/operators'
function duration(ms) {
return defer(() => {
const start = Date.now()
return interval(0, animationFrameScheduler).pipe(
map(() => (Date.now() - start) / ms),
takeWhile(n => n <= 1)
)
})
}
What defer
does is to only create a new observable upon being subscribed. This
is to ensure every subscriber gets a new observable, otherwise, we’ll see weird
movements.
First, we record the animation beginning time, then we return an interval
function that will emit an event every 0 seconds. This seems ridiculous.
However, notice the animationFrameScheduler
, it schedules at which point the
interval
function can emit an event. This is how we simulate an atomic time
unit. The map
function maps every time unit emitted by interval
to a time
ratio of current elapse to the whole animation duration. takeWhile
ensures we
unsubscribe to interval
once we reach the end. We know we’ve reached the end
if the current elapse equals the total time.
Then we calculate how far the object is away from the origin at every point in time.
Move the object
const distance = d => t => d * t
d
is the total distance the object is going to move. t
is the time ratio,
which has been calculated in the last step. We multiply them and get the
distance at that point.
We get the target in the DOM and move it.
const targetDiv = document.querySelector('.target')
const moveRight$ = duration(1500).pipe(
map(distance(1000)),
tap(x => (targetDiv.style.left = x + 'px'))
)
const moveDown$ = duration(900).pipe(
map(distance(700)),
tap(y => (targetDiv.style.top = y + 'px'))
)
The first stream moves the object to the right, the second to the bottom. Notice that the animation hasn’t taken place, because we haven’t’ subscribed to them yet.
We combine these two streams into a new stream, making the object move rightwards and downwards at the same time.
merge(moveRight$, moveDown$).subscribe()
This is boring, we don’t see any curve yet. But bear with me.
Make the motion trajectory parabolic!
I hope you still remember middle school math. If you don’t, fret not. It’s
pretty intuitive actually. What we observe as a curve movement is the result of
an object moves at different speeds in different directions. The shape of the
curve can be expressed in a mathmatical equation. Take a look of the graph of
the equation y = x^3, the left and right half of it is the parabolic curve we
want:
If we can make the downward speed and rightward speed form a cubic equation, then we have a parabolic curve movement!
We can use two different easing functions that form a cubic relationship between them:
The first one is easeInQuad
:
const easeInQuad = t => t * t
The second one is easeInQuint
:
const easeInQuint = t => Math.pow(t, 6)
Then we only need to map the time ratio emitted from the interval pipeline to the result of applying these easing functions:
const moveDown$ = duration(900).pipe(
map(easeInQuint),
map(distance(700)),
tap(y => (targetDiv.style.top = y + 'px'))
)
const moveRight$ = duration(1500).pipe(
map(easeInQuad),
map(distance(1000)),
tap(x => (targetDiv.style.left = x + 'px'))
)
See the codepen below for the result and the complete code: