Notesnook Blog

LoginSign up
Development

Using React Native Skia to Build a 60 FPS Free-hand Drawing App

By Ammar AhmedMarch 22, 2022
Photo by ANTONI SHKRABA on Pexels

When it comes to comparing Flutter with React Native, the most popular argument has been a more native user experience with buttery smooth animations and graphics drawing support. Flutter uses Skia, a very popular, open-source 2D graphics engine made by Google and used in Chrome and Android to draw everything natively with great performance.

On the other hand, React Native uses the platform specific rendering engines which is great but much slower. All this slowness is there because of a bridge which sends all information to Java/Objective-C in batches from the Javascript side. Since everything is single threaded, under heavy load the FPS drops; you see blank spaces in FlatLists, lag during scroll, and a lot of hang ups.

Communication between Javascript and Android/iOS environment with JSI
Communication between Javascript and Android/iOS environment with JSI

React Native had a good increase in performance when Hermes came out but soon the React Native team realized that the only factor holding back performance was the JS-Native bridge. React Native needed a better and faster way of communication with native side. So the React Native team started to work on the new architecture (JSI + Fabric + Turbo Modules).

Today the new architecture is in it's final stages and about to be released to the public in the next React Native release changing many things for React Native. However, it's still very uncertain (due to lack of proper documentation) how the new architecture actually works.

Currently only a handful of libraries have used this new architecture. The most prominent ones have been react-native-reanimated and @shopify/react-native-skia. Today we will be using @shopify/react-native-skia to build a fully native, 60 FPS, free-hand drawing app to explore the possibilities.

We will be building a very simple free-hand drawing app in React Native with the following features:

  1. Free hand drawing
  2. Color selection
  3. Variable stroke
  4. Undo/redo
  5. Export the drawing to standard SVG format

Getting started

Create a new react native project

We are starting from a fresh react native project.

npx react-native init skia-drawing-app

Installing dependencies

react-native-skia is not yet released on npm hence we will add it directly from GitHub releases

npm install https://github.com/Shopify/react-native-skia/releases/download/v0.1.103-alpha/shopify-react-native-skia-0.1.103.tgz

And zustand for React state management.

npm install zustand

Project structure

.
┃  📦src
┃  ┣ 📂components
┃  ┃ ┣ 📜color.tsx
┃  ┃ ┣ 📜header.tsx
┃  ┃ ┣ 📜stroke.tsx
┃  ┃ ┗ 📜toolbar.tsx
┃  ┣ 📂drawing
┃  ┃ ┣ 📜constants.tsx
┃  ┃ ┣ 📜history.tsx
┃  ┃ ┣ 📜index.tsx
┃  ┃ ┗ 📜utils.tsx
┃  ┗ 📂store
┃  ┃ ┗ 📜index.ts
┣ 📜App.tsx
  1. The components folder has the toolbar & all the basic components.
  2. The drawing folder has all the logic + the drawing board.
  3. We are using zustand for global state management. The store folder has our global state where we keep drawing paths and paint information.

Building the drawing board

The drawing board consists of 3 components: Header, Canvas and Toolbar interconnected by the global state as shown below.

Native bindings not found error

1. The global state

We are using zustand for global state. The following interface will help you understand the purpose of each property in the state:

import { DrawingInfo, IPaint, IPath } from '@shopify/react-native-skia';

interface DrawingStore extends State {
  /**
   * Array of completed paths for redrawing on the `Canvas`
   */
  completedPaths: CurrentPath[];
  /**
   * A function to update completed paths
   */
  setCompletedPaths: (completedPaths: CurrentPath[]) => void;
  /**
   * Current stroke. Basically a paint object from Skia
   */
  stroke: IPaint;
  /**
   * Width of the stroke, used when creating stroke.
   */
  strokeWidth: number;
  /**
   * Color of the stroke
   */
  color: string;
  setStrokeWidth: (strokeWidth: number) => void;
  setColor: (color: string) => void;
  setStroke: (stroke: IPaint) => void;

  /**
  Width & height information of the canvas used for svg export 
  */
  canvasInfo: Partial<DrawingInfo> | null;
  setCanvasInfo: (canvasInfo: Partial<DrawingInfo>) => void;
}

The state stores and coordinates everything between all the different components.

2. Canvas

