CS 466 - React Native Canvas

Due: 2019-11-13

Goals

  • Learn how to draw custom content using the canvas
  • Learn about requestAnimationFrame for animation
  • Learn how to handle page focus

Prerequisites

  1. Accept the assignment on our Github Classroom.
  2. Download the git repository to your local computer. This code is the solution to the last practical plus some additional data files.
  3. Update the package.json file with your name and email address.
  4. Install the package dependencies with npm install

Assignment

In this practical, we are going to implement the radar screen in our Ghost Hunter app. We will build a custom component that shows a mock radar screen, complete with sweeping tracer line. We will also have a collection of ghosts roaming around. When the radar sweeps by the ghosts, they are momentarily lit up. Tapping on the ghost will catch it. In this practical, we will focus on the radar display.

Radar screen

To implement this, we are going to use the Expo 2D Context. This provides an implementation of the Canvas API on top of the GLView component.

In all honesty, the status of this project is unclear. It doesn't appear as part of the Expo documentation set, and it is currently failing its continuous integration test (from eight months ago). However, it seems less buggy than the Processing implementation that is actually linked in the GLView documentation (which I would have personally preferred).

Part 1: Make a new component

Our radar screen is going to be fairly extensive, so we are going to make a new component to house it. Create a new component called GhostRadar and put it in a file called components/GhostRadar.js. Don't forget to import React at the top of the file.

The new component should return only one thing: a GLView. You will need to import this from expo-gl as well as Expo2DContext from expo-2d-context. Note that Expo2DContext is the default export from its module, while GLView is not.

import { GLView } from 'expo-gl';
import Expo2DContext from 'expo-2d-context';

Replace the Button in RadarScreen with an instance of this component.

Update the styles in RadarScreen to match the following:

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#000',
    alignItems: 'center',
    justifyContent: 'center',
  },
  radar:{
    flex: 1,
    width: "90%",
    height: "90%",
    borderStyle: "solid",
    borderColor:'white',
    borderWidth:1
    }
});

This container style has been modified to make the background of the view black. We also have created a style for the actual radar screen itself, sizing it so it is not quite tight to the edges of the view. There is also a white boarder drawn around the component so you can see where it is (feel free to delete this when after you see how it fills the space).

Apply the style to the new GhostRadar component you added to RadarScreen.

Styles don't trickle down automatically, so in GhostRadar, you will need to extract the style prop out of props and apply it to the GLView manually. Why not just declare the style down in GhostRadar? This gives us flexibility to style our new component based on where we put it. We could decide to add more controls to RadarScreen, for example, and squeeze the GhostRadar display to a smaller region.

You can run this, but at this point you shouldn't get anything other than a white border around the component on a black background.

Part 2: Set up the rendering context

Working with our rendering context is going to be quite different from what we have done with React so far. We are stepping back from React's declarative style and returning to an imperative one.

The GLView component takes a prop called onContextCreate. This prop takes a function to be called when the view is finished setting up. This function should accept an OpenGL ES rendering context as an argument. The idea is that this function then is responsible for drawing whatever you want to show in this view.

Because we are going to eventually animate our view, we are going to expand this a little bit to match a common pattern. We are going to write a setup function that is called once at the start of the process and a renderer function that will be responsible for the actual drawing and can be called repeatedly.

To set this up, we are going to write three things into the GhostRadar component function. First, add a new variable called ctx. This will hold our rendering context. You can leave it undefined initially. Second, add two functions, one called setup and the other called renderer. setup should take one argument, which you should call gl. The renderer function will take no arguments.

Set the onContextCreate prop of the GLView to setup. Now, when the view is initialized, it will call the setup function with the OpenGL context.

Of course, we don't want an OpenGL context, so on the first line of setup, write ctx = new Expo2DContext(gl);. This builds the 2D canvas context on top of the OpenGL context and saves it as a local variable to our component. Then call renderer().

Part 3: Drawing the radar screen

In most drawing APIs, the coordinate system has (0,0) in the upper left corner, the x-axis advancing to the right, and the y-axis increasing down. The canvas API is no different.

For our purposes, it would be convenient for the origin to be in the center of the screen. We can find the width and height of the canvas with ctx.width and ctx.height, so the center will be at (ctx.width / 2, ctx.height/2).

To move the origin, we are going to apply a transform. When we apply a transform, it is applied to all future drawing instructions (until we change the transform again). We are going to translate to the middle of the screen. All future drawing commands will thus include this movement, so it is as if we moved the origin to the center.

