Creating a native animated driver for react-native-web: Traversing the graph and animating
I am trying to move the animations in react-native-web to the GPU. I explained it in the previous article: First steps to create a native driver for Animated using react-native-web. Any help is welcome!
Two days ago I had a vague idea of what was the graph about. I knew that I had to link the nodes and traverse it in order to create the animations, but the example we have in that article was just too simple to let me understand how the graph works. So, let’s create an animation a bit more complex:
In the codesandbox above we have 2 AnimatedValue
s and 1 interpolation.
When the component is mounted, the first animation starts and it scales the square during 4 seconds up to 1.5 its size.
2 seconds after the component was mounted (before the first animation ends), a second animation starts translating the square. Since we are using an interpolation of that animated value, the opacity of the square goes down to 0.5 also with the second animation.
Traversing the Animated graph of nodes
Internally, Animated treats values, interpolations, styles, views… everything as nodes in a graph. In our example we would get a graph like the one below:
We have 2 AnimatedValue
s, and we start 2 timing()
animations, so from the point of view of Animated we have 2 animations. From the point of view of the DOM, we can have more animations if some AnimatedValue
is used in more than one view. But in our case, we will have only 2 animations, and they are for the same DOM element.
We also have an interpolation, that might be seen as another AnimatedValue
, since we can use it like them in our Animated.View
´s style attribute. But in the graph, we see that the interpolation is an intermediate step between the value and the view, and it will be always related to the value that is interpolating, so we definitely only have 2 animations.
An interesting thing that we can see from the graph is that Animated is not only for styles, but we could also pass an AnimatedValue
to other props of our views and get them animated. Our driver can’t animate in the GPU anything but styles, so we can simplify the graph bypassing the Props
node since it will be always a style
prop for us.
Our goal from the graph traversing process is to get 2 animations declarations like the one above, that would have all the info we need to create the web animation:
{
view: <div className="square" />,
style: {
opacity: [1, 0.991, 0.979, 0.951, ...],
transform: {
translateY: [0, 1.321, 3.11, 5.84, ...]
},
}
duration: 2000,
iterations: 1
}
So we need to traverse the graph in 2 directions:
- First, from the value down to discover the view(s) we need to animate.
- Then, go up again calculating the frame values for every style property or transformation.
That’s exactly what we do in the NodesManager (partially implemented yet), the responsible for maintaining and traversing the graph to return this kind of information.
As we saw in the previous article, our driver is getting the animation’s progression in an array of frames when we call the start()
method. We need to transform that progress into new arrays of frames depending on the initial and end value of the animation, and the operations (like the interpolation) we will find when traversing the node.
All those calculations need to be done when we call Animated.timing().start()
. It looks like a big load of computation, but the performance seems to be ok so far.
Animating the view
Once we have the data that define the animation, we just need it to turn it into a web animation. I have tried 4 different approaches for the animation:
- Using
new Animation()
as described in the web animations API specs. - Using
new Animation()
, but using the second format to describe the keyframes as defined here. - Using
element.animate()
as described also in the web animations API specs. - Using CSS animations, trying to make it work in the maximum number of browsers.
All of them kind of work, this codesandbox below is showing the CSS variant:
As we can see, the animation is not exactly the same. We will need some more work to make it like the original one but I am not worried because I think the problems are fixable.
I will go through the approaches to highlight their pros and cons. Initial implementations
Using new Animation() — method 1
First thought I had when I started with the investigation was using the Web Animation API, this would be a nice chance to learn about it! So I started to read about it, and I see there was an Animation
class that we can instantiate to get an animation.
That felt natural to me since Animated’s animations have methods like start()
, stop()
, or reset()
, we will be able to map them to the web’s Animation
object easily, that contains methods like play()
or pause()
.
Unfortunately, the Animation
class seems not to have much support from any browser but Firefox (kudos to firefox for being in the edge!), so this method doesn’t work in other browsers.
The second problem of this method (and of any other web API approach) is that the animations are not summing up. I mean, when the translating animation starts, the scaling one stops, the square returns to its original size, and then it starts moving down. In order to run both animations at the same time, we would need to use the option composite: 'add'
when declaring the animation (see this great article about animation composition), and browser’s support for the composition at the moment is almost 0, only available in some nightly versions of Firefox.
Another common problem of all the methods is that when the animation ends, the square returns to its original state. We could use the fill: 'forwards'
option to make the animation stays in its latest state, but I am afraid that any re-render in the Animated.View
will reset the square to its origins. We will probably need to send an event to update the Animated.View
in order to store there the last state of the square when the animation finishes.
For this initial proof of concept, we are just taking all the keyframes that the driver receives and pass them to the new Animation()
constructor. In our example there are 3 animated style properties, 2 of them will receive 121 frames each, and the scaling one 242. One of my biggest concerns is that we need to go through all those frames again to format the values in a way that the Animation API can understand, and that can have a huge impact on performance. The format passed to the web animations is an array of style properties:
[
{ opacity: 1, transform: 'translate(0px)' },
{ opacity: 0.99, transform: 'translate(1.321px)' },
...
]
Maybe we could try to simplify the frames returned by the NodeManager
to get a version more friendly with our processors or try to guess the easing function from the original frames. Any idea here will be welcome.
Using new Animation() — method 2
This approach is using new Animation()
like the previous ones, so it has all its pros and cons, but the keyframe syntax is a bit different. Instead of passing an array of objects with the properties, we pass an object with the properties as its keys:
{
opacity: [1, 0.991, 0.979, 0.951, ...],
transform: [
'translateY(0px)',
'translateY(1.321px),
...
]
},
Firefox accepts this syntax, and it saves us from a lot of processing because we can use the arrays returned by the NodeManager
directly for properties that are not transform
(I must confess that CSS’s transform
property has always been a pain in the ass for me).
Again, this method is only working in Firefox ¯\_(ツ)_/¯.
Using element.animate()
This is a method supported by much more browsers. element.animate()
is basically a shortcut for (new Animation()).play()
and it returns an instance of an Animation object, with the Animation
methods. Weird that browsers that don’t support Animation
object creation return an instance of that class.
The keyframe syntax to make it work is the one seen in method 1, so it has all of the cons we saw before.
Using CSS animations
This is the method that has the best support among browsers, but it is also the one that feels hackier.
We need to build keyframe definitions as strings:
@keyframes ani_1 {
0% {
transform: translateY(0px);
opacity: 1;
}
0.8264462809917356% {
transform: translateY(0.077px);
opacity: 0.999871;
}
1.6528925619834711% {
transform: translateY(0.30px);
opacity: 0.99949;
}
...
}
Then insert them in <style>
tags in our page’s <header>
.
To start the animation, we need to update our view by changing view.style.animation
property directly:
view.style.animation = 'ani_1 2s'
We have the CSS property animation-play-state
to pause and restart our animation, and we can listen to events in the view to detect when the animations have finished:
view.addEventListener("animationend", AnimationListener, false)
It seems to have all that we need to build the animations of our native driver. It even as an extra feature that I haven’t tried yet, but looks so promising, we can define multiple animations in the same DOM element, and they will sum up:
view.style.animation = 'ani_1 4s, ani_2 2s'
So our problem about running multiple animations would be gone as soon as we add and remove animations respecting others.
What’s next?
A lot! First, decide what method we pick for the animations, seems that the CSS one is the favorite.
We need to complete the implementation of all the native driver methods, the ones to remove and unlink nodes, remove views, handling offsets (I hate that!) and emit events.
We need to implement all the operations shipped by Animated, like add
, multiply
or modulo
. They can be extracted from the implementation that comes with react-native-web, the same way our example extracts interpolate
.
We need to decide how to implement spring
or decay
animations.
A way of responding to Animated.Event
s in order to run animations in the GPU as the response to scroll events, the same way the native driver for mobile does.
Next article I’ll try the Web Animation API’s way of adding animations.
—
My current progress is in this branch if you want to give it a try locally from this point:
https://github.com/arqex/rnw-animated-module-playground/tree/dev