How We Used the Best of React Native to Develop RollingBall Animation

Once upon a time, smooth animations were something you only expected from native apps. Cross-platform apps just couldn’t do it right. Every time you tried developing an animation, you would end up with something sad and slow. However, times have changed, and React Native has opened a door for animation development across platforms and devices.

Our Android team has been mastering animations in React Native using gl-react-native and  box2dweb, a JavaScript port of the well-known physics engine written in C++.

Here are our insights into developing our new RollingBall animation.

How it works under the hood

In a nutshell, the gl-react-native library consists of native iOS and Android modules, which encapsulate such functionality as a compilation of the vertex shader and fragment shader, texture binding, and so on. This means less time spent on routine code – you just need to write a fragment shader and all the other stuff will be done for you.

Let’s get started

Let’s start by installing the gl-react and gl-react-native for your newly created project. 

npm i --save gl-react
npm i --save gl-react-native

To proceed, it’s necessary to add a dependency in the iOS and Android parts of your project. Take a look at the readme for the gl-react-native repo for a step-by-step guide.

Write the first shader

A well-known “Hello, world!” analog in OpenGL is a triangle filled with a red-green-blue gradient. So let’s keep this tradition on React Native too.

RollingBall animation on android at Yalantis

As we’ve said, our main task is to create a fragment shader. For this purpose, we created a separate triangle.js file.

module.exports = `
 precision highp float;
 varying vec2 uv;
 const float PI = 3.1415926535897932384626433832795;

 bool equal(float a, float b) {
   return abs(a - b) < 0.001;
 }

 float angle(vec2 a, vec2 b) {
   return acos(dot(normalize(a), normalize(b)));
 }

 bool insideTriangle(vec2 uv) {
   vec2 a = vec2(0.0, 0.0) - uv;
   vec2 b = vec2(0.5, 1.0) - uv;
   vec2 c = vec2(1.0, 0.0) - uv;
   
   return equal(angle(a, b) + angle(b, c) + angle(a, c), PI * 2.0);
 }

 void main() {
   float red = uv.y;
   float green = (1.0 - uv.x) * (1.0 - uv.y);
   float blue = uv.x * (1.0 - uv.y);
   
   gl_FragColor = insideTriangle(uv) ? vec4(red, green, blue, 1.0) : vec4(1.0);
 }
`

Now let’s analyze the code written above. The main() function is the entry point of our fragment shader. It gets called for every pixel-sized fragment of our component; the main responsibility of this function is to define the color of a particular fragment.

The main difficulty of our task is to decide whether the current fragment is inside the triangle and whether it should it be colored.

We decided to check if the sum of the angles of the vectors composed by the current point and the vertices of our triangle is equal to 2π. If it is, then the fragment is inside the shape; otherwise, it’s not. The insideTriangle() function is responsible for this check. We assumed the triangle vertices to be (0.5, 1.0), (0.0, 0.0) and (1.0, 0.0). The uv variable is equal to the current fragment position. The dot() function calculates the cosine of the smallest angle between the normalized vectors.

The equal() function allows us to avoid the floating point numbers equality issue.

To check if the shader works according to our requirements, let’s import it into the index.android.js file:


import React, {Component} from "react";
import {View, AppRegistry} from "react-native";
import {Surface} from "gl-react-native";
import GL from "gl-react";
const Dimensions = require('Dimensions');
const window = Dimensions.get('window');
import Triangle from './triangle';

export default class GLExample extends Component {
 render () {
   return <View>
     <Surface width={window.width} height={window.height}>
       <GL.Node
         shader={{
           frag: Triangle
         }}
       />
     </Surface>
   </View>;
 }
}

AppRegistry.registerComponent('GLExample', () => GLExample);

Pay attention to the use of our shader. It should be set to the flag field of the shader attribute of the GL.Node component.

Use this command to run the application:

react-native run-android

animation for Android by Yalantis

 

Redraw the component

According to the definition of “animation,” this term refers to the process of making the illusion of motion and the illusion of change by means of the rapid display of a sequence of images. We need to continuously re-render our component to create an animation; in other words, we need to force the component to continuously update itself right after the previous update.

How can we find out when a component has been updated? The answer is in component’s lifecycle.

The componentDidMount() function is called when a component is rendered for the first time, and the componentDidUpdate() function lets us know when it’s re-rendered.

To force a component to refresh, I used the forceUpdate() function.

Let’s remake our previous js file to force it to call the render() function all the time.

It’s obvious that an animation is about the continuous, quick change of frames. So our next goal is to make our GLExample component refresh all the time. We can achieve this effect by calling the forceUpdate() function after each previous update. How can we get notified about each update? According to the React Native component lifecycle, the componentDidUpdate() function will be called after each update. The componentDidMount() function is also needed, since it’s called when the component is rendered for the first time.

Let’s update our component to fit these requirements.

