DEV Community

Cover image for Write cool stateful animations with js-coroutines
Mike Talbot ⭐
Mike Talbot ⭐

Posted on

Write cool stateful animations with js-coroutines

TL;DR

  • There is a way of writing animations that you've probably never heard of
  • It makes writing animation code much simpler because it's imperative: you can use for-next loops and while statements
  • My js-coroutines library let's you write stateful coroutines for things like reactive animations
  • You write simple stateful generator functions and then fire and forget
  • Below is a React example of a simple reactive magnify animation

Magnify Demo

Magnify

The magnify effect increases the size of an item as the mouse approaches it, then animates its exit state as a flip should the mouse enter and then leave it. This is a useful example of stateful coroutines.

I've implemented it as a React wrapper component that can perform the effect on its children.

export function MagnifyBox({
    children,
    from = 1,
    to = 1.8,
    flipFrames = 60,
    radius = 15,
    ...props
}) {
    const ref = useRef()
    const classes = useStyles()
    useEffect(() => {
        const promise = magnify(ref.current, from, to, radius, flipFrames)
        return promise.terminate
    })
    return (
        <Box ref={ref} className={classes.magnify} {...props}>
            {children}
        </Box>
    )
}
Enter fullscreen mode Exit fullscreen mode

Here we create a simple Material UI Box wrapper that creates a coroutine in it's useEffect and calls the exit function of the coroutine should it unmount.

The coroutine

The magnify call creates a coroutine to perform the animation:

export function magnify(
    element,
    from = 0.9,
    to = 1.5,
    radius = 5,
    flipFrames = 60
) {
    if (!element) return
    const pos = rect()
    const zIndex = element.style.zIndex || 0
    const initialTransform = element.style.transform || ""
    const SCALING_FACTOR = pos.width + pos.height * 2
    //Get js-coroutines to run our function in high priority
    return update(run)
    ...
Enter fullscreen mode Exit fullscreen mode

The first part of the function grabs some useful stuff from the element to be animated and uses js-coroutines to start a high priority update animation.

Then we have 2 animation states, the first one is about the mouse approaching the item, the second about flipping. In the main animation we resize the item based on mouse position and then check if we are moving from inside to outside, which should trigger the flip.

    //Standard animation
    function* run() {
        let inside = false
        while (true) {
            //Resize based on mouse position
            const [, middleX] = resize()
            const outside = Math.abs(middleX - x) > pos.width
            if (!outside) {
                inside = true
            } else {
                if (inside) {
                    inside = false
                    //Use the flip animation until complete
                    yield* flip(middleX > x ? 1 : -1)
                }
            }
            yield
        }
    }

Enter fullscreen mode Exit fullscreen mode

resize performs cursor distance resizing:

    function resize() {
        const pos = rect()
        let middleX = pos.width / 2 + pos.x
        let middleY = pos.height / 2 + pos.y
        let d = Math.sqrt((x - middleX) ** 2 + (y - middleY) ** 2)
        const value = lerp(to, from, clamp((d - radius) / SCALING_FACTOR))
        element.style.transform = `scale(${value}) ${initialTransform}`
        element.style.zIndex =
            zIndex + ((((value - from) / (to - from)) * 1000) | 0)
        return [d, middleX, middleY]
    }

    function clamp(t) {
         return Math.max(0, Math.min(1, t))
    } 

    function lerp(a, b, t) {
        return (b - a) * t + a
    }
Enter fullscreen mode Exit fullscreen mode

Then when it's time to flip, we just do a for-next loop, which is the joy of using a stateful generator function when writing imperative animations that execute over multiple frames:

    function* flip(direction = 1) {
        for (let angle = 0; angle < 360; angle += 360 / flipFrames) {
            //Still perform the resize
            resize()
            //Make the item "grey" on the back  
            if (angle > 90 && angle < 270) {
                element.style.filter = `grayscale(1)`
            } else {
                element.style.filter = ``
            }
            element.style.transform = `${
                element.style.transform
            } rotate3d(0,1,0,${angle * direction}deg)`
            //Wait until next frame
            yield
        }
    }
Enter fullscreen mode Exit fullscreen mode

Miscellany

Getting the mouse position is achieved by adding a global handler to the document:

let x = 0
let y = 0

function trackMousePosition() {
    document.addEventListener("mousemove", storeMousePosition)
}

trackMousePosition()

function storeMousePosition(event) {
    x = event.pageX
    y = event.pageY
}

Enter fullscreen mode Exit fullscreen mode

And then using the effect is a case of wrapping MagnifyBox around the content:

           <Box mt={10} display="flex" flexWrap="wrap" justifyContent="center">
                {icons.map((Icon, index) => {
                    return (
                        <MagnifyBox key={index} mr={2} to={2.5} from={1}>
                            <IconButton
                                style={{
                                    color: "white",
                                    background: colors[index]
                                }}
                            >
                                <Icon />
                            </IconButton>
                        </MagnifyBox>
                    )
                })}
            </Box>
Enter fullscreen mode Exit fullscreen mode

Conclusion

Hopefully this example has shown how easy it is to write stateful animations using generator functions and js-coroutines!

Top comments (2)

Collapse
 
jwp profile image
John Peters

Mike;
Just wondering about the term co-routine. Are you using it as defined by React? I understand a co-routine to be an uninterruptible ''function" which keeps it's own stack. We see the async await pattern able to do this, but we are not able to interrupt at will with that pattern.

Collapse
 
miketalbot profile image
Mike Talbot ⭐ • Edited

Hey John,

I'm using the definition from back in my Unity programming days.

So I think your description is right, it's very like async/await which are coroutines by my definition, just the resumption criteria is "the next tick after something happened". My implementation here does 1 of 2 things. An update coroutine is being called back by requestAnimationFrame - so guaranteed every tick presuming the system isn't under heavy load. js-coroutines also does requestIdleCallback to run things in the "gaps" - in this when you yield it checks how much time is left and if there is enough, you get to go again straight away.

In Unity coroutines are called every game loop (so the Unity equivalent of requestAnimationFrame). I've just built a library to use that and the other principle, in conjunction with generator functions.

My implementation of both allows for termination because the promise returned by run or update has a terminate method that will stop it on the next time it's called.

I detail the actual code that runs the coroutines and a bit of a broader description in this article.

I am building examples in React, but it's all just plain Javascript and should work anywhere. It is the basic principle behind the upcoming Concurrent mode in React I believe - though not sure they'll be exposing the underlying power of the technique or wrapping it in other structures on top.