Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

frame drops on reanimated pinch and pan gesture using reanimated on react-native-svg #6325

Closed
sqb47 opened this issue Jul 24, 2024 · 8 comments
Labels
Platform: Android This issue is specific to Android Repro provided A reproduction with a snippet of code, snack or repo is provided

Comments

@sqb47
Copy link

sqb47 commented Jul 24, 2024

Description

I am trying to implement pinch and pan gestures to move and zoom an SVG. This SVG has multiple paths, and I am trying to implement zoom without losing any quality of the SVG, so I have to scale the SVG itself on pinch gesture. However, this causes performance issues and frame drops. Is there a way that I can optimize the code or do something to enhance the performance of the app?

Steps to reproduce

pinch and pan gesture on an svg with 50 or more paths

import {
    Dimensions,
    View,
  } from 'react-native';
  import React, {} from 'react';
  import Animated, {
    useAnimatedProps,
    useSharedValue,
  } from 'react-native-reanimated';
  import {
    Gesture,
    GestureDetector,
    GestureHandlerRootView,
  } from 'react-native-gesture-handler';
  import Svg, {Path, G} from 'react-native-svg';
  const AnimatedGroup = Animated.createAnimatedComponent(G);
  
  const {width, height} = Dimensions.get('screen');
  
const PinchPan = () => {

    function clamp(val: number, min: number, max: number) {
        return Math.min(Math.max(val, min), max);
        }

    // ================= PAN =======================
    const translationX = useSharedValue(0);
    const translationY = useSharedValue(0);
    const prevTranslationX = useSharedValue(0);
    const prevTranslationY = useSharedValue(0);

    const pan = Gesture.Pan()
        .minDistance(1)
        .onStart(() => {
        prevTranslationX.value = translationX.value;
        prevTranslationY.value = translationY.value;
        })
        .onUpdate(event => {
        const maxTranslateX = width / 2;
        const maxTranslateY = height / 2;

        translationX.value = clamp(
            prevTranslationX.value + event.translationX,
            -maxTranslateX,
            maxTranslateX,
        );
        translationY.value = clamp(
            prevTranslationY.value + event.translationY,
            -maxTranslateY,
            maxTranslateY,
        );
        })
        .runOnJS(true);
    // ============================================

    // ================== pinch ===================
    const scale = useSharedValue(1);
    const startScale = useSharedValue(0);

    const pinch = Gesture.Pinch()
        .onStart(() => {
        startScale.value = scale.value;
        })
        .onUpdate(event => {
        scale.value = clamp(startScale.value * event.scale, 1, 20);
        })
        .runOnJS(true);
    // ============================================

    const composed = Gesture.Simultaneous(pan, pinch);

    const animatedPropsPinch = useAnimatedProps(() => ({
        transform: [{scale: scale.value}],
    }));
    const animatedPropsPan = useAnimatedProps(() => ({
        transform: [
        {translateX: translationX.value},
        {translateY: translationY.value},
        ],
    }));

    return (
        <View style={styles.container}>
        <GestureHandlerRootView>
            <GestureDetector gesture={composed}>
            <View>
                <Svg
                width={width}
                height={height}
                stroke-linecap="round"
                stroke-linejoin="round"
                stroke-width=".1"
                viewBox={`0 0 ${width} ${height}`}>
                {/* pinch */}
                <AnimatedGroup
                    animatedProps={animatedPropsPinch}
                    translateX={width / 2}
                    translateY={height / 2.7}>
                    {/* pan */}
                    <AnimatedGroup animatedProps={animatedPropsPan} scale={0.5}>
                    {svgPaths.map(path => (
                        <Path
                        key={Math.random()}
                        d={path.d}
                        translateX={-width / 2}
                        translateY={-height / 2}
                        fill={'grey'}
                        stroke={'black'}
                        onPress={() => 
                            console.log('path pressed')}
                        />
                    ))}
                    </AnimatedGroup>
                </AnimatedGroup>
                </Svg>
            </View>
            </GestureDetector>
        </GestureHandlerRootView>
        </View>
    );
};

Snack or a link to a repository

https://snack.expo.dev/NSrVunUN5W2dDIgEOzxlB

Reanimated version

3.12.1

React Native version

0.74.2

Platforms

Android

JavaScript runtime

None

Workflow

React Native

Architecture

None

Build type

Release app & dev bundle

Device

Real device

Device model

No response

Acknowledgements

Yes

@github-actions github-actions bot added Platform: Android This issue is specific to Android Repro provided A reproduction with a snippet of code, snack or repo is provided labels Jul 24, 2024
@MatiPl01
Copy link
Member

Hey!
I think this is a problem with how react-native-svg applies transformation to group elements. It is likely applying transformation separately to each path and redraws the entire SVG, which is not performant.

To fix your problem, you can wrap your entire SVG with the Animated.View component and apply the transformation to this view using the animated style. Similarly, update the scale of the Animated.View instead of animating the SVG.

See the example code showing the possible change:

const PinchPan = () => {
  function clamp(val: number, min: number, max: number) {
    'worklet';
    return Math.min(Math.max(val, min), max);
  }

  // ================= PAN =======================
  const translationX = useSharedValue(0);
  const translationY = useSharedValue(0);
  const prevTranslationX = useSharedValue(0);
  const prevTranslationY = useSharedValue(0);

  const pan = Gesture.Pan()
    .minDistance(1)
    .onStart(() => {
      prevTranslationX.value = translationX.value;
      prevTranslationY.value = translationY.value;
    })
    .onUpdate((event) => {
      const maxTranslateX = width / 2;
      const maxTranslateY = height / 2;

      translationX.value = clamp(
        prevTranslationX.value + event.translationX,
        -maxTranslateX,
        maxTranslateX
      );
      translationY.value = clamp(
        prevTranslationY.value + event.translationY,
        -maxTranslateY,
        maxTranslateY
      );
    });
  // ============================================

  // ================== pinch ===================
  const scale = useSharedValue(1);
  const startScale = useSharedValue(0);

  const pinch = Gesture.Pinch()
    .onStart(() => {
      startScale.value = scale.value;
    })
    .onUpdate((event) => {
      scale.value = clamp(startScale.value * event.scale, 1, 20);
    });
  // ============================================

  const composed = Gesture.Simultaneous(pan, pinch);

  const animatedStylePinch = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }));
  const animatedStylePan = useAnimatedStyle(() => ({
    transform: [
      { translateX: translationX.value },
      { translateY: translationY.value },
    ],
  }));

  return (
    <View style={styles.container}>
      <GestureHandlerRootView>
        <GestureDetector gesture={composed}>
          {/* pinch */}
          <Animated.View style={animatedStylePinch}>
            {/* pan */}
            <Animated.View style={animatedStylePan}>
              <Svg
                width={width}
                height={height}
                stroke-linecap="round"
                stroke-linejoin="round"
                stroke-width=".1"
                viewBox={`0 0 ${width} ${height}`}>
                <G translateX={width / 2} translateY={height / 2.7} scale={0.5}>
                  {svgPaths.map((path) => (
                    <Path
                      key={Math.random()}
                      d={path.d}
                      translateX={-width / 2}
                      translateY={-height / 2}
                      fill={'grey'}
                      stroke={'black'}
                      onPress={() => console.log('path pressed')}
                    />
                  ))}
                </G>
              </Svg>
            </Animated.View>
          </Animated.View>
        </GestureDetector>
      </GestureHandlerRootView>
    </View>
  );
};

@MatiPl01
Copy link
Member

I also wonder why you run both gestures on the JS thread instead of the UI thread. If the only reason why you did so is the problem with the clamp function, you can workletize it by adding the 'worklet' string at the beginning of the function body and remove the unnecessary runOnJS call from gestures.

@sqb47
Copy link
Author

sqb47 commented Jul 24, 2024

thank you for the response @MatiPl01

i admit that i get a performances boost if i implement it with Animated.view but then the svg will not be scaled with the zoom and will pixelate, that is the reason why i had to implement scale on a group. And i have to check onPress function of every single path, so i have to implement every path separately.

i have tried to remove runOnJs and workletize functions like clamp, onStart and onUpdate for the gestures but there are no significant changes to the performance.

any other ideas

@MatiPl01
Copy link
Member

MatiPl01 commented Jul 24, 2024

Thanks for your response. I can see that scaling the view might be problematic when you want to keep the high quality of the svg. Unfortunately, react-native-svg is not very performant so you might would like to check if react-native-skia works better for you. This library should be more performant and has similar API to react-native-svg.

It also works well with react-native-reanimated, so you should have no problems to switch to using the react-native-skia library.

There is one caveat, I am not sure if it supports detection of press events on separate svg paths but you can check it out.

@sqb47
Copy link
Author

sqb47 commented Jul 24, 2024

i just started researching react native skia. I have not yet implemented pinch and pan gesture, but it does not provide any onPress function on the path component, i am trying to figure out any way that i can detect which path is selected through gesture handler.

@MatiPl01
Copy link
Member

MatiPl01 commented Jul 24, 2024

i just started researching react native skia. I have not yet implemented pinch and pan gesture, but it does not provide any onPress function on the path component, i am trying to figure out any way that i can detect which path is selected through gesture handler.

I reimplemented the component I used for testing with react-native-skia. After this change, I get very small frame drops (at most to 100 fps from 120 fps). When I used react-native-svg, frame drops were huge, to about 30-40 fps.

The remaining problem is handling touches. I've found react-native-skia-gesture but it implements only pan gesture handler. You can take look at this library implementation and maybe create your fork which adds tap gesture handler or implement something similar if you wish (the library is quite simple, it just reads touch from the gesture handler that wraps the canvas component and check whether the touched point is within a skia path).

I have no other ideas how I may help you. Let me know if you have more questions.

@sqb47
Copy link
Author

sqb47 commented Aug 6, 2024

a little update on this,
in the end i gave up and used a web view.
here is the Code if anyone is interested or facing the same issue

@MatiPl01
Copy link
Member

MatiPl01 commented Aug 6, 2024

a little update on this, in the end i gave up and used a web view. here is the Code if anyone is interested or facing the same issue

Thanks for letting know that you found a solution for your problem.

I will close this issue, since it is not an issue with react-native-reanimated but rather a problem react-native-svg performance, which probably won't be resolved in the near future.

If you have more questions, feel free to re-open the issue and ask.

@MatiPl01 MatiPl01 closed this as completed Aug 6, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Platform: Android This issue is specific to Android Repro provided A reproduction with a snippet of code, snack or repo is provided
Projects
None yet
Development

No branches or pull requests

2 participants