import React, {Component} from "react";
import {View, AppRegistry} from "react-native";
import {Surface} from "gl-react-native";
import GL from "gl-react";
const Dimensions = require('Dimensions');
const window = Dimensions.get('window');
import Triangle from './triangle';

export default class GLExample extends Component {
 render () {
   console.log('I`m redrawn!');
   return <View>
     <Surface width={window.width} height={window.height}>
       <GL.Node
         shader={{
           frag: Triangle
         }}
       />
     </Surface>
   </View>;
 }

 componentDidUpdate() {
   setTimeout(() => {
     this.forceUpdate();
   }, 0);
 }

 componentDidMount() {
   this.componentDidUpdate()
 }
}

AppRegistry.registerComponent('GLExample', () => GLExample);

Due to the setTimeout() function call, we’ll avoid too frequent component updates, which could cause a crash. To review the logs from the device, use the react-native log-android command.

05-03 10:56:33.001   665   782 I ReactNativeJS: I`m redrawn!
05-03 10:56:33.011   665   782 I ReactNativeJS: I`m redrawn!
05-03 10:56:33.021   665   782 I ReactNativeJS: I`m redrawn!
05-03 10:56:33.030   665   782 I ReactNativeJS: I`m redrawn!
05-03 10:56:33.040   665   782 I ReactNativeJS: I`m redrawn!
05-03 10:56:33.049   665   782 I ReactNativeJS: I`m redrawn!
05-03 10:56:33.059   665   782 I ReactNativeJS: I`m redrawn!
...
05-03 10:56:33.934   665   782 I ReactNativeJS: I`m redrawn!
05-03 10:56:33.945   665   782 I ReactNativeJS: I`m redrawn!
05-03 10:56:33.956   665   782 I ReactNativeJS: I`m redrawn!
05-03 10:56:33.967   665   782 I ReactNativeJS: I`m redrawn!
05-03 10:56:33.977   665   782 I ReactNativeJS: I`m redrawn!
05-03 10:56:33.987   665   782 I ReactNativeJS: I`m redrawn!
05-03 10:56:33.998   665   782 I ReactNativeJS: I`m redrawn!

The component now updates every ~10 milliseconds, which is quite frequently for an animation.

Provide rendering stability

To create a smooth animation, it’s enough to achieve re-rendering every ~16 milliseconds (1000 ms / 60 frames ≈ 16 fps). So there’s no need to spend extra device resources on more frequent updates. Our solution needs to guarantee fps stability.

componentDidUpdate() {
   var now = Date.now();
   var diff = now - lastRendered;
   lastRendered = now;
   var timeout = diff >= 16 ? 0 : 16 - diff;
   setTimeout(() => {
     this.forceUpdate();
   }, timeout);
 }


It’s physics time

As an example of a quite interactive animation, we decided to implement a volleyball that bounces off a user’s clicks. To make the volleyball physically correct, we used the box2dweb library – a JavaScript port of the well-known C++ box2d physics engine.

 

RollingBall animation on Android

After the library is installed by running:

npm install box2dweb

We can import it for further use:

const Box2D = require('box2dweb');

We created a separate ballBody.js file to implement all the logic of creating the ball.

const Box2D = require('box2dweb');
const b2World = Box2D.Dynamics.b2World;
const b2Vec2 = Box2D.Common.Math.b2Vec2;
const b2BodyDef = Box2D.Dynamics.b2BodyDef;
const b2Body = Box2D.Dynamics.b2Body;
const b2FixtureDef = Box2D.Dynamics.b2FixtureDef;
const b2CircleShape = Box2D.Collision.Shapes.b2CircleShape;

var body;

export default class BallBody {
 constructor(position, radius, world) {
   var fixtureDef = new b2FixtureDef();
   fixtureDef.shape = new b2CircleShape();
   fixtureDef.density = 2;
   fixtureDef.shape.SetRadius(radius);

   var bodyDef = new b2BodyDef();
   bodyDef.type = b2Body.b2_dynamicBody;
   bodyDef.position.Set(position[0], position[1]);

   body = world.CreateBody(bodyDef);
   body.CreateFixture(fixtureDef);
   body.SetAngularDamping(500.0);
 }

 applyImpulse(impulse) {
   body.ApplyImpulse(impulse, body.GetPosition());
 }

 position() {
   return [body.GetPosition().x, body.GetPosition().y];
 }

 angle() {
   return body.GetAngle() * (180.0 / Math.PI);
 }
}

The code above is pretty easy to understand for those who are already acquainted with the original box2d engine or its ports written in any language. We have a World class, which encapsulates all physics logic, and to make something exist we need to create it using a World instance. Like in the real world, box2d bodies also have properties like fixture, shape, density, body type, and position.

We also implemented some functions like applyImpulse(), position(), and angle() to make it easier to control the ball outside the class.

