Creating a Star Field With Reanimated 2

3 March 2021

3 Mar 2021

3/3/21

Aranda Morrison

3 MIN READ

3 MIN READ

React Native includes the excellent Animated API. It has a declarative serialisable interface which means you pre-define your animations and they can be offloaded to run in native code (see useNativeDriver). This is necessary for smooth animations as code running in React Native’s Javascript thread can struggle to keep up with the screen’s refresh rate.

However, Reanimated 2 takes a different approach and runs code in a separate JavaScript context that runs on its own thread. This means you can write your own procedural animation code that results in smooth transitions.

Project Setup

Firstly make sure you have installed the Expo cli. Then create a new Expo app using the Reanimated v2 alpha template.

$ npx crna --template with-reanimated2

Be careful if experimenting with different Reanimated versions as Expo apps include specific versions of the native code for supported libraries, meaning different JS versions may not work. This tutorial is using Expo 40.0.0 and Reanimated 2.0.0-rc0.

TypeScript

Converting over to a TypeScript project is simple. If you’re not familiar with TypeScript, I recommend becoming familiar. It will save you time in the long run with autocompletion and linting. Run these commands to grab Expo’s default TypeScript config and convert the new app:

curl https://raw.githubusercontent.com/expo/expo/master/templates/expo-template-blank-typescript/tsconfig.json -o tsconfig.json
yarn add --dev typescript @types/react @types/react-native @types/react-dom
mv App.js App.tsx

Show me the stars

Let's start with something simple, a single star (spoiler: it's actually a square).

Replace the contents of App.tsx with the following:

import React from "react";
import { View } from "react-native";

// Props type for the star
interface StarProps {
  x: number;
  y: number;
}

// Star component
const Star: React.FC<StarProps> = (props) => {
  return (
    <View
      style={{
        left: props.x,
        top: props.y,
        width: 10,
        height: 10,
        backgroundColor: "white",
      }}
    />
  );
};

