2

I've created a simple canvas drawing app using React Native and Skia, which works for the most part, but isn't saving drawing strokes correctly. It's based loosely off of these two articles, with the thought being that I could simplify the implementation since a.) Skia already has a touchHandler facility so I don't need Gesture Handler, and b.) I should be able to leverage the useState hook instead of a state handler like zustand.

The problem I'm running into is that the paths state variable is getting updated appropriately (as demonstrated by the useEffect console log output) on the first touch event, but appears to be empty on each subsequent touch event. The net effect is that I can draw a series of paths but only the last one is preserved and displayed on the canvas.

My dev environment is Windows using react-native-cli instead of expo. Node is installed via nvm-windows. Project was bootstrapped using react-native init <project-name> and launched via npx react-native run-android I modified App.tsx to import StateTest from a file containing the code below. I can reproduce the behavior on a physical Pixel 5 and on an Android Studio virtual device. Relevant versions are:

NPM 9.2.0
@shopify/react-native-skia 0.1.172
react-native 0.71.1

Here's the code; I'm using:

import {
    Canvas,
    Path,
    Skia,
    Group,
    useTouchHandler,
  } from "@shopify/react-native-skia";
  import { useState, useRef, useCallback, useEffect } from 'react';

  export const StateTest = () => {
    let workingPath = [];

    const [ displayPaths, setDisplayPaths ] = useState([])
    const [ paths, setPaths ] = useState([]);

    const onDrawingStart = useCallback(({ x, y }) => {
        workingPath = [`M ${x} ${y}`];
    });

    const onDrawingActive = useCallback(({ x, y }) => {
        if (!workingPath){
            return;
        }
        workingPath.push(`L ${x} ${y}`);
      });
   
    const onDrawingFinished = useCallback(() => {
        if (!workingPath){
            return;
        }
        savePaths();
        workingPath = [];
    });

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

    /* I thought maybe the problem was with the touchHandler operating on
       an old copy of state, so I pulled that code out here, which didn't
       fix the problem; that's not surprising, since the callback would
       necessarily be operating on the same version of state as the
       touchHandler
    */
    const savePaths = useCallback(() => {
        let localPath = [...paths];
        console.log('before:');
        console.log(localPath);
        localPath.push(workingPath.join(" "));
        console.log('after:');
        console.log(localPath);
        setPaths([...localPath]);
    });
    
    const drawPaths = useCallback(() => {
      console.log("setting displaypaths from:");
      console.log(paths);
      setDisplayPaths(
        <Group>
        {paths.map((p) => 
        <Path key={p} path={p} color="red" style="stroke" strokeWidth={4} />
        )}
        </Group>
      )
    });

    useEffect(() => {
        console.log("paths have been updated:");
        console.log(paths);
        drawPaths();
    }, [paths])

    useEffect(() => {
        console.log("paths have been drawn:");
        console.log(displayPaths);
    }, [displayPaths])

    return (
        <Canvas style={{ flex: 1 }} onTouch={touchHandler}>
        {displayPaths}
        { /* This may be related; I couldn't get anything to display using the array map
             here, so I created the drawPaths() function and inserted {displayPaths}
             above, which seems to work except for the problem with outdated state.
          paths.map(p => {
            //console.log(`mapped |${p.toSVGString()}|`);
            <Path key={p.toSVGString()} path={p} color="red" style="stroke" strokeWidth={10} />
          }
        ) */ }
      </Canvas>
    );
  };

The debug output looks like this. I started the app and made two finger swipes on the canvas.

 LOG  before:
 LOG  []
 LOG  paths have been updated:
 LOG  ["M 283.6363636363636 233.45454545454547 L 283.6363636363636 233.45454545454547 L 283.6363636363636 233.45454545454547  <elided for space>"]
 LOG  setting displaypaths from:
 LOG  ["M 283.6363636363636 233.45454545454547 L 283.6363636363636 233.45454545454547 L 283.6363636363636 233.45454545454547 <elided for space>"]
 LOG  paths have been drawn:
 LOG  <Group><Path color="red" path="M 283.6363636363636 233.45454545454547 L 283.6363636363636 233.45454545454547 <elided for space>" strokeWidth={4} style="stroke" /></Group>
 LOG  before:
 LOG  []
 LOG  paths have been updated:
 LOG  ["M 141.45454545454547 240.72727272727272 L 141.45454545454547 240.72727272727272 L 141.45454545454547 240.72727272727272  <elided for space>"]
 LOG  setting displaypaths from:
 LOG  ["M 141.45454545454547 240.72727272727272 L 141.45454545454547 240.72727272727272 L 141.45454545454547 240.72727272727272 <elided for space>"]
 LOG  paths have been drawn:
 LOG  <Group><Path color="red" path="M 141.45454545454547 240.72727272727272 L 141.45454545454547 240.72727272727272 <elided for space>" strokeWidth={4} style="stroke" /></Group>

As you can see, the first input updated the paths or the useEffect wouldn't have output the 'paths have been updated' message, but on the second input, the paths array is empty when the touchHandler's onEnd event calls my onDrawingFinished callback. I would expect that by the time I draw the second path, it would observe the updated state, but I'm not confident in my understanding of react internals, so it's entirely possible I've just overlooked some facet of useState or Skia.

Kaiido
  • 123,334
  • 13
  • 219
  • 285
cbolton
  • 21
  • 1

0 Answers0