software-mansion / react-native-svg

SVG library for React Native, React Native Web, and plain React web projects.
MIT License
7.52k stars 1.14k forks source link

Animated ClipPaths not re-rendering on Android #2473

Open esbenvb opened 1 month ago

esbenvb commented 1 month ago

Description

Animated ClipPaths does not seem to update visually on Android.

If I change other SVG props, it seems to re-render based on the current animated value, the animation of a ClipPath property itself does not cause the SVG to re-render.

I have attached example code using the latest RN and RNSVG modules.

There's a related issue from 2022, but unfortunately I can't downgrade to the mentioned version as it won't build with React Native 0.75

https://github.com/software-mansion/react-native-svg/issues/1719

Steps to reproduce

Clone repo from the attached link or do the following:

npx react-native init TestSVG
cd TestSVG
yarn add react-native-svg
yarn android

Replace App.tsx with the code below and try the different variations. All works on iOS, but animated ClipPath does not work on Android.

/**
 * Sample React Native App
 * https://github.com/facebook/react-native
 *
 * @format
 */

import React, {useEffect, useRef, useState} from 'react';
import {
  Animated,
  Button,
  Easing,
  SafeAreaView,
  StatusBar,
  Text,
  useColorScheme,
  View,
} from 'react-native';
import {Circle, ClipPath, G, Mask, Rect, Svg} from 'react-native-svg';

import {Colors} from 'react-native/Libraries/NewAppScreen';

const WIDTH = 300;
const HEIGHT = 140;

const AnimatedRect = Animated.createAnimatedComponent(Rect);
const AnimatedCircle = Animated.createAnimatedComponent(Circle);

const SlidingInClipPath: React.FC = () => {
  const animatedWidth = useRef(new Animated.Value(0)).current;
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    if (isVisible) {
      Animated.timing(animatedWidth, {
        toValue: WIDTH,
        duration: 1200,
        useNativeDriver: true,
        easing: Easing.inOut(Easing.ease),
      }).start();
    } else {
      animatedWidth.setValue(0);
    }
  }, [animatedWidth, isVisible]);
  return (
    <View>
      <Text>ClipPath sliding in</Text>
      <Svg width={WIDTH} height={HEIGHT} fill={'blue'}>
        <ClipPath id="clipPath">
          <AnimatedRect x={0} y={0} width={animatedWidth} height={HEIGHT} />
        </ClipPath>
        <G clipPath="url(#clipPath)">
          <Circle stroke={'green'} strokeWidth={3} cx={230} cy={50} r={30} />
          <Rect x={50} y={30} width={200} height={100} />
          <Circle stroke={'red'} strokeWidth={3} cx={80} cy={50} r={30} />
        </G>
      </Svg>
      <Button
        onPress={() => setIsVisible(current => !current)}
        title={isVisible ? 'Hide' : 'SHow'}
      />
    </View>
  );
};

const SlidingInMask: React.FC = () => {
  const animatedWidth = useRef(new Animated.Value(0)).current;
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    if (isVisible) {
      Animated.timing(animatedWidth, {
        toValue: WIDTH,
        duration: 1200,
        useNativeDriver: true,
        easing: Easing.inOut(Easing.ease),
      }).start();
    } else {
      animatedWidth.setValue(0);
    }
  }, [animatedWidth, isVisible]);
  return (
    <View>
      <Text>Mask sliding in</Text>
      <Svg width={WIDTH} height={HEIGHT} fill={'blue'}>
        <Mask id="mask">
          <Rect x={0} y={0} width={WIDTH} height={HEIGHT} fill="black" />
          <AnimatedRect
            x={0}
            y={0}
            width={animatedWidth}
            height={HEIGHT}
            fill="white"
          />
        </Mask>
        <G mask="url(#mask)">
          <Circle stroke={'green'} strokeWidth={3} cx={230} cy={50} r={30} />
          <Rect x={50} y={30} width={200} height={100} />
          <Circle stroke={'red'} strokeWidth={3} cx={80} cy={50} r={30} />
        </G>
      </Svg>
      <Button
        onPress={() => setIsVisible(current => !current)}
        title={isVisible ? 'Hide' : 'SHow'}
      />
    </View>
  );
};

