DEV Community

Mateo Hrastnik
Mateo Hrastnik

Posted on

Implementing Gravity And Collision Detection In React Native

Implementing Gravity And Collision Detection In React Native

Hey you! Wanna hear about how I coded this thing:

Animated bubbles with collision detection

You do?! Well strap in cause it's about to start.

So, what is this thing you ask? Well it's an animation of some bubbles running in a React Native app. All the bubbles start somewhere off screen and gravitate towards the center of the screen. The "cool" thing is they collide and iteract with each other. The other "cool" thing is that this animation is ran completely on the native UI thread so our JS code can react to our inputs.

All this is running inside a React Native app, even better - it's running on Expo so you can run this code on Android, iOS and the web and it will look the same.

So how do we start coding this thing? Well I don't know about you, but here's how I went about it.

The prototype

Well first of all, that "cool" thing about the bubbles colliding and reacting to each other is not so simple to code. So instead of trying to code the animation directly in React Native, let's first build a prototype of the animation.

The tool I chose for the prototype is p5.js. p5 is a simple tool to create animations and visual arts, and it even has an online editor that you can use to test your animations. It provides a setup function and a draw function. The setup function is ran only once an can be used to initialize some data and the draw function runs every frame and should draw the scene and update it for the next frame.

So, the idea is to use p5 to create a working prototype of the animation. Then when we are sure it's working we'll code up the solution in React Native.

Positioning circles

Let's first create some circles and draw them in the center of the screen. We'll use the setup funciton to initialize some circles and the draw function to display them on screen.

const canvasWidth = 600;
const canvasHeight = 600;

const numCircles = 10;
const circleDiameter = 30;
let circles;

function setup() {
  createCanvas(canvasWidth, canvasHeight);
  circles = [];
  for (let i = 0; i < numCircles; i++) {
    circles.push({ x: 0, y: 0 });
  }

  stroke("transparent");
  fill("#ff0000");
}

function draw() {
  background(220);

  for (const c of circles) {
    circle(c.x + canvasWidth / 2, c.y + canvasHeight / 2, circleDiameter);
  }
}
Enter fullscreen mode Exit fullscreen mode

Note that we use the coords 0,0 to put the circles in the middle of the screen, but since p5 internaly draws 0,0 at the top left of the canvas, we must offset all the circles by canvasWidth / 2 and canvasHeight / 2 when we're drawing them. This is the output.

All circles in the middle of the screen

Now, that's great and all, but we initially want to position the circles outside of the visible canvas. This means that we have to offset each circle by half of the diagonal of the canvas + half circle radius to be sure that the circle will not be visible.

In what direction will we move the circles though? My implementation was to move each circle in it's own direction by dividing 360 degrees evenly by the number of circles and moving each circle in the direction defined by that angle * circle index. We'll add some randomization to the distance and angle to make it a bit more sporadic and voila

function setup() {
  createCanvas(canvasWidth, canvasHeight);

  const diagonal = Math.hypot(canvasWidth, canvasHeight);
  const diagonalHalf = diagonal / 2;

  circles = [];
  const angle = (2 * Math.PI) / numCircles;
  for (let i = 0; i < numCircles; i++) {
    const randomOffsetAngle = random(-angle * 0.4, angle * 0.4);
    const randomOffsetDistance = random(0, circleDiameter);

    const distance = diagonalHalf + circleDiameter + randomOffsetDistance;
    const currentAngle = angle * i + randomOffsetAngle;
    const x = Math.sin(currentAngle) * distance;
    const y = Math.cos(currentAngle) * distance;

    circles.push({ x, y });
  }

  stroke("transparent");
  fill("#ff0000");
}

function draw() {
  background(220);

  for (const c of circles) {
    circle(c.x + canvasWidth / 2, c.y + canvasHeight / 2, circleDiameter);
  }
}
Enter fullscreen mode Exit fullscreen mode

initial position of circles

Here's how it looks for now. Note that the image here is a bit zoomed out so that we actually see the circles. The code above should actually render the circles outside of the viewport so they are not visible.

Gravity

