Pokemon Cards with Skia – Gesture and Motion Optimisations

GESTURE AND MOTION

As you have probably noticed the gesture component is a component that is above the canvas since it needs to detect the gesture and motion changes and then pass them into the canvas to render the card tilt, gloss and holo change and affect the styles that are computed in canvas component that we mentioned before in useFullCanvasDerivedValues.

The reason for that is that we wanted to separate the user input from the canvas and pass it as a for of reference that will not cause a costly re-render of the whole canvas component. As you have seen before the canvas component has plenty of optimisations on its own.

When designing any app you should think about separating your code where the rendering part is completely independent from other parts.

THE RENDERER SHOULD BE DOING ONE JOB – RENDERING.

Putting any kind of logic from input, gesture, motion, component layer will always end up affecting your render pipeline and cause performance slowdown.

If you monitor when the canvas component re-renders there is no single point that it is doing any kind of work on a gesture or a motion – GOOD!

So what can we optimise when working with gestures?

CONTENTS:

Full Video Play button

▶ Full Video
Reel Play button

▶ Reel

📟 Pokédex GIF Gallery

Click any card to open the file.

Pikachu
#001 Pikachu
Bulbasaur
#002 Bulbasaur
Jolteon
#003 Jolteon
Charizard Big
#004 Charizard Big
Pikachu
#005 Pikachu
Chardizard
#006 Chardizard
Pikachu
#007 Pikachu
Squirtle
#008 Squirtle
Marill
#009 Marill
Mew
#010 Mew
Mew Controls
#011 Mew Controls
🟡 ???
Coming soon

Shared values instead of React state

This is something that we have already talked about before. Do not use any form of state (useState for example) when working with animations. The animation state should not be considered state. The fact that the button was tapped or the form is active is a form of state that affects your UI and logic.

Phone motion or a gesture is not that kind of state. Always use Shared values from react-native-reanimated or an equivalent that works with a reference not a value.

We open the component with:

const gestureRotateX = useSharedValue(ZERO);
const gestureRotateY = useSharedValue(ZERO);
const sensorRotateX = useSharedValue(ZERO);
const sensorRotateY = useSharedValue(ZERO);
const sensorTranslateX = useSharedValue(ZERO);
const sensorTranslateY = useSharedValue(ZERO);

That are reused in multiple places all over the code. Any change to this shared value will not cause the component to rerender. We control the logic and can apply changes to the component without triggering a rerender.

Gesture and sensor motion are stored in useSharedValue:

  • gesture rotation
  • sensor rotation
  • sensor translation

Impact

  • eliminates React rerenders on touch and sensor updates
  • keeps updates on the UI thread
  • enables smooth, high-frequency animation

Move style and transform into a separate hook

The part that is responsible for the motion and gesture transform is provided by: useGestureContainerAnimatedStyles

It returns styles a inner and outer style provided by  useAnimatedStyle

We pass it into the react-native-reanimated Animated.View – wham! bam! and we’re done here.

<Animated.View style={[style.outer, sizeStyle, outerStyle]}>
   <Animated.View style={[style.inner, innerStyle]}>

Again please note that we separate it from the hook that provides the style manipulation useGestureContainerMotion. This one is coming up next.

  • reduces inline render complexity
  • improves memoization boundaries
  • better long-term maintainability

Move gesture logic into a separate hook

We use our custom hook to calculate the gesture and motion for our canvas – useGestureContainerMotion. This is a function where we optimize the code even more. You find a callback reference here, memoised motion and gesture and other useful stuff.

There are a lot of things happening here. What we try to do is to make all the elements into references and update them without causing any re-renders.

  • smaller, cleaner render component
  • clear separation of concerns
  • easier profiling and optimization
  • prevents render-path performance regressions

Reusable helpers with worklets

Functions like clamp, mapToAngle, applyDeadZone, smoothValue, shouldUpdateValue are moved to reusable utils with worklets.

What is a worklet?

A JavaScript function that is extracted, serialized, and executed on a different thread (usually the UI thread), without going through the React Native bridge.

This means that even in the modern architecture with (Codegen etc.) they will not run on js thread.

Why is this important?

Gestures affect the ui in our case. There is no reason to have any inter-thread communication. It would significantly slow down the ui updates if for each gesture or motion we would have to call something on js thread. We can do it all on our ui thread.

I think that the worklets can be used to optimize even more of the useGestureContainerMotion code.

  • avoids redefining functions per render
  • improves consistency and reuse
  • keeps worklet logic isolated and tunable

Function reference into a memo into a children renderer with motion

I know this title is confusing but that is what really happened. In our case we have a situation where we insert a child component into the gesture container providing motion. This can go so wrong on so many levels when trying to keep the code from unnecessary rerenders.

What we do here is create a function callback (renderCanvas – REF1) that contains the code responsible for rendering the children (canvas with shader, image etc.) that need to accept a motion as a shared value.