// Starfield app component
const Starfield: React.FC<{}> = () => {
  return (
    <View
      style={{
        flex: 1,
        backgroundColor: "black",
      }}
    >
      <Star x={100} y={100} />
    </View>

Admittedly, the results are underwhelming.

Make it move

Now we get to the interesting part. We’ll modify the above code to animate the star in a circular motion. Note that the Star component now returns an Animated.View (imported from react-native-animation). The useAnimatedStyle hook generates any style properties that should be animated. For performance, the static style properties are not included with the animated style.

import React, { useEffect } from "react";
import { View } from "react-native";
// Import the required types from Reanimated
import Animated, {
  Easing,
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withTiming,
} from "react-native-reanimated";

interface StarProps {
  x: number;
  y: number;
  // Stars now take a shared time value
  time: Animated.SharedValue<number>;
}

const Star: React.FC<StarProps> = (props) => {
  // useAnimatedStyle takes a function that runs our
  // procedural animation code and returns style properties.
  const animatedStyle = useAnimatedStyle(() => {
    const x = Math.sin(props.time.value * 5) * 50;
    const y = Math.cos(props.time.value * 5) * 50;
    return {
      left: props.x + x,
      top: props.y + y,
    };
  });

  // Return an Animated.View and compose the static style values
  // with the animated style values
  return (
    <Animated.View
      style={[
        {
          width: 10,
          height: 10,
          backgroundColor: "white",
        },
        animatedStyle,
      ]}
    />

Before the above code will work, we need to create the shared time value with the Reanimated useSharedValue hook. To animate the time, we use the React useEffect hook to trigger a repeating animation (withRepeat, withTiming), fire-and-forget style. It will loop after an hour, but chances are nobody will watch it for that long to see your animation pop! Don’t forget to pass this time value as a prop to the Star component.

const Starfield: React.FC<{}> = () => {
  // Create a shared time value
  const timeVal = useSharedValue(0);
  // Start a repeating animation that goes from 0 to 3600 in an hour
  const duration = 3600;
  useEffect(
    () => {
      timeVal.value = 0;
      timeVal.value = withRepeat(
        withTiming(duration, {
          duration: 1000 * duration,
          easing: Easing.linear,
        }),
        0,
        false
      );
    },
    // Tells useEffect to only trigger on mount instead of every render
    []
  );
  
  return (
    <View
      style={{
        flex: 1,
        backgroundColor: "black",
      }}
    >
      {/* Pass in the time value */}
      <Star x={100} y={100} time={timeVal} />
    </View>

It moves!

I want more

A single star moving is not particularly impressive, so let’s create a few more. Split the static star data into its own interface and add an id (more on that later). Create an array of star StarData and fill it with random positions.

// Split the static star data out from the props type
interface StarData {
  id: number;
  x: number;
  y: number;
}

interface StarProps extends StarData {
  time: Animated.SharedValue<number>;
}

// Build an array of star data and spread them across the screen
const stars: StarData[] = [];
const starCount = 50;
const windowWidth = Dimensions.get("window").width;
const windowHeight = Dimensions.get("window").height;

for (var i = 0; i < starCount; i++) {
  stars.push({  
    id: i,
    x: windowWidth / 2 + windowWidth * (Math.random() - 0.5),
    y: windowHeight / 2 + windowHeight * (Math.random() - 0.5),
  });
}

Finally, alter the animated style code slightly to incorporate the id property as an offset to the sin and cos input. This stops the stars from all circling in unison.

  const animatedStyle = useAnimatedStyle(() => {
    const x = Math.sin(props.time.value * 2 + props.id) * 50;
    const y = Math.cos(props.time.value * 2 + props.id) * 50;
    return {
      left: props.x + x,
      top: props.y + y,
    };
  });

And in the Starfield component, we need to iterate through the stars array and pass the star data in as props

  return (
    <View
      style={{
        flex: 1,
        backgroundColor: "black",
      }}
    >
      {stars.map((s) => (
        // The spread operator passes all StarData fields as individual props
        <Star key={s.id} time={timeVal} {...s} />
      ))}
    </View>
  );

Loop hitch due to video trimming

You call that a star field?

To make our moving squares a little more star-like, it’s a matter of animating them along the (virtual) z-axis and applying a perspective divide. Rather than add another z property to the stars, we can use use id / starCount to spread the stars evenly along the z axis. Since they are randomly placed in x and y, this doesn’t look too orderly. We also move the screen size calculation into the animated style function as it’s easier to apply the pseudo-3D maths to points in the [-0.5, 0.5] range.

// Stars positions should be spread evenly between -0.5 and 0.5.
// We apply the screen width/height adjustment in the animated style function.
for (var i = 0; i < starCount; i++) {
  stars.push({
    id: i,
    x: Math.random() - 0.5,
    y: Math.random() - 0.5,
  });
}

const Star: React.FC<StarProps> = (props) => {
  const animatedStyle = useAnimatedStyle(() => {
    const t = props.time.value;
    const { x, y } = props;

    // For the 3D effect, we can use the star's id as the Z value
    const z = props.id / starCount;
    
    // Animate the Z value by adding time.
    // Modulo 1 resets stars back to the start when they reach 1
    const depth = (z + t) % 1;

    // Calculate the perspective effect. 
    // The x and y value are divided by the star's depth
    const invZp = 0.4 / (1 - depth);

    return {
      transform: [
        // Apply window size and perspective to x and y
        { translateX: windowWidth * (0.5 + x * invZp) },
        { translateY: windowHeight * (0.5 + y * invZp) },
        // Scale the star based on it's depth
        { scaleX: depth },
        { scaleY

It looks 3D!

That’s all folks

If you’d like to see the full final App.tsx code, it’s available here. In future posts I plan to explore other features of Reanimated 2. In particular, createAnimatedComponent and useAnimatedProps can be combined to create interesting animated SVG effects.