Experiments with High Performance Animation in React Native

At Salesforce I work on a project that provides a fully functional charting library implemented in JavaScript which renders in HTML5 Canvas. I plan to port the library to use React Native for mobile platforms. Luckily only minor changes are needed to render static charts with built-in React Native components. However, archiving decent animation performance is quite tricky. This story is my experience and experiments to achieve high performance animation in React Native.

Along the way I learned tons from these blogs and recommend you read them as you dive deeper into building your own animations in React Native:

React Native Animations Using Animated API from Nader Dabit
React Native ART and D3 from Harry Wolff

Before we continue, I would like to define what “high performance animation” in terms of my project. The goal it to animate a world map of polygons to another set of polygons, like:

Zoom animation for a world map

All the polygons are drawn frame by frame. Each polygon will have around 100 vertices and there are hundreds of polygons. We will need to animate around 10000 vertices every frame, including native-side data exchange, interpolation, and rendering.

Drawing in React Native

The first step is to draw shapes. Here are few ways (libraries) I’ve tried:

  • Pseudo shapes — This only works for drawing rectangles and other basic shapes. It can be easily achieved by applying CSS styles to ReactNative.View without using any other libraries. It is easy to implement and ReactNative.View has decent native animation support. The downside is the drawing capability is very limited (cannot draw irregular shapes) and it will have some memory impact to have so many views. More details from MK Safi.
  • React Native ART — React Native comes with a built-in library which can be used to draw pretty much any shape. It has only one class called Path that can be used to construct any shape. On the native side, React Native ART translates paths to Core Graphics on iOS and Canvas on Android. This library can do almost any shape while the performance is bound with native graphics performance which degrades with many shapes and frequent updates. Memory-wise, since only the root view is constructed with the shapes having only shadow nodes, it is more efficient than pseudo shapes. I found this documentation useful.
  • React Native SVG — This was branched from React Native ART and provides an HTML SVG-like interface. As result, the backend uses Core Graphics and Canvas. I have read that this library is somewhat less performant than React Native ART, but I found similar performance between them. The difference is more about the API side (declarative API for SVG) than the performance.
  • React Native NanoVG — There is a library called NanoVG which provides an OpenGL implementation for drawing shapes with a nice interface similar to React Native ART — i.e. no need to worry about shaders and buffers! React Native NanoVG is a wrapper around this library. It has a built-in queuing mechanism for consecutive updates from multiple shapes. Therefore, it performs great when drawing many shapes. Note: There is another OpenGL binding for React Native which enables a more flexible OpenGL implementation without the interface.

Animation in React Native

In my exploration I tested three major methods (with some variations) for doing shape animation with React Native. Here are those methods and the pros & cons of each:

  1. Request Animation Frame — This is the most intuitive way to implement animation in React Native. React Native has implemented the window.requestAnimationFrame API from the web environment. As a result, almost nothing needs to change to get animation working from the web version. However, by using this implies most of the animation logic would happen in the JavaScript thread and re-render the entire view frame-by-frame. This could lead to a low FPS animation.
  2. Layout Animation — Another alternative is the layout animation. It has a very easy way to setup the animation by simply adding one line of code before you change the properties which will trigger a layout change. The whole animation is nicely implemented in the native side which has great performance. The big downside of this approach is it only supports layout properties (left, top, flex… but no width/height). For my use case, this is far from enough — even wrapping all the shapes inside views.
  3. Animated API — The Animated API is a set of declarative animation APIs. The big idea of React is to only update child elements when data changes. However, this conflicts with the animation use case. Most of the time when doing animation, we only want to update the properties of some node without modifying its children. To avoid the unnecessary updating, the Animated API uses animation variables that nodes can bind to. The main difference is we do not have to worry about implementing tweening (interpolation) logic ourselves. However, this API did not help with the performance until early this year when they introduced the concept of the native driver.

Animated API + JS Driver — This is the default option. All the interpolation, tweening, and updating will happen in the JavaScript thread. The updated views will be serialized and sent to the native UI thread — every frame. This might cause performance issues since most of the business logic also happens in the JavaScript thread. It would be quite challenging to perform the business logic, interpolation, and serialization/deserialization, all within 16ms (the maximum frame execution time). This approach is very flexible and we can change the shape every frame.