const renderCanvas = useCallback(
   (motion: GestureContainerMotion) => {
     return (<Component {our props go here} />
   })
)

 Then we pass renderCanvas into the gesture container as a child:

<GestureContainer
   width={props.width}
   height={props.height}
   maxAngle={props.max_angle}
>
   {renderCanvas}
</GestureContainer>

In gesture container we use the children to create a memo:

const renderedChildren = useMemo(
   () =>
     typeof props.children === "function"
       ? props.children(motion)
       : props.children,
   [props.children, motion],
);

Since our render canvas is a function it will memoize it with renderCanvas and keep it stable without causing any rerender.

Since motion GestureContainerMotion that is filled with SharedValue this memo will be called only when you change the props.children –  and they change only when we select a different image, hologram or shader.

PERFECT!

You just have created a highly optimised render component that accepts the motion from the gesture container and passes it down as a reference without any re-renders.

  • prevents unnecessary child execution
  • stabilizes render tree
  • especially important for canvas-heavy children

Stable motion object and memoized gesture

We use shard values inside the motion object. This limits the number of renders to a minimum. Both react-native-reanimated and react-native-skia are designed to work like that. 

GestureContainerMotion {
 gestureRotateX: SharedValue<number>;
 gestureRotateY: SharedValue<number>;
 sensorRotateX: SharedValue<number>;
 sensorRotateY: SharedValue<number>;
 sensorTranslateX: SharedValue<number>;
 sensorTranslateY: SharedValue<number>;
}

I was thinking about having the motion object itself as a stable object that is always the same thing in the memory. Feel free to test that out.

We pass a memoized gesture into the:

<GestureDetector gesture={gesture}>

This way this component does not do any unnecessary rerenders.

  • stable identity across renders
  • enables child memoization
  • reduces downstream recalculation
  • prevents gesture re-instantiation
  • avoids native reconfiguration overhead

Store callback in a ref onRotationChange

This is a interesting optimisation for the callback that needs to change things outside the function.

const onRotationChangeRef = useRef<GestureContainerProps["onRotationChange"]>(
   params.props.onRotationChange,
);

useEffect(() => {
   onRotationChangeRef.current = params.props.onRotationChange;
}, [params.props.onRotationChange]);

After that in our code we can use the reference that does not change at all.

const notifyRotationChange = useCallback((rx: number, ry: number) => {
   onRotationChangeRef.current?.(rx, ry);
}, []);

 useAnimatedReaction(
   () => [params.gestureRotateX.value, params.gestureRotateY.value] as const,
   (current, previous) => {
     const changed =
       !previous ||
       Math.abs(current[0] - previous[0]) > ROTATION_EPSILON ||
       Math.abs(current[1] - previous[1]) > ROTATION_EPSILON;

     if (changed && onRotationChangeRef.current) {
       scheduleOnUI(notifyRotationChange, current[0], current[1]);
     }
   },
);

This way you have a very stable reference to pass a hook that needs to update your UI based on any rotation. As you can see we schedule the update on ui – again doing everything on a separate thread.

You can try hooking into the component by passing a function and checking out how it works.

  • prevents gesture/reaction rebuilds on callback changes
  • keeps worklets stable while accessing latest callback

Reset is a callback

This is obvious. When there is no motion and the device is not moving and you make no gestures just call a reset that is a callback.

const resetMotion = useCallback(() => {
   params.gestureRotateX.value = withTiming(ZERO, TIMING_CONFIG);
   params.gestureRotateY.value = withTiming(ZERO, TIMING_CONFIG);
   params.sensorRotateX.value = withTiming(ZERO, TIMING_CONFIG);
   params.sensorRotateY.value = withTiming(ZERO, TIMING_CONFIG);
   params.sensorTranslateX.value = withTiming(ZERO, TIMING_CONFIG);
   params.sensorTranslateY.value = withTiming(ZERO, TIMING_CONFIG);
 }, [
   params.gestureRotateX,
   params.gestureRotateY,
   params.sensorRotateX,
   params.sensorRotateY,
   params.sensorTranslateX,
   params.sensorTranslateY,
]);

🤝 Sponsors

WORKDEI

Generalne wykonawstwo inwestycji
Podwykonawstwo budowlane
Usługi budowlane Poznań
Nadzór budowlany
MILWICZ ARCHITEKCI

Gotowy projekt domu
Architekt Poznań
Projekt domu na zamówienie
Nowoczesna stodoła
DRL-CLINIC

Dentysta Kraków
Ortodonta Kraków
Implanty Kraków
Metamorfoza uśmiechu
MAXDENT

Leczenie laserowe Wrocław
Leczenie pod mikroskopem
Ortodonta Wrocław
Implanty Wrocław

💛 Thanks to our partners for supporting this project

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top