When we perform transformations, it is usually good to save the current state of the drawing context and restore it when we are done, so add the following to renderer:

ctx.save(); // save the current rendering context
ctx.translate(ctx.width / 2, ctx.height/2); // move the origin to the middle

// do drawing in here

ctx.restore(); // restore to previous state

The next piece of boilerplate that we need is a call to ctx.flush(). This should be placed in renderer after all drawing commands. This command tells the context that we are done tweaking the drawing and it is ready to be displayed on the screen.

Before we are ready to start actually drawing anything, we are going to calculate the maximum radius of our display, since we will use it everywhere. The maximum radius should be the radius of a circle that almost, but not quite, fills the smaller dimension of the view (in this case the width). Add a line to renderer that finds the smaller of width and height, divides that number by 2 and subtracts 10 from it (you may want Math.min). Save this in a variable imaginatively called radius.

Draw the background

We are ready to draw the background rings for the radar display. Since we will have a lot going on, we will create a function that just handles this one task. Create a function called drawRadar outside of the component (this will be a "pure" function, so it won't need access to anything inside of the component). This function should take in two arguments: the drawing context (ctx) and the radius (radius).

In order to draw the radar screen, you need to know something about how the canvas API works. The API allows us to draw two different things: rectangles and paths. The rectangle is pretty straightforward (and we won't need any for this task).

A path works a bit like our old friend the turtle from Python's turtle graphics. We have a collection of commands for configuring the stroke or fill of the shape, and a collection of commands for moving the "pen" around. We can then stroke (i.e., outline) or fill the shape.

The typical drawing sequence will start with ctx.beginPath(), which creates a new path. Then, after we have provides a sequence of movement commands, we will issue either ctx.stroke() to draw the outline or ctx.fill() to fill it in, or both to create a filled shape with an outline.

There are a number of different commands for moving the pen around, but for our purposes, we just need these:

  • ctx.moveTo(x,y): Pick up the pen and move to the location (x,y) on the canvas
  • ctx.lineTo(x,y): Draw a line from the current location to location (x,y)
  • ctx.arc(x, y, radius, startAngle, endAngle, anticlockwise): Draw an arc. The arc will be along the border of a circle with radius radius and centered at (x,y). The two angles are the angles in radians around the circle (measured from the x-axis) where the arc should start and stop. The optional anticlockwise argument provides control over which direction the arc should go around the circle to connect the two angles.

So, to draw a circle with radius 10 that is centered at (25, 25), we would write:

ctx.beginPath();
ctx.arc(25, 25, 10, 0, Math.PI * 2);
ctx.stroke()

Draw the outermost circle of the radar display. To control the stroke, we can set the line width (ctx.lineWidth) and we can control the color (ctx.strokeStyle). These are both properties rather than functions, so you can use assignment (e.g., ctx.lineWidth = 3), and the color takes strings of the same sort that we pass to CSS. I recommend a white line of width 3 to start.

Call your new function from the renderer and make sure it works.

Once it works, draw the rest of the background. You can be creative, but I would like to see at least four rings and a crosshair at a minimum. For inspiration, you can check out some examples.

As a challenge, try to do the whole thing as one path. This will require you to use moveTo to move the pen around.

Part 4: Draw the sweep hand

The sweep hand will swing around the face of our radar, so we will need to add animation for this step. First, however, we will just draw the static version.

Create a new function called drawSweep outside of your component. This should take in ctx, radius, and angle. The first two are the same as we passed into drawRadar. The angle will be the angle of the sweep arm.

The simplest version would be to draw a single line from the center to the outside edge of the circle at the appropriate angle. To remind you of your trig, the end points of this line will be (0,0) and (radius * Math.cos(angle), radius * Math.sin(angle)).

This would work, but... it has no personality (Wang, Principle 3.3: 'Imbue personality'). Radar sweeps leave behind a trail as the display recovers from its passing. We can emulate this effect by drawing not just one line, but many lines, progressively turning down the opacity of each one until it fades away.

We can control the opacity of our drawings by adding an alpha component to our colors. The alpha value ranges from 1.0 (full opaque) to 0.0 (fully transparent).

For my version, I made an alpha variable that I initially set at 1. To set the stroke style I used the rgb() form so I could set the alpha with a variable:

ctx.strokeStyle = `rgb(0, 255, 0, ${alpha})`;

I made a while loop that looped while alpha was larger than 0.1. Inside the loop, I set the stroke style and draw the line at the current angle. I then subtract some small amount from the angle (e.g., 0.01) and turn down the alpha by multiplying it by something less than 1 (e.g., 0.9). You can tinker with these values to vary the effect.

Add this new function to your renderer function and experiment with the effect a bit. You will need to pass some angle in, so just pick one for the time being.

Part 5: Add animation

Time to make the sweep arm swing around.

React Native provides an Animated component for doing animations. We aren't going to use it. Animated is great for moving React components around. So, if we were working with Image components, that would be the solution. Our problem is that the way the GLView works means that it only calls the function passed to onContextCreate on its creation. If we trigger a state update the re-renders the GhostRadar component, it won't call our function again. So we need to be a little more clever.

One approach would be to keep changing a state variable (e.g., angle), triggering re-renders, and then calling our render function manually once we have a valid context. This would be more React-y approach, but doesn't provide much control over the animation.

We are going to fall back on a non-React approach based on timers. In particular, we are going to use requestAnimationFrame, which is designed to call a callback function after the current render is complete.

Add a new variable to GhostRadar called angle to hold the current angle of the sweep arm. See how un-React we are? We are using local variables instead of state, assuming the component will be run a single time, but we will animate inside of that.

In renderer, use angle as the angle for the sweep arm. At the end of the renderer function, add some small amount to angle (I used 0.02, but experiment to find a speed you like). We don't want the angle to grow indefinitely, so if the value of angle grows beyond 2π, just subtract out 2π.

Then, to get the animation to continue, add this line at the end of renderer:

 requestAnimationFrame(renderer);

This requests that the renderer frame be rendered again when the current fame is ready.

Test this out -- the arm should now sweep around in circles.

You will notice that the effect isn't quite what we want. The sweep arm sweeps around painting the whole circle green. The solution is to clear the screen at the start of every rendering pass. Add this to the start of the renderer function:

ctx.clearRect(0,0, ctx.width, ctx.height);

Part 6: Managing focus

As we have noted, our navigation scheme can be thought of shuffling through a collection of cards, stacking one on top of another. A curious side effect of this once we make a component, it stays active, even if we can't see it.

Try this. Add console.log(angle) to renderer. You should see it print out a long stream of numbers as it swings around. Now switch to the Ghosts tab. What madness is this? The angle keeps printing out... If you return to the radar screen you will see that the arm has continued to sweep around while you were looking at other screens.

Normally, this isn't a big deal, but it is not really ideal when the component is (for instance) constantly rendering and doing math. This would be a battery killer.

To solve this, we are going to keep track of when the component has focus and only request new frames if there is someone there to see them.

To do this, we are going to install yet another library: expo install react-navigation-hooks.

This library actually provides a collection of hooks that we could have used earlier, and this will eventually be bundled into the core of React Navigation.

The hook we are interested in is useFocusEffect, which will inform us when the component gains and loses focus.

Import this at the top of the file:

import {useFocusEffect  } from 'react-navigation-hooks';

We can then use this in GhostRadar to be notified when it gains focus:

useFocusEffect(useCallback(() => {
  console.log('got focus');
  return ()=> console.log('lost focus');
}));

Note that the return value of our callback is another function to be called when focus is lost.

Add another local variable to the component called focus. Set focus to true when the component gains focus and to false when it loses it.

Now, in renderer, check if the component still has focus before calling requestAnimationFrame. Only request a new frame if the component still has focus.

Do the same test, watching the angle change. This time, it should cut off when you switch to a new page.

Unfortunately, it won't start up again when you come back. However, the focus effect hook will still be called, so add a line in there that requests another animation frame when the component gets focus provided ctx has been set (to avoid calling it when the component is first rendered when ctx isn't valid).

Coming attractions

At this point, we have something interesting looking, but we aren't finished. We still need to add ghosts and a way to catch them to the app. However, this practical is already long enough, so we will return to this in the next installment...

Finishing up

Commit your changes to git and push them back up to GitHub. I will find them there.

Grading

Points Requirement
✓/✗ GhostRadar component is setup and styled properly
✓/✗ The setup and renderer functions are properly set up
✓/✗ The radar background is drawn
✓/✗ There is a sweep arm with fade-off
✓/✗ The sweep arm is animated
✓/✗ Focus is used to control the animation