Canvas is where all the drawing takes place (obviously).

Objective: Draw a path when the user touches the screen & moves their finger, and save it.

import {
  Canvas,
  ExtendedTouchInfo,
  ICanvas,
  Path,
  Skia,
  SkiaView,
  TouchInfo,
  useDrawCallback,
  useTouchHandler
} from '@shopify/react-native-skia';
import React, { useCallback, useRef, useState } from 'react';
import { LayoutChangeEvent, SafeAreaView, useWindowDimensions, View } from 'react-native';
import useDrawingStore, { CurrentPath } from '../store';
import Header from '../components/header';
import history from './history';
import Toolbar from '../components/toolbar';

const Drawing = () => {
  // Is user touching the screen
  const touchState = useRef(false);
  // Instance of canvas for imperative access
  const canvas = useRef<ICanvas>();
  // The current path which the user is drawing. The value is reset when finger is raised from screen
  const currentPath = useRef<CurrentPath | null>();
  const { width } = useWindowDimensions();
  // Array of completed paths from global state
  const completedPaths = useDrawingStore(state => state.completedPaths);
  // A function in global state to add/remove paths
  const setCompletedPaths = useDrawingStore(state => state.setCompletedPaths);
  // Stroke value from global state
  const stroke = useDrawingStore(state => state.stroke);
  // Height of canvas, set on layout of View wrapping Canvas
  const [canvasHeight, setCanvasHeight] = useState(400);

  const onDraw = useDrawCallback((_canvas, info) => {
    if (!canvas.current) {
      useDrawingStore.getState().setCanvasInfo({
        width: info.width,
        height: info.height
      });

      canvas.current = _canvas;
    }
  }, []);

  // We need to provide absolute height to the canvas as percentages/flex won't work.
  // Therefore when the `View` renders, we get the height and keep it in state. This
  // height will be the height of Canvas & SkiaView components.
  const onLayout = (event: LayoutChangeEvent) => {
    setCanvasHeight(event.nativeEvent.layout.height);
  };

  return (
    <SafeAreaView
      style={{
        flex: 1
      }}
    >
      <View
        style={{
          backgroundColor: '#f0f0f0',
          flex: 1,
          alignItems: 'center'
        }}
      >
        <Header />

        <View
          onLayout={onLayout}
          style={{
            width: width - 24,
            flexGrow: 1,
            backgroundColor: '#ffffff',
            borderRadius: 10,
            overflow: 'hidden',
            elevation: 1
          }}
        >
          <SkiaView
            onDraw={onDraw}
            style={{ height: canvasHeight, width: width - 24, zIndex: 10 }}
          />

          <Canvas
            style={{
              height: canvasHeight,
              width: width - 24,
              position: 'absolute'
            }}
          >
            {completedPaths?.map(path => (
              <Path
                key={path.path.toSVGString()}
                path={path.path}
                //@ts-ignore
                paint={{ current: path.paint }}
              />
            ))}
          </Canvas>
        </View>

        <Toolbar />
      </View>
    </SafeAreaView>
  );
};

export default Drawing;

The Drawing component is responsible for drawing paths on touch input. It consists of three Skia components.

  1. SkiaView: Draws the current when user touches the screen and moves their finger.
  2. Canvas: When the user raises their finger from the SkiaView that path is rendered on the underlying Canvas. This is why the Canvas component is rendered absolutely below the SkiaView.
  3. Path: Once a path is drawn on SkiaView, it is added to completedPaths. Completed paths are rendered using the Path component declaratively.

To draw on the SkiaView we need an instance of the internal canvas to access the methods imperatively. We do this by subscribing to the onDraw callback. In the onDraw function we store a reference to the Canvas. We are also storing width & height of the Canvas in the global state.

The Drawing component is rendered in App.tsx like so:

import React from 'react';
import Drawing from './src/drawing';

const App = () => {
  return <Drawing />;
};

export default App;

Subscribing to touch events with useTouchHandler hook