Animated API + Native Driver — This is a new mechanism introduced early this year to alleviate the problems mentioned above. Instead of communicating between the JS thread and the native thread every frame, it sends off the entire animation information, all at once, before the animation starts. With this approach, we save not only the serialization/deserialization time, but also improve the performance by doing all the interpolation in the native thread. It is expectedly more performant than the JS Driver. However, at the time of writing, the Native Driver only supports non-layout properties — transforms (translate, rotate, scale), color, and opacity. For example, if we have a shape with a 1px border, it cannot be transformed to have a larger width/height and retain the same border width without redrawing.

Experiments and Results

Request Animation Frame + JavaScript tweening

The first approach is to change nothing but use the same code from the web implementation — meaning everything is done in JavaScript without any help from React Native. The result is sadly to be around 1~2 FPS for under 100 shapes. Note: I did not try the setNativeProps for this approach, but it might help the performance to some extent.

Animated + Native Driver + Transforms

Even though this approach does not support the full drawing capability that I need, I did test the performance. The setup is to have the drawn shapes (with React Native ART) wrapped inside a ReactNative.View and set the style.transform to the views. The following numbers were collected from an iPhone 6 Plus and Samsung S2:

The performance is impressive with the Native Driver. However, due to the limitation of being able to use only transforms, I can only achieve the animation below (which fades out the scaled shapes.) The gaps between each country will also get scaled so the transformed shapes are not the same as the new shapes.

Zoom animation with fade in/out

This method has a significant impact on the memory footprint since there are two views with a shadow node per shape.

Animated + Hybrid Native Driver + Animated Shape (ART)

For this approach I still used the Native Driver while I found there is a trick to communicate between the JS thread and UI thread using setNativeProps. I listen to the animated variables, get the interpolated value, use the value to redraw the shape, and then use setNativeProps to update the native module. With this hybrid approach, even though we cannot take the full advantage of the Native Driver, we can morph the shape anyway we want. The following is the performance collected from the same devices:

We can clearly see the performance drops significantly since for each frame all the shapes redraw. I further profiled this setup with 500 shapes:

The first row is the JS thread which handles the serialization and the second row marked as main is the Native UI thread. The grey and white colors mark the interval for 16ms per frame. This indicates the bottleneck is Core Graphics rendering 500 shapes, not the communication payload from the JS thread to UI thread. I first tried with React Native ART and switched to React Native SVG while both give similar results.

Animated + Hybrid Native Driver + Animated Shape (OpenGL)

After realizing the bottleneck comes from re-drawing shapes, I switched to another library which has the drawing logic implemented in OpenGL. This approach has exactly the same setup but different drawing library. This library does not support setNativeProps so I had to add some glue code. The library does a good job queue consecutive render calls. As a result, the it performs much better than Core Graphics in terms of drawing hundreds of shapes per frame.

It works great! I did not measure the iOS here since it usually is more performant than Android. We achieved decent FPS on Android and I did not see a memory crisis here either. The only downside is we still need to communicate between the JS thread and the UI thread each frame. When the payload gets large, the serialization/deserialization time becomes the bottleneck and frames start dropping again.

Animated + Native Driver + Animated Shape (OpenGL)

This is an on-going work so I do not have the numbers yet. I have traced the React Native code and learned that even though it officially does not support properties other than transforms, it does interpolate the properties and send them to the native module as long as they have a UI view. However, we do not have a view for each shape — only shadow nodes. I think there are many ways to work around this constraint. We can potentially still fully leverage the Native Driver with limited modification in the native layer. If it works out (or in the future the React team supports it), we can have large payloads without reducing the animation performance like above.Conclusion


Several experiments prove that React Native does have the potential to support high performance animation with the right tools and some tuning. At the time of writing, the Native Driver was just released. Since this all is new, resources or documentation are scarce. I’d greatly appreciate comments and feedback so I can improve this resource over time!