const FadingIn: React.FC = () => {
  const animatedOpacity = useRef(new Animated.Value(0)).current;
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    if (isVisible) {
      Animated.timing(animatedOpacity, {
        toValue: 1,
        duration: 1200,
        useNativeDriver: true,
        easing: Easing.inOut(Easing.ease),
      }).start();
    } else {
      animatedOpacity.setValue(0);
    }
  }, [animatedOpacity, isVisible]);
  return (
    <View>
      <Text>Mask fading in</Text>
      <Svg width={WIDTH} height={HEIGHT} fill={'blue'}>
        <Mask id="mask">
          <AnimatedRect
            x={0}
            y={0}
            width={WIDTH}
            opacity={animatedOpacity}
            height={HEIGHT}
            fill="white"
          />
        </Mask>
        <G mask="url(#mask)">
          <Circle stroke={'green'} strokeWidth={3} cx={230} cy={50} r={30} />
          <Rect x={50} y={30} width={200} height={100} />
          <Circle stroke={'red'} strokeWidth={3} cx={80} cy={50} r={30} />
        </G>
      </Svg>
      <Button
        onPress={() => setIsVisible(current => !current)}
        title={isVisible ? 'Hide' : 'SHow'}
      />
    </View>
  );
};

const RADIUS = 80;
const PulsatingCircle: React.FC = () => {
  const animatedRadius = useRef(new Animated.Value(0)).current;
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    if (isVisible) {
      Animated.timing(animatedRadius, {
        toValue: RADIUS,
        duration: 1200,
        useNativeDriver: true,
        easing: Easing.inOut(Easing.ease),
      }).start();
    } else {
      Animated.timing(animatedRadius, {
        toValue: 0,
        duration: 1200,
        useNativeDriver: true,
        easing: Easing.inOut(Easing.ease),
      }).start();
    }
  }, [animatedRadius, isVisible]);
  return (
    <View>
      <Text>Pulsating Circle</Text>
      <Svg width={WIDTH} height={HEIGHT} fill={'blue'}>
        <G>
          <AnimatedCircle
            fill={'green'}
            strokeWidth={3}
            cx={80}
            cy={80}
            r={animatedRadius}
          />
        </G>
      </Svg>
      <Button
        onPress={() => setIsVisible(current => !current)}
        title={isVisible ? 'Hide' : 'SHow'}
      />
    </View>
  );
};

function App(): React.JSX.Element {
  const isDarkMode = useColorScheme() === 'dark';

  const backgroundStyle = {
    backgroundColor: isDarkMode ? Colors.darker : Colors.lighter,
  };

  return (
    <SafeAreaView style={backgroundStyle}>
      <StatusBar
        barStyle={isDarkMode ? 'light-content' : 'dark-content'}
        backgroundColor={backgroundStyle.backgroundColor}
      />
      <SlidingInClipPath />
      <SlidingInMask />
      <FadingIn />
      <PulsatingCircle />
    </SafeAreaView>
  );
}

export default App;

Snack or a link to a repository

https://github.com/esbenvb/rnsvg-android-animated-clippath-issue-reproduction

SVG version

15.7.1

React Native version

0.75.4

Platforms

Android

JavaScript runtime

Hermes

Workflow

React Native

Architecture

Paper (Old Architecture)

Build type

Release app & production bundle

Device

Real device

Device model

Pixel 8 pro - Android 14, Samsung A14 - Android 14

Acknowledgements

Yes

LukasMod commented 6 hours ago

Not sure if it's related, but I found a similar issue in my app after upgrading React Native from 0.73 to 0.76.3. This code, which is a gradient component for modals, worked fine on both iOS and Android before:


                <Svg width="100%" height="100%" >
                    <Defs>
                        <LinearGradient id="grad" x1="0%" y1="0%" x2="0%" y2="100%">
                            <Stop offset="14.83%" stopColor={fromColor} />
                            <Stop offset="83.89%" stopColor={toColor} />
                        </LinearGradient>
                    </Defs>
                    <Rect width="100%" height="100%" fill="url(#grad)" />
                </Svg>

With React Native 0.76 (old architecture), I encountered strange behavior with this: .

Randomly, it started rendering with something like width="80%". However, after making changes in the code (e.g., updating the width from 100 to 90 and then back to 100 during hot reload), it rendered properly as it should have initially at full width (100%).

I found a solution by using onLayout to set the SVG dimensions correctly.

    const [svgWidth, setSvgWidth] = useState(0);
    const [svgHeight, setSvgHeight] = useState(0);

    const handleLayout = (event: LayoutChangeEvent) => {
        const { width, height } = event.nativeEvent.layout;
        setSvgWidth(width);
        setSvgHeight(height);
    };

    return (
        <View style={[styles.container, containerStyle]} onLayout={handleLayout}>
            <View style={styles.backgroundContainer}>
                <Svg width={svgWidth} height={svgHeight}>
                    <Defs>
                        <LinearGradient id="grad" x1="0%" y1="0%" x2="0%" y2="100%">
                            <Stop offset="14.83%" stopColor={fromColor} />
                            <Stop offset="83.89%" stopColor={toColor} />
                        </LinearGradient>
                    </Defs>
                    <Rect width="100%" height="100%" fill="url(#grad)" />
                </Svg>
            </View>
            {children}
        </View>