And we want to keep the ball moving inside the screen, so some walls are needed.

const Box2D = require('box2dweb');
const b2World = Box2D.Dynamics.b2World;
const b2Vec2 = Box2D.Common.Math.b2Vec2;
const b2BodyDef = Box2D.Dynamics.b2BodyDef;
const b2Body = Box2D.Dynamics.b2Body;
const b2FixtureDef = Box2D.Dynamics.b2FixtureDef;
const b2PolygonShape = Box2D.Collision.Shapes.b2PolygonShape;

export default class Wall {
 constructor(startPosition, endPosition, world) {
   var fixtureDef = new b2FixtureDef();
   fixtureDef.shape = new b2PolygonShape();
   fixtureDef.shape.SetAsEdge(startPosition, endPosition);

   var bodyDef = new b2BodyDef();
   bodyDef.type = b2Body.b2_staticBody;
   bodyDef.position.Set(startPosition.x, startPosition.y);

   var body = world.CreateBody(bodyDef);
   body.CreateFixture(fixtureDef);
 }
}

The wall setup is really similar to the previous class setup except for the body type and shape.

The fragment shader created earlier isn’t appropriate for our walls, so let’s write a new one.

module.exports = `
 precision highp float;
 varying vec2 uv;
 uniform float ratio;
 uniform float radius;
 uniform vec2 location;
 uniform float angle;
 uniform sampler2D image;

 void main () {
float sin_factor = sin(angle);
float cos_factor = cos(angle);
mat2 rotation = mat2(cos_factor, sin_factor, -sin_factor, cos_factor);
vec2 ballLocation = location * rotation;
    vec2 currentLocation = vec2(uv.x, uv.y / ratio) * rotation;
    vec2 result = (ballLocation - currentLocation + radius) / (radius * 2.0);
    gl_FragColor = texture2D(image, result);
 }

In contrast to the triangle shader, the new shader is pretty difficult to understand, since it’s responsible for texture rendering and rotation (as a result of the vertex shader encapsulation). Now we pass the screen width-to-height ratio, the radius of the ball, its location, the rotation angle, and the ball image to the fragment shader.

The texture2D() function returns the color of the texture sample using the coordinates of this sample. To take into consideration the angle of rotation, it’s necessary to multiply the ball’s location and the current fragment’s location by the rotation matrix.

To work with the engine, it’s necessary to set up the World initially and then continuously move it in order to calculate the new positions of the existing bodies. Let’s create a start() and move() function for these purposes.

function start() {
 var gravity = new b2Vec2(0.0, -2000000.0);
 world = new b2World(gravity, true);
 ball = new BallBody(someInitialPosition, radius, world);

 // vertical walls
 new Wall(new b2Vec2(0, 0), new b2Vec2(0, 2.0), world);
 new Wall(new b2Vec2(0.5, 0), new b2Vec2(0.5, 2.0), world);

 // horizontal walls
 new Wall(new b2Vec2(0, 0.03), new b2Vec2(1, 0.03), world);
 new Wall(new b2Vec2(0, 0.5 / ratio), new b2Vec2(1, 0.5 / ratio), world);
}

function move() {
 world.Step(step, 1, 1);
}

Interact with users

Almost every animation responds to a user’s touch. For touch response functionality, we chose PanResponder, which simplifies the handling of touch events.

componentWillMount() {
   this._panResponder = PanResponder.create({
     onStartShouldSetPanResponder: (evt, gestureState) => true,
     onStartShouldSetPanResponderCapture: (evt, gestureState) => true,
     onMoveShouldSetPanResponder: (evt, gestureState) => true,
     onMoveShouldSetPanResponderCapture: (evt, gestureState) => true,
     onPanResponderGrant: (evt, gestureState) => {
       this.handleTouch(evt);
     }
   });
 }

 handleTouch(event) {
   var touchX = event.nativeEvent.locationX / window.width;
   var touchY = 1.0 - event.nativeEvent.locationY / window.height;

   if (this.distance(touchX, touchY / ratio, ball.position()[0], ball.position()[1]) < radius) {
this.kickBall(touchX > ball.position()[0] ? -50.0 : 50.0, 500.0);
   }
 }

 kickBall(x, y) {
   ball.applyImpulse(new b2Vec2(x, y));
 }

 distance(x1, y1, x2, y2) {
   return Math.sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1));
 }

After each touch, we check whether the touch is within the ball radius using the distance() function. If so, we call the applyImpulse() function to make our ball bounce away.

Eventually, we will launch the application!

 

rollingball animation Yalantis Android

You can also take a look at the source code on GitHub.

 

4.3/ 5.0
Article rating
14
Reviews
Remember those Facebook reactions? Well, we aren't Facebook but we love reactions too. They can give us valuable insights on how to improve what we're doing. Would you tell us how you feel about this article?