To be able to draw on the SkiaView we have to subscribe to the touch events on the SkiaView. Whenever the user touches on the canvas and moves their finger, the onDraw callback is called with the relevant touch-point data. We will pass this data to our touchHandler which is a hook provided by react-native-skia.

  const touchHandler = useTouchHandler({
    onActive: onDrawingActive,
    onStart: onDrawingStart,
    onEnd: onDrawingFinished,
  });

  const onDraw = useDrawCallback((_canvas, info) => {
    touchHandler(info.touches);
...

We call the touchHandler function every time onDraw callback is called with data about the current touch.

The onDrawingStart callback

As soon as the user touches the screen, onDrawingStart is called.

const onDrawingStart = useCallback(
  (touchInfo: TouchInfo) => {
    if (currentPath.current) return;
    const { x, y } = touchInfo;
    currentPath.current = {
      path: Skia.Path.Make(),
      paint: stroke.copy()
    };

    touchState.current = true;
    currentPath.current.path?.moveTo(x, y);

    if (currentPath.current) {
      canvas.current?.drawPath(currentPath.current.path, currentPath.current.paint);
    }
  },
  [stroke]
);
  1. We get the current x & y position of touch on the SkiaView.
  2. We update the currentPath ref with a new path.
  3. We set the touchState ref to true.
  4. We move the pen in currentPath to x/y position with moveTo function so that drawing starts from this point.
  5. Finally we draw the path on canvas with drawPath function.
The onDrawingActive callback

While the user moves finger on the screen, we update the path based on the movement.

const onDrawingActive = useCallback((touchInfo: ExtendedTouchInfo) => {
  const { x, y } = touchInfo;
  if (!currentPath.current?.path) return;
  if (touchState.current) {
    currentPath.current.path.lineTo(x, y);
    if (currentPath.current) {
      canvas.current?.drawPath(currentPath.current.path, currentPath.current.paint);
    }
  }
}, []);
  1. We get the current x & y position of touch on the SkiaView.
  2. We create the path using lineTo function to where the touch has moved.
  3. Finally we draw the path on canvas with drawPath function. It takes two values. the current path and stroke information. The stroke contains information about the color, width & size of the stroke.
The onDrawingFinished callback

When the user raises the finger from the screen, we store the current path in global state using the updatePaths function and reset touchState and currentPath.

const onDrawingFinished = useCallback(() => {
  updatePaths();
  // reset the path. prepare for the next draw
  currentPath.current = null;
  // set touchState to false
  touchState.current = false;
}, [completedPaths.length]);

const updatePaths = () => {
  if (!currentPath.current) return;
  // Copy paths in global state
  let updatedPaths = [...completedPaths];

  // Push the newly created path
  updatedPaths.push({
    path: currentPath.current?.path.copy(),
    paint: currentPath.current?.paint.copy(),
    // The current color of the stroke
    color: useDrawingStore.getState().color
  });

  // Update history (will get to this later in the blog)
  history.push(currentPath.current);

  // Update the state.
  setCompletedPaths(updatedPaths);
};

Once the paths update, They are rendered on the Canvas below SkiaView as I mentioned above. We can also draw all the paths on the same SkiaView but it will be too intensive because all paths need to be redrawn every time onDraw is called.

So far you'll be able to draw multiple paths freely:

Drawing with React Native using React Native Skia

3. Toolbar

Our drawing app has a neat toolbar at the bottom having 3 main functions.

  1. Set the color.
  2. Set the stroke width.
  3. Generate a stroke based on current color & width.
Native bindings not found error
import React, { useState } from 'react';
import { StyleSheet, View } from 'react-native';
import Color from '../components/color';
import Stroke from '../components/stroke';
import useDrawingStore from '../store';
import constants from '../drawing/constants';
import utils from '../drawing/utils';

const Toolbar = () => {
  const currentColor = useDrawingStore(state => state.color);
  const currentStrokeWidth = useDrawingStore(state => state.strokeWidth);
  const setStrokeWidth = useDrawingStore(state => state.setStrokeWidth);
  const setColor = useDrawingStore(state => state.setColor);
  const setStroke = useDrawingStore(state => state.setStroke);
  const [showStrokes, setShowStrokes] = useState(false);

  const onStrokeChange = (stroke: number) => {
    setStrokeWidth(stroke);
    setShowStrokes(false);
    setStroke(utils.getPaint(stroke, currentColor));
  };

  const onChangeColor = (color: string) => {
    setColor(color);
    setStroke(utils.getPaint(currentStrokeWidth, color));
  };

  return (
    <>
      {showStrokes && (
        <View
          style={[
            styles.toolbar,
            {
              bottom: 80,
              position: 'absolute'
            }
          ]}
        >
          {constants.strokes.map(stroke => (
            <Stroke key={stroke} stroke={stroke} onPress={() => onStrokeChange(stroke)} />
          ))}
        </View>
      )}

      <View style={[styles.toolbar, { position: 'relative', marginVertical: 12 }]}>
        <View
          style={{
            backgroundColor: '#f7f7f7',
            borderRadius: 5
          }}
        >
          {showStrokes && (
            <View
              style={{
                width: 5,
                height: 5,
                borderRadius: 100,
                backgroundColor: 'black',
                alignSelf: 'center',
                position: 'absolute'
              }}
            />
          )}

          <Stroke stroke={currentStrokeWidth} onPress={() => setShowStrokes(!showStrokes)} />
        </View>

        <View
          style={{
            height: 30,
            borderWidth: 1,
            borderColor: '#f0f0f0',
            marginHorizontal: 10
          }}
        />

        {constants.colors.map(item => (
          <Color key={item} color={item} onPress={() => onChangeColor(item)} />
        ))}
      </View>
    </>
  );
};

export default Toolbar;

Generating a stroke

You must be wondering what utils.getPaint does. The getPaint function is responsible for generating different kind of strokes using Skia.Paint:

import { PaintStyle, Skia, StrokeCap, StrokeJoin } from '@shopify/react-native-skia';

const getPaint = (strokeWidth: number, color: string) => {
  const paint = Skia.Paint();
  paint.setStrokeWidth(strokeWidth);
  paint.setStrokeMiter(5);
  paint.setStyle(PaintStyle.Stroke);
  paint.setStrokeCap(StrokeCap.Round);
  paint.setStrokeJoin(StrokeJoin.Round);
  paint.setAntiAlias(true);
  const _color = paint.copy();
  _color.setColor(Skia.Color(color));
  return _color;
};

Wherever needed, we can update the current stroke of the next path that is drawn on canvas:

setStroke(utils.getPaint(currentStrokeWidth, item));
Various lines with different strokes drawn on the canvas using React Native Skia
Various lines with different strokes drawn on the canvas using React Native Skia

4. Header

The header component has buttons for undo, redo, reset and save.

import React from 'react';
import { Text, View, TouchableOpacity, StyleSheet } from 'react-native';
import useDrawingStore from '../store';
import history from '../drawing/history';
import utils from '../drawing/utils';

const Header = () => {
  /**
   * Reset the canvas & draw state
   */
  const reset = () => {
    useDrawingStore.getState().setCompletedPaths([]);
    useDrawingStore.getState().setStroke(utils.getPaint(2, 'black'));
    useDrawingStore.getState().setColor('black');
    useDrawingStore.getState().setStrokeWidth(2);
    history.clear();
  };

  const save = () => {
    let canvasInfo = useDrawingStore.getState().canvasInfo;
    let paths = useDrawingStore.getState().completedPaths;
    if (paths.length === 0) return;
    console.log('saving');
    if (canvasInfo?.width && canvasInfo?.height) {
      console.log(
        utils.makeSvgFromPaths(paths, {
          width: canvasInfo.width,
          height: canvasInfo.height
        })
      );
    }
  };

  const undo = () => {
    history.undo();
  };

  const redo = () => {
    history.redo();
  };
  return (
    <View
      style={{
        height: 50,
        width: '100%',
        paddingHorizontal: 12,
        flexDirection: 'row',
        justifyContent: 'space-between',
        alignItems: 'center'
      }}
    >
      <View
        style={{
          flexDirection: 'row'
        }}
      >
        <TouchableOpacity
          activeOpacity={0.6}
          onPress={undo}
          style={[styles.button, { marginRight: 10 }]}
        >
          <Text style={styles.buttonText}>Undo</Text>
        </TouchableOpacity>

        <TouchableOpacity onPress={redo} activeOpacity={0.6} style={styles.button}>
          <Text style={styles.buttonText}>Redo</Text>
        </TouchableOpacity>
      </View>

      <View
        style={{
          flexDirection: 'row'
        }}
      >
        <TouchableOpacity onPress={reset} activeOpacity={0.6} style={styles.button}>
          <Text style={styles.buttonText}>Reset</Text>
        </TouchableOpacity>

        <TouchableOpacity
          activeOpacity={0.6}
          onPress={save}
          style={[styles.button, { marginLeft: 10 }]}
        >
          <Text style={styles.buttonText}>Save</Text>
        </TouchableOpacity>
      </View>
    </View>
  );
};

export default Header;
  1. Undo: Removes the last added path in completedPaths array.
  2. Redo: Re-adds a path removed by undo.
  3. Reset: Resets the drawing board to default state.
  4. Save: Generate an SVG from all the paths in completedPaths.
Native bindings not found error

History

History keeps track of all the changes on the drawing board.

import useDrawingStore, { CurrentPath } from '../store';

const history: {
  undo: CurrentPath[];
  redo: CurrentPath[];
} = {
  undo: [],
  redo: []
};

// Clear undo/redo stacks
function clear() {
  history.undo = [];
  history.redo = [];
}

// Push a new path to undo stack
function push(path: CurrentPath) {
  history.undo.push(path);
}

When users stops drawing. The newly created path is added to history with history.push function.

1. Undo
function undo() {
  if (history.undo.length === 0) return;
  // Get the last path in history
  let lastPath = history.undo[history.undo.length - 1];
  // Add the path to redo history
  history.redo.push(lastPath);
  // Remove path from undo history
  history.undo.splice(history.undo.length - 1, 1);
  // Update global state so the drawing board redraws
  useDrawingStore.getState().setCompletedPaths([...history.undo]);
}
2. Redo
function redo() {
  if (history.redo.length === 0) return;
  // Get last path from redo history
  let lastPath = history.redo[history.redo.length - 1];
  // Remove the path from redo history
  history.redo.splice(history.redo.length - 1, 1);
  // Add the path to undo history
  history.undo.push(lastPath);
  // Update the state
  useDrawingStore.getState().setCompletedPaths([...history.undo]);
}

Exporting the drawing as an SVG file

Once drawing completes, it can be exported to svg format using makeSvgFromPaths function

const makeSvgFromPaths = (
  paths: CurrentPath[],
  options: {
    width: number;
    height: number;
    backgroundColor?: string;
  }
) => {
  return `<svg width="${options.width}" height="${options.height}" viewBox="0 0 ${options.width} ${
    options.height
  }" fill="none" xmlns="http://www.w3.org/2000/svg">
    <rect width="${options.width}" height="${options.height}" fill="${
    options.backgroundColor || 'white'
  }"/>
  <g>

    ${paths.map(path =>
      path.paint && path.path
        ? `<path d="${path.path.toSVGString()}" stroke="${
            path.color
          }" stroke-width="${path.paint.getStrokeWidth()}" stroke-linecap="${path.paint.getStrokeCap()}" stroke-linejoin="${path.paint.getStrokeJoin()}"/>`
        : ''
    )}
    </g>
    </svg>`;
};

For each path in completedPaths array, we generate a <path/> svg component with stroke, color and width from the path so that it matches the exact drawing.

const save = () => {
  // Get canvas info, basically width and height. This will be the size of svg.
  let canvasInfo = useDrawingStore.getState().canvasInfo;
  // Get the paths from global state
  let paths = useDrawingStore.getState().completedPaths;
  if (paths.length === 0) return;
  if (canvasInfo?.width && canvasInfo?.height) {
    // Generate the svg. Saving the svg to a file can be done here
    console.log(
      utils.makeSvgFromPaths(paths, {
        width: canvasInfo.width,
        height: canvasInfo.height
      })
    );
  }
};

Conclusion

Imagine the possibilities, the sheer butter-smoothness that can be achieved with react-native-skia! This was a very basic drawing app — touching only the surface of what's possible with this amazing library. Features like path selection, movement & resizing or animations can be easily added.

The only issue currently is the lack of documentation. I was unable to find anything about the imperative API of react-native-skia which forced me to delve into the codebase and figure everything out myself. But once proper documentation is finished, the whole ecosystem of React Native will change.

You can find the complete code for the above tutorial on Github. Free free to check it out & play with it.

#tutorials#javascript#react-native
Ammar Ahmed
Ammar AhmedThe brain behind the iOS & Android Notesnook apps.
PREV POSTImproving User Account Security with Two-factor AuthenticationNEXT POST NeutralinoJS: The Next Best Alternative to Electron & Tauri