Now to add the gravity, we'll first add an update() function to the draw loop, and there we have to move each ball a just a bit in the direction of the center (0, 0). Lucky for us the center is in (0, 0) so we can just use the negative x and y position of the ball to get there.

function draw() {
  /* ... stays the same ... */
  update();
}

function update() {
  for (let i = 0; i < circles.length; i++) {
    const circle = circles[i];

    circle.x += -circle.x * 0.001;
    circle.y += -circle.y * 0.001;
  }
}
Enter fullscreen mode Exit fullscreen mode

Boom! That was easy!

circles moving to the center of canvas

Colision detection

So here comes the hard part. We actually have to implement collision detection.

This means that in every frame we have to detect if two circles are overlapping. If two circles overlap we have to resolve the collision somehow. We're gonna use a primitive method - if two circles are overlapping just move the circles away from each other so they're not overlapping anymore. Sounds simple right?

First we have to know if they're overlapping. We can detect that by checking if the distance between two circle centers is less than the the sum of their radii. Since all the circles have the same radius we can simply check if the distance between two circles is less than the diameter of a single circle. Click on the image below to open a small Codepen demonstrating this.

Open Codepen

Now that we know when two circles are overlapping, we have to move them away from each other. We do that by first calculating the overlap distance. This is the length shown with the black line on the image below.

Circle intersection with overlap

Once we have the overlap distance we use some simple math to move each circle away from the other by half of that distance. Note that this can still result in circles overlapping when there's a lot of circles, but for our purposes it's will look just fine.

So let's transform all this to code.

function update() {
  // ... gravity stuff goes here ...

  // Colision detection
  for (let i = 0; i < circles.length; i++) {
    for (let j = i; j < circles.length; j++) {
      const circleA = circles[i];
      const circleB = circles[j];

      const dx = circleB.x - circleA.x;
      const dy = circleB.y - circleA.y;
      const distanceBetweenCenters = Math.hypot(dx, dy);
      const areOverlapping = distanceBetweenCenters < circleDiameter;

      if (areOverlapping) {
        const overlapDistance = circleDiameter - distanceBetweenCenters;
        const percentOverlap = overlapDistance / circleDiameter;

        const halfPercent = percentOverlap * 0.5;

        circleA.x -= dx * halfPercent;
        circleA.y -= dy * halfPercent;

        circleB.x += dx * halfPercent;
        circleB.y += dy * halfPercent;
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This produces the following output. Note that I added the borders and increased the size of the circles.

Circles with collision

Alright! We did it! All that's left is to recreate the effect in React Native

React Native implementation

Now that we know that our code works we have to somehow move it into React Native. And if we want smooth 60fps animations, we have to use Reanimated.

I'll assume you already know how to set up a basic React Native project so we're gonna skip that part. Let's create a directory with all the files we'll need. In the root directory we create the following structure:

─── AnimatedCircles
    ├── AnimatedCircles.component.js
    ├── Circle.component.js
    └── useGravityAnimation.hook.js
Enter fullscreen mode Exit fullscreen mode

The AnimatedCircles component will serve as an entry point to our animation.

The App.js component will simply render our animated component like so

// App.js

import React from "react";
import { AnimatedCircles } from "./AnimatedCircles/AnimatedCircles.component";

export default function App() {
  return <AnimatedCircles />;
}
Enter fullscreen mode Exit fullscreen mode

We'll create a Circle component that will render the circle and recieve two animated values for translating it's x and y values.

import React from "react";
import Animated from "react-native-reanimated";

export const circleDiameter = 128;
const circleRadius = circleDiameter / 2;

export const Circle = ({ translateX, translateY }) => {
  return (
    <Animated.View
      style={{
        transform: [{ translateX }, { translateY }],
        position: "absolute",
        width: circleDiameter,
        height: circleDiameter,
        borderRadius: circleRadius,
        backgroundColor: "#ff0000"
      }}
    ></Animated.View>
  );
};
Enter fullscreen mode Exit fullscreen mode

Notice how we exported the circleDiameter so we can later reference it in other files. We also set the position attribute to absolute so the circles can overlap.

Next we'll define the AnimatedCircles component. This will render the canvas on which we'll animate the circles (a blank View) and measure the canvas' width and height so that we can calculate the diagonal. We first render a blank View and grab it's layout event to measure the size and in the next frame we'll actually render the circles

// AnimatedCircles/AnimatedCircles.component.js

import React, { useCallback, useState } from "react";
import { StyleSheet, View } from "react-native";

import { useGravityAnimation } from "./useGravityAnimation.hook";
import { Circle } from "./Circle.component";

const S = StyleSheet.create({
  flex: { flex: 1 },
  wrap: {
    flex: 1,
    justifyContent: "center",
    alignItems: "center",
    overflow: "hidden"
  }
});

export function AnimatedCircles() {
  const [viewDimensions, setViewDimensions] = useState(undefined);
  const handleLayout = useCallback(event => {
    const { width, height } = event.nativeEvent.layout;
    setViewDimensions({ width, height });
  }, []);

  const isCanvasReady = viewDimensions !== undefined;

  return (
    <View style={S.flex} onLayout={handleLayout}>
      {isCanvasReady && (
        <AnimatedCirclesInner dimensions={viewDimensions} />
      )}
    </View>
  );
}

export function AnimatedCirclesInner({ dimensions }) {
  const circles = useGravityAnimation(dimensions);

  return (
    <View style={S.wrap}>
      {circles.map((p, index) => {
        return <Circle key={index} translateX={p.x} translateY={p.y} />;
      })}
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

All the circles are initially centered using justifyContent: 'center' and alignItems: 'center'.

The inner component passes the dimensions to the useGravityAnimation hook where the magic happens and gets back an array of animated x and y offsets for each circle.

The code for the gravity animation hook is the p5 code from the prototype translated into Reanimated. It has a setup function and a draw function where the magic happens.

// AnimatedCircles/useGravityAnimation.hook.js

export const useGravityAnimation = dimensions => {
  const circles = useSetup(dimensions);
  useDraw(circles);

  return circles;
};
Enter fullscreen mode Exit fullscreen mode

The setup function more or less works exactly the same as the p5 implementation - it calculates some angles and distances and initializes the positions of the circles. The difference is that we use Animated.Values for x and y values so that we can actually animate them. Another difference is that here we return the circles from the function in contrast to defining a global circles variable in p5.

// AnimatedCircles/useGravityAnimation.hook.js

const useSetup = dimensions => {
  const circles = useMemo(() => {
    const { width, height } = dimensions;
    const diagonal = Math.hypot(width, height);
    const diagonalHalf = diagonal / 2;
    const circles = [];

    const angle = (2 * Math.PI) / numCircles;
    for (let i = 0; i < numCircles; i++) {
      const randomOffsetAngle = random(-angle * 0.4, angle * 0.4);
      const randomOffsetDistance = random(0, circleDiameter);

      const distance = diagonalHalf + circleDiameter + randomOffsetDistance;
      const currentAngle = angle * i + randomOffsetAngle;
      const x = Math.sin(currentAngle) * distance;
      const y = Math.cos(currentAngle) * distance;

      circles.push({ x: new Value(x), y: new Value(y) });
    }
    return circles;
  }, [dimensions]);

  return circles;
};
Enter fullscreen mode Exit fullscreen mode

We wrap the calculation in a useMemo call for optimization purposes.

Looks great! Now all that's left to do is implement the useDraw loop and update the circle positions.

If you used Reanimated before, you'll notice that it doesn't have any looping primitives. for loops and while loops don't exist in Reanimated. The only thing we have is Clocks, and we can use those to keep the animation running forever (or until some condition is met).

So let's start by defining an infinite loop.

// AnimatedCircles/useGravityAnimation.hook.js

const useDraw = circles => {
  const nativeCode = useMemo(() => {
    const clock = new Clock();

    const nativeCode = [
      cond(clockRunning(clock), 0, startClock(clock)),
      clock
    ];
  }, []);

  useCode(() => nativeCode, [nativeCode]);
}
Enter fullscreen mode Exit fullscreen mode

This loop actually does nothing, but it's being evaluated on the native thread. You can think about the nativeCode array as a list of commands to be ran by the native thread that we send across the bridge using the useCode hook.

Currently we have two commands - the first one starts the clock if it's stopped and the other one just evaluates the clock. We don't actually use the clock to drive an animation value. We just evaluate it so that Reanimated keeps running the loop. You see, the way Reanimated works is by evaluating all commands on every frame, and if it evaluates a clock in any command, it schedules another evaluation of for the next frame. This is what drives our infinite loop.

Now that we have the loop we can implement the actual animation. Let's first add the gravity part

// AnimatedCircles/useGravityAnimation.hook.js

const useDraw = circles => {
  const nativeCode = useMemo(() => {
    const clock = new Clock();

    const nativeCode = [cond(clockRunning(clock), 0, startClock(clock)), clock];

    // gravity. We push cirlces to 0, 0
    for (let i = 0; i < circles.length; i++) {
      const circle = circles[i];

      nativeCode.push(set(circle.x, add(circle.x, multiply(circle.x, -0.01))));
      nativeCode.push(set(circle.y, add(circle.y, multiply(circle.y, -0.01))));
    }

    return block(nativeCode);
  }, [circles]);

  useCode(() => nativeCode, [nativeCode]);
};

Enter fullscreen mode Exit fullscreen mode

We loop over the circles and push two set commands to the native code list.

Compare the code of the for loop to our original p5 implementation and you can see how similar they look.

  for (let i = 0; i < circles.length; i++) {
    const circle = circles[i];

    circle.x += -circle.x * 0.001;
    circle.y += -circle.y * 0.001;
  }
Enter fullscreen mode Exit fullscreen mode

Similarly we implement the collision detection

// AnimatedCircles/useGravityAnimation.hook.js

const useDraw = circles => {
  const nativeCode = useMemo(() => {

    // ... gravity stuff goes here 

    for (let i = 0; i < circles.length; i++) {
      for (let j = i; j < circles.length; j++) {
        const circleA = circles[i];
        const circleB = circles[j];

        const dx = sub(circleB.x, circleA.x);
        const dy = sub(circleB.y, circleA.y);
        const distanceBetweenCenters = sqrt(
          add(multiply(dx, dx), multiply(dy, dy))
        );

        const areOverlapping = lessThan(distanceBetweenCenters, circleDiameter);

        const overlapDistance = sub(circleDiameter, distanceBetweenCenters);
        const percentOverlap = divide(overlapDistance, circleDiameter);
        const halfPercent = multiply(percentOverlap, 0.5);

        nativeCode.push(
          cond(areOverlapping, [
            set(circleA.x, sub(circleA.x, multiply(dx, halfPercent))),
            set(circleA.y, sub(circleA.y, multiply(dy, halfPercent))),
            set(circleB.x, add(circleB.x, multiply(dx, halfPercent))),
            set(circleB.y, add(circleB.y, multiply(dy, halfPercent)))
          ])
        );
      }
    }

    return block(nativeCode);
  }, [circles]);

  useCode(() => nativeCode, [nativeCode]);
};
Enter fullscreen mode Exit fullscreen mode

We already went over how this works in the p5 implementation, the difference here is that we have to push the code to the native thread.

Notice how we only have to push the cond command and since it depends on the other commands everything gets sent over the bridge.

So, once we put all this together we can start the project and see what we made.

Final animation

Conclusion

I hope you learned something from all of this. Maybe a bit about Reanimated, maybe about physics simulations, maybe about p5.

I personally got a ton of new knowledge while creating this. At first this seemed like a really complex problem and I wasn't sure if it's even possible, but breaking it down into smaller problems was the key to solving this.

So what's next? Well, I implemented this animation as a part of a project where it was used as a menu similar to Apple watch, but you could use it as a cool background, screen transition or even in a game. Be creative!

Apple Watch menu

Adding interactivity, transitions and advanced animations with Reanimated has never been simpler. I have to give a shoutout to William Candilon's excelent Redash library that makes it even easier. If you like React Native and animations you should subscribe to his YouTube channel

Thank you for reading. That's all I got!

Link to Github Repo https://github.com/hrastnik/react-native-gravity-circles

Top comments (1)

Collapse
 
timicodes profile image
Timi Tejumola

Thanks for this beautiful solution. In addition to this, I would like to keep the balls moving a few steps out after collision. I want this to happen infinitely like in this image.