software-mansion / react-native-svg

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

[Rect] width not being animated via createAnimatedComponent and interpolate #803

Closed xzilja closed 5 years ago

xzilja commented 5 years ago

I have simple animation interpolation going on for <Rect /> element, however I see no changes on my ui, as if width is staying 0.

I manually added my end value to the component as width={149.12} and it displayed it correctly, hence I am a bit confused now to why it is not picking up same value from animation?

react-native@0.57.1 react-native-svg@7.0.3 iOS

Here is full implementation, in essence a mana and health bar that take in current value and total value i.e. 50 and 100 should display half width for the rect. (Example uses typescript)

import * as React from 'react'
import { Animated } from 'react-native'
import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'
import { deviceWidth } from '../services/Device'

const barWidth = deviceWidth * 0.3454
const barHeight = barWidth * 0.093
const AnimatedRect = Animated.createAnimatedComponent(Rect)

/**
 * Types
 */
export interface IProps {
  variant: 'MANA' | 'HEALTH'
  currentValue: number
  totalValue: number
}

export interface IState {
  width: Animated.Value
}

/**
 * Component
 */
class HealthManaBar extends React.Component<IProps, IState> {
  state = {
    width: new Animated.Value(0)
  }

  rectangleRef = React.createRef()

  componentDidMount() {
    const { currentValue, totalValue } = this.props
    this.animate(currentValue, totalValue)
  }

  componentDidUpdate({ currentValue, totalValue }: IProps) {
    this.animate(currentValue, totalValue)
  }

  animate = (current: number, total: number) =>
    Animated.timing(this.state.width, {
      toValue: current !== 0 ? current / total : 0,
      duration: 400,
      useNativeDriver: false
    }).start()

  render() {
    const { variant } = this.props
    const { width } = this.state

    return (
      <Svg width={barWidth} height={barHeight}>
        <Defs>
          <LinearGradient
            id={`HeathManaBar-gradient-${variant}`}
            x1="0"
            y1="0"
            x2="0"
            y2={barHeight}
          >
            <Stop
              offset="0"
              stopColor={variant === 'HEALTH' ? '#EC561B' : '#00ACE1'}
              stopOpacity="1"
            />
            <Stop
              offset="0.5"
              stopColor={variant === 'HEALTH' ? '#8D1B00' : '#003FAA'}
              stopOpacity="1"
            />
            <Stop
              offset="1"
              stopColor={variant === 'HEALTH' ? '#9F3606' : '#007C97'}
              stopOpacity="1"
            />
          </LinearGradient>
        </Defs>
        <AnimatedRect
          x="0"
          y="0"
          rx="3"
          ry="3"
          width={width.interpolate({
            inputRange: [0, 1],
            outputRange: [0, barWidth]
          })}
          height={barHeight}
          fill={`url(#HeathManaBar-gradient-${variant})`}
        />
      </Svg>
    )
  }
}

export default HealthManaBar

NOTE: this is based on following example https://github.com/msand/SVGPodTest/blob/a74a600818e496efaa78298291b63107295064bf/App.js#L14-L57

Only difference I see is that it uses strings '0' and '50', which I also tried, but got react native error saying that NSString can't be converted to Yoga Value. However, when I pass width to my <Rect /> as an integer it works correctly, so I assume this shouldn't matter as much in v7?

msand commented 5 years ago

This seems to work fine at least: https://snack.expo.io/@msand/animate-rect-width

import React, { Component } from 'react';
import { View, StyleSheet, Dimensions, Animated } from 'react-native';
import { Svg } from 'expo';

const { Rect } = Svg;

const { width, height } = Dimensions.get('window');
const AnimatedRect = Animated.createAnimatedComponent(Rect);
const AnimatedSvg = Animated.createAnimatedComponent(Svg);

class SvgRoot extends Component {
  state = {
    initAnim: new Animated.Value(0),
  };

  componentDidMount() {
    Animated.timing(
      // Animate over time
      this.state.initAnim,
      {
        toValue: 1,
        duration: 3000,
        useNativeDriver: false,
      }
    ).start();
  }

  render() {
    const { initAnim } = this.state;
    let animateWidth = initAnim.interpolate({
      inputRange: [0, 1],
      outputRange: ['0', '80'],
    });
    return (
      <AnimatedSvg width={width} height={height} viewBox="0 0 100 100">
        <AnimatedRect
          y="10"
          x="10"
          height="80"
          width={animateWidth}
        />
      </AnimatedSvg>
    );
  }
}

export default class App extends Component {
  render() {
    return (
      <View style={styles.container}>
        <SvgRoot />
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    backgroundColor: '#ecf0f1',
  },
});
xzilja commented 5 years ago

@msand I'm not sure, but isn't expo still on v6 of react-native-svg? (double checked this they are using v 6.2.2 as of now)

Also

    let animateWidth = initAnim.interpolate({
      inputRange: [0, 1],
      outputRange: ['0', '80'],
    });

You used strings here for output range, which in rn 0.57.x yields error

NSString can't be converted to Yoga Value

I think with v7 we can use numbers here? At least I am able to use <Rect width={80} />


I see you also made SVG an animated component, I will try that out, but is there a reason why it needs to be one?

xzilja commented 5 years ago

UPDATE: Making Svg animated as well in my example didn't do the trick :/

msand commented 5 years ago

Do you have useNativeDriver: false?

xzilja commented 5 years ago

@msand I didn't, but added it just in case and tested, same result. I added it to code in my question to reflect current state. I think useNativeDriver should be "undefined" by default anyway however.

I also tried animating width of Svg element instead of rect, no luck there either :/

I'm not sure if it helps at all, but I added ref to my AnimatedRect its width doesn't seem to be a number or string, rather animated interpolation function, not sure if this is causing issue possibly?

screenshot 2018-10-09 at 14 56 01
msand commented 5 years ago

Please try with the latest commit from the master branch, it should work both with and without native driver now, at least this code seems to work fine:

import React from "react";
import { Dimensions, Animated } from "react-native";
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";

const { width, height } = Dimensions.get("window");

const barWidth = width * 0.3454;
const barHeight = barWidth * 0.093;
const AnimatedRect = Animated.createAnimatedComponent(Rect);

/**
 * Component
 */
class HealthManaBar extends React.Component {
  state = {
    width: new Animated.Value(0),
  };

  rectangleRef = React.createRef();

  componentDidMount() {
    const { currentValue, totalValue } = this.props;
    this.animate(currentValue, totalValue);
  }

  componentDidUpdate({ currentValue, totalValue }) {
    this.animate(currentValue, totalValue);
  }

  animate = (current, total) =>
    Animated.timing(this.state.width, {
      toValue: current / total,
      duration: 4000,
      useNativeDriver: true,
    }).start();

  render() {
    const { variant } = this.props;
    const { width } = this.state;

    return (
      <Svg width={barWidth} height={barHeight}>
        <Defs>
          <LinearGradient
            id={`HeathManaBar-gradient-${variant}`}
            x1="0"
            y1="0"
            x2="0"
            y2={barHeight}
          >
            <Stop
              offset="0"
              stopColor={variant === "HEALTH" ? "#EC561B" : "#00ACE1"}
              stopOpacity="1"
            />
            <Stop
              offset="0.5"
              stopColor={variant === "HEALTH" ? "#8D1B00" : "#003FAA"}
              stopOpacity="1"
            />
            <Stop
              offset="1"
              stopColor={variant === "HEALTH" ? "#9F3606" : "#007C97"}
              stopOpacity="1"
            />
          </LinearGradient>
        </Defs>
        <AnimatedRect
          x="0"
          y="0"
          rx="3"
          ry="3"
          rectwidth={width.interpolate({
            inputRange: [0, 1],
            outputRange: [0, barWidth],
          })}
          width={width.interpolate({
            inputRange: [0, 1],
            outputRange: [0, barWidth],
          })}
          height={barHeight}
          fill={`url(#HeathManaBar-gradient-${variant})`}
        />
      </Svg>
    );
  }
}

export default function App() {
  return <HealthManaBar totalValue={100} currentValue={50} variant="HEALTH" />;
}
adam-s commented 5 years ago

I'm having the same problem with props x, y on the Text, G, and TSpan components.

msand commented 5 years ago

It requires replicating these changes to those components and properties:

ios: https://github.com/react-native-community/react-native-svg/commit/7c012a9fe37aad146e7794a35a4238481b7d4703

android: the changes in android/src/main/java/com/horcrux/svg/RectShadowNode.java and android/src/main/java/com/horcrux/svg/RenderableViewManager.java https://github.com/react-native-community/react-native-svg/commit/e307eeee57b72401fe1b13c75f9642b56ccced69#diff-f992420cdd73af55d1a573a979adec6eR16

@adam-s Could you attempt making the changes and open a pr?

msand commented 5 years ago

@adam-s I realized it requires some other changes in the js side as well: https://github.com/react-native-community/react-native-svg/commit/2a43579404b35db6b6b9613c05076170e02203e3 Now I have support for animation of transforms, x, y etc on G, Text and TSpan at least with useNativeDriver: false Try something like this:

import React from "react";
import { Dimensions, Animated } from "react-native";
import Svg, { Defs, LinearGradient, Rect, Stop, Text, TSpan, G } from "react-native-svg";

const { width, height } = Dimensions.get("window");

const barWidth = width * 0.5;
const barHeight = barWidth * 0.5;
const AnimatedRect = Animated.createAnimatedComponent(Rect);
const AnimatedText = Animated.createAnimatedComponent(Text);
const AnimatedTSpan = Animated.createAnimatedComponent(TSpan);
const AnimatedG = Animated.createAnimatedComponent(G);

/**
 * Component
 */
class HealthManaBar extends React.Component {
  state = {
    width: new Animated.Value(0),
  };

  rectangleRef = React.createRef();

  componentDidMount() {
    const { currentValue, totalValue } = this.props;
    this.animate(currentValue, totalValue);
  }

  componentDidUpdate({ currentValue, totalValue }) {
    this.animate(currentValue, totalValue);
  }

  animate = (current, total) =>
    Animated.timing(this.state.width, {
      toValue: current / total,
      duration: 4000,
      useNativeDriver: false,
    }).start();

  render() {
    const { variant } = this.props;
    const { width } = this.state;

    return (
      <Svg width={barWidth} height={barHeight}>
        <Defs>
          <LinearGradient
            id={`HeathManaBar-gradient-${variant}`}
            x1="0"
            y1="0"
            x2="0"
            y2={barHeight}
          >
            <Stop
              offset="0"
              stopColor={variant === "HEALTH" ? "#EC561B" : "#00ACE1"}
              stopOpacity="1"
            />
            <Stop
              offset="0.5"
              stopColor={variant === "HEALTH" ? "#8D1B00" : "#003FAA"}
              stopOpacity="1"
            />
            <Stop
              offset="1"
              stopColor={variant === "HEALTH" ? "#9F3606" : "#007C97"}
              stopOpacity="1"
            />
          </LinearGradient>
        </Defs>
        <AnimatedRect
          x="0"
          y="0"
          rx="3"
          ry="3"
          rectwidth={width.interpolate({
            inputRange: [0, 1],
            outputRange: [0, barWidth],
          })}
          width={width.interpolate({
            inputRange: [0, 1],
            outputRange: [0, barWidth],
          })}
          height={barHeight}
          fill={`url(#HeathManaBar-gradient-${variant})`}
        />
        <AnimatedG
          x={width.interpolate({
            inputRange: [0, 1],
            outputRange: [0, barWidth],
          })}
        >
          <AnimatedText y={width.interpolate({
            inputRange: [0, 1],
            outputRange: ['0', `${barHeight}`],
          })}
          >
            <AnimatedTSpan x={width.interpolate({
              inputRange: [0, 2],
              outputRange: ['0', `${barWidth}`],
            })}
            >
            Test
            </AnimatedTSpan>
          </AnimatedText>
        </AnimatedG>
      </Svg>
    );
  }
}

export default function App() {
  return <HealthManaBar totalValue={100} currentValue={50} variant="HEALTH" />;
}
msand commented 5 years ago

@adam-s managed to get useNativeDriver: true animation of x and y properties on Text and TSpan now as well: https://github.com/react-native-community/react-native-svg/commit/a87096d3eee6d80940384bcf88e08dea861d1b4f

import React from "react";
import { Dimensions, Animated } from "react-native";
import Svg, {
  Defs,
  LinearGradient,
  Rect,
  Stop,
  Text,
  TSpan,
  G,
} from "react-native-svg";

const { width, height } = Dimensions.get("window");

const barWidth = width * 0.5;
const barHeight = barWidth * 0.5;
const AnimatedRect = Animated.createAnimatedComponent(Rect);
const AnimatedText = Animated.createAnimatedComponent(Text);
const AnimatedTSpan = Animated.createAnimatedComponent(TSpan);
const AnimatedG = Animated.createAnimatedComponent(G);

/**
 * Component
 */
class HealthManaBar extends React.Component {
  state = {
    width: new Animated.Value(0),
  };

  rectangleRef = React.createRef();

  componentDidMount() {
    const { currentValue, totalValue } = this.props;
    this.animate(currentValue, totalValue);
  }

  componentDidUpdate({ currentValue, totalValue }) {
    this.animate(currentValue, totalValue);
  }

  animate = (current, total) =>
    Animated.timing(this.state.width, {
      toValue: current / total,
      duration: 4000,
      useNativeDriver: true,
    }).start();

  render() {
    const { variant } = this.props;
    const { width } = this.state;

    return (
      <Svg width={barWidth} height={barHeight}>
        <Defs>
          <LinearGradient
            id={`HeathManaBar-gradient-${variant}`}
            x1="0"
            y1="0"
            x2="0"
            y2={barHeight}
          >
            <Stop
              offset="0"
              stopColor={variant === "HEALTH" ? "#EC561B" : "#00ACE1"}
              stopOpacity="1"
            />
            <Stop
              offset="0.5"
              stopColor={variant === "HEALTH" ? "#8D1B00" : "#003FAA"}
              stopOpacity="1"
            />
            <Stop
              offset="1"
              stopColor={variant === "HEALTH" ? "#9F3606" : "#007C97"}
              stopOpacity="1"
            />
          </LinearGradient>
        </Defs>
        <AnimatedRect
          x="0"
          y="0"
          rx="3"
          ry="3"
          rectwidth={width.interpolate({
            inputRange: [0, 1],
            outputRange: [0, barWidth],
          })}
          width={width.interpolate({
            inputRange: [0, 1],
            outputRange: [0, barWidth],
          })}
          height={barHeight}
          fill={`url(#HeathManaBar-gradient-${variant})`}
        />
        <G>
          <AnimatedText
            positionY={width.interpolate({
              inputRange: [0, 1],
              outputRange: [0, barHeight],
            })}
            y={width.interpolate({
              inputRange: [0, 1],
              outputRange: [0, barHeight],
            })}
          >
            <AnimatedTSpan
              positionX={width.interpolate({
                inputRange: [0, 2],
                outputRange: [0, barWidth],
              })}
              x={width.interpolate({
                inputRange: [0, 2],
                outputRange: [0, barWidth],
              })}
            >
              Test
            </AnimatedTSpan>
          </AnimatedText>
        </G>
      </Svg>
    );
  }
}

export default function App() {
  return <HealthManaBar totalValue={100} currentValue={50} variant="HEALTH" />;
}
adam-s commented 5 years ago

There is a PanResponder in a different component with a class that contains a lot of D3 logic. All I'm looking for is the text to be on the same x coordinate as the touch event. It works with Circle, Line, Animated.View, and Animated.Text. (Yes, I know it isn't very 'reactive'. It is performant.)

import React from 'react';
import PropTypes from 'prop-types';
import { Animated } from 'react-native';
import { Text } from 'react-native-svg';

import CCColors from '../../common/components/CCColors';

const AnimatedText = Animated.createAnimatedComponent(Text);

const textStyle = {
  fill: CCColors.slate,
  fontFamily: "'Gill Sans', 'Gill Sans MT', 'Seravek', 'Trebuchet MS', sans-serif",
  fontSize: 14,
  letterSpacing: 'normal',
  stroke: 'transparent',
  textAnchor: 'middle',
};

const propTypes = {
  addListener: PropTypes.func.isRequired,
  removeListener: PropTypes.func.isRequired,
};
const defaultProps = {};

class HistoricalTooltip extends React.Component {
  constructor(props) {
    super(props);
    this.callback = this.callback.bind(this);
  }
  componentDidMount() {
    const { addListener } = this.props;
    addListener(this.callback);
  }
  componentWillUnmount() {
    const { removeListener } = this.props;
    removeListener(this.callback);
  }
  setRef(ref) {
    this.ref = ref;
  }
  callback(data) {
    const { x } = data;
    this.ref.setNativeProps({ x });
  }
  render() {
    return (
      <AnimatedText
        ref={ref => this.setRef(ref)}
        x={100}
        y={100}
      >
      Hello
      </AnimatedText>
    );
  }
}

HistoricalTooltip.propTypes = propTypes;
HistoricalTooltip.defaultProps = defaultProps;

export default HistoricalTooltip;
msand commented 5 years ago

Now with support for useNativeDriver with transform styles using the same syntax as for react-native views: https://github.com/react-native-community/react-native-svg/commit/fb4e877c2b23b4c52971c16d42c305d9f28a9eb5

import React from "react";
import { Dimensions, Animated } from "react-native";
import Svg, { Text, TSpan, G } from "react-native-svg";

const { width, height } = Dimensions.get("window");

const AnimatedG = Animated.createAnimatedComponent(G);

class NativeAnimGTransform extends React.Component {
  state = {
    anim: new Animated.Value(0),
  };

  componentDidMount() {
    this.animate(this.props.value);
  }

  componentDidUpdate({ value }) {
    this.animate(value);
  }

  animate = value =>
    Animated.timing(this.state.anim, {
      useNativeDriver: true,
      duration: 4000,
      toValue: value,
    }).start();

  render() {
    const { anim } = this.state;

    return (
      <Svg width={width} height={height}>
        <AnimatedG
          style={{
            transform: [
              {
                translateY: anim.interpolate({
                  inputRange: [0, 1],
                  outputRange: [0, 100],
                }),
              },
            ],
          }}
        >
          <Text>
            <TSpan>Test</TSpan>
          </Text>
        </AnimatedG>
      </Svg>
    );
  }
}

export default function App() {
  return <NativeAnimGTransform value={1} />;
}
msand commented 5 years ago

@adam-s Try something like this (with the latest commit from the master branch):

  callback(data) {
    this.ref.setNativeProps({ positionX: data.x });
  }
xzilja commented 5 years ago

@msand I tried this with latest master and it is now working, thank you! :)

Few caveats that I think are worth mentioning, in your example you have useNativeDriver: true, this only works with rectwidth, for width it needs to be disabled or not present.

I will stick with using rectwidth for animations as they seem to be more performant, is this safe to do?

What I mentioned above, seems like a "hidden" knowledge at the moment i.e I would have never figure this out from the docs at the moment and am still not sure of difference between width and rectwidth and why one works with native driver and other doesn't? If you explain it and feel like it might deserve space in the documentation, I can help with PR 👍

Shall this issue be closed or shall I close it after fix is up on npm?

wcandillon commented 5 years ago

@msand The updates you just posted are super exciting. Looking forward to try it out with expo in the near future.

@IljaDaderko Good to see you here 🙋🏼‍♂️

msand commented 5 years ago

The rectwith is needed if you want the native animation to work, the width and height properties have a naming collision with the same property name, so they have the element name prepended on the native side, and Animated needs the native name to be able to animate it with useNativeDriver set to true. Hopefully the naming collision could be avoided somehow, and the correct name would be sufficient.

msand commented 5 years ago

I would actually expect that we can get rid of the {use,mask,image,bb,pattern,rect}{width,height} attributes and just use width and height instead, as I've been able to override the transform https://github.com/react-native-community/react-native-svg/commit/fb4e877c2b23b4c52971c16d42c305d9f28a9eb5#diff-50f7e7f8735df31a6ac08ae41511a2a6R262 https://github.com/react-native-community/react-native-svg/commit/fb4e877c2b23b4c52971c16d42c305d9f28a9eb5#diff-ba367d8c1ef85007a13efc389f1b0608R156 view property handlers in the view managers, it should be possible to do the same with them as well.

xzilja commented 5 years ago

I would actually expect that we can get rid of the {use,mask,image,bb,pattern,rect}{width,height} attributes and just use width and height instead, as I've been able to override the transform fb4e877#diff-50f7e7f8735df31a6ac08ae41511a2a6R262 fb4e877#diff-ba367d8c1ef85007a13efc389f1b0608R156 view property handlers in the view managers, it should be possible to do the same with them as well.

If thats achievable, it definitely sounds less confusing and easier to get going with, I wonder how many people will skip setting useNativeDriver to true however since in general rn doesn't support it for width, only transitions and opacity atm I think? If skipped, will it cause any errors?

msand commented 5 years ago

@IljaDaderko I've simplified the handling in the way I thought should be possible: https://github.com/react-native-community/react-native-svg/commit/445780c121dbc5d8a683a7d33437ce84520f7583 Now you can skip rectwidth and simply use width instead, both with and without useNativeDriver:

import React from "react";
import { Dimensions, Animated } from "react-native";
import Svg, {
  LinearGradient,
  Defs,
  Rect,
  Stop,
  Text,
} from "react-native-svg";

const { width } = Dimensions.get("window");

const barWidth = width * 0.5;
const barHeight = barWidth * 0.5;
const AnimatedRect = Animated.createAnimatedComponent(Rect);
const AnimatedText = Animated.createAnimatedComponent(Text);

class HealthManaBar extends React.Component {
  state = {
    width: new Animated.Value(0),
  };

  componentDidMount() {
    const { currentValue, totalValue } = this.props;
    this.animate(currentValue, totalValue);
  }

  componentDidUpdate({ currentValue, totalValue }) {
    this.animate(currentValue, totalValue);
  }

  animate = (current, total) =>
    Animated.timing(this.state.width, {
      toValue: current / total,
      duration: 4000,
      useNativeDriver: true,
    }).start();

  render() {
    const { variant } = this.props;
    const { width } = this.state;

    return (
      <Svg width={barWidth} height={barHeight}>
        <Defs>
          <LinearGradient
            id={`HeathManaBar-gradient-${variant}`}
            x1="0"
            y1="0"
            x2="0"
            y2={barHeight}
          >
            <Stop
              offset="0"
              stopColor={variant === "HEALTH" ? "#EC561B" : "#00ACE1"}
              stopOpacity="1"
            />
            <Stop
              offset="0.5"
              stopColor={variant === "HEALTH" ? "#8D1B00" : "#003FAA"}
              stopOpacity="1"
            />
            <Stop
              offset="1"
              stopColor={variant === "HEALTH" ? "#9F3606" : "#007C97"}
              stopOpacity="1"
            />
          </LinearGradient>
        </Defs>
        <AnimatedRect
          x="0"
          y="0"
          rx="3"
          ry="3"
          width={width.interpolate({
            inputRange: [0, 1],
            outputRange: [0, barWidth],
          })}
          height={barHeight}
          fill={`url(#HeathManaBar-gradient-${variant})`}
        />
        <AnimatedText
          x={width.interpolate({
            inputRange: [0, 2],
            outputRange: [0, barWidth],
          })}
          y={width.interpolate({
            inputRange: [0, 2],
            outputRange: [0, barHeight],
          })}
        >
          Test
        </AnimatedText>
      </Svg>
    );
  }
}

export default function App() {
  return <HealthManaBar totalValue={100} currentValue={50} variant="HEALTH" />;
}
msand commented 5 years ago

The difference between useNativeDriver: false and true, is that when false: the animation is driven by javascript calling setNativeProps on the animated element; when true: the animation is serialised and sent over the bridge to be driven by the native animation driver events, without any further processing from js, which is much faster in essentially every case and doesn't get blocked by long running js computations. The main reason one would disable the native driver is if the properties one needs to animate aren't supported.

But, my commits from the past two days should be relatively good inspiration for how to get support for all the remaining properties. To get js driven (useNativeDriver: falsy) animation, the js property extraction logic needs to be run in the setNativeProps handler of the svg elements, and to get useNativeDriver: true support, the property setters in the native view managers need to change their input parameter type to Dynamic (java) / id (obj-c) and the appropriate transformation done from the values provided by the native driver to the types expected by the shadow nodes / native views.

These would be excellent first issues / PRs for anyone interested in contributing to wider animation support. ❤️

adam-s commented 5 years ago

I'm having luck using Victory Native primitives which wrap the react-native-svg elements. https://github.com/FormidableLabs/victory-native/tree/master/lib/components/victory-primitives

msand commented 5 years ago

Now with useNativeDriver animation support for all number accepting properties: https://github.com/react-native-community/react-native-svg/commit/864d7610d3aaea2b4e4f722f04ea52ddf292aae7

xzilja commented 5 years ago

NOTE: This is a long response but it walks through issue and solution I found for another animation issue.

@msand seems to be working well with some cases I tested, I tried to use same approach in example below, essentially trying to replicate https://kimmobrunfeldt.github.io/progressbar.js yellow progress-bar circle from here, but with gradient colour. Approach we used for width does not work here, is this because strokeDasharray accepts array instead of number ?

import * as React from 'react';
import { Animated, StyleSheet, ViewProperties } from 'react-native';
import Svg, { Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
import { deviceWidth } from '../services/Device';

const circleWidthHeight = deviceWidth * 0.2565;
const circleRadius = circleWidthHeight * 0.45;
const strokeThickness = deviceWidth * 0.007;
const circumference = circleRadius * 2 * Math.PI;

/**
 * Types
 */
export interface IProps {
  nextExp: number;
  currentExp: number;
  style?: ViewProperties['style'];
}

export interface IState {
  percentage: Animated.Value;
}

/**
 * Component
 */
class ExperienceCircle extends React.Component<IProps, IState> {
  state = {
    percentage: new Animated.Value(0)
  };

  componentDidMount() {
    const { currentExp, nextExp } = this.props;
    this.animate(currentExp, nextExp);
  }

  animate = (currentExp: number, nextExp: number) => {
    const percentage = currentExp / nextExp;
    Animated.timing(this.state.percentage, {
      toValue: percentage,
      useNativeDriver: true
    }).start();
  };

  render() {
    const { style } = this.props;
    const { percentage } = this.state;

    return (
      <Svg style={[styles.container, style]} width={circleWidthHeight} height={circleWidthHeight}>
        <Defs>
          <LinearGradient
            id="ExperienceCircle-gradient"
            x1="0"
            y1="0"
            x2="0"
            y2={circleWidthHeight * 2}
          >
            <Stop offset="0" stopColor="#DEF030" stopOpacity="1" />
            <Stop offset="0.5" stopColor="#71A417" stopOpacity="1" />
          </LinearGradient>
        </Defs>
        <Circle
          cx={circleWidthHeight / 2}
          cy={circleWidthHeight / 2}
          r={circleRadius}
          stroke="url(#ExperienceCircle-gradient)"
          strokeWidth={strokeThickness}
          fill="transparent"
          strokeDasharray={[
            percentage.interpolate({
              inputRange: [0, 1],
              outputRange: [0, circumference]
            }),
            circumference
          ]}
          strokeLinecap="round"
        />
      </Svg>
    );
  }
}

export default ExperienceCircle;

/**
 * Styles
 */
const styles = StyleSheet.create({
  container: {
    transform: [
      {
        rotate: '90deg'
      }
    ]
  }
});

As a workaround I tried to use setNativeProps approach below, but it throws following error

JSON value '300.120202' of type NSNumber cannot be converted to NSString

import * as React from 'react';
import { Animated, StyleSheet, ViewProperties } from 'react-native';
import Svg, { Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
import { deviceWidth } from '../services/Device';

const circleWidthHeight = deviceWidth * 0.2565;
const circleRadius = circleWidthHeight * 0.45;
const strokeThickness = deviceWidth * 0.007;
const circumference = circleRadius * 2 * Math.PI;

/**
 * Types
 */
export interface IProps {
  nextExp: number;
  currentExp: number;
  style?: ViewProperties['style'];
}

export interface IState {
  percentage: Animated.Value;
}

/**
 * Component
 */
class ExperienceCircle extends React.Component<IProps, IState> {
  state = {
    percentage: new Animated.Value(0)
  };

  circleRef = React.createRef();

  componentDidMount() {
    this.state.percentage.addListener(percentage => {
      const dashLength = percentage.value * circumference;
      this.circleRef.current.setNativeProps({
        strokeDasharray: [dashLength, circumference]
      });
    });
    this.animate(this.props.currentExp, this.props.nextExp);
  }

  componentDidUpdate({ currentExp, nextExp }: IProps) {
    this.animate(currentExp, nextExp);
  }

  animate = (currentExp: number, nextExp: number) => {
    const percentage = currentExp / nextExp;
    Animated.timing(this.state.percentage, {
      toValue: percentage
    }).start();
  };

  render() {
    const { style } = this.props;

    return (
      <Svg style={[styles.container, style]} width={circleWidthHeight} height={circleWidthHeight}>
        <Defs>
          <LinearGradient
            id="ExperienceCircle-gradient"
            x1="0"
            y1="0"
            x2="0"
            y2={circleWidthHeight * 2}
          >
            <Stop offset="0" stopColor="#DEF030" stopOpacity="1" />
            <Stop offset="0.5" stopColor="#71A417" stopOpacity="1" />
          </LinearGradient>
        </Defs>
        <Circle
          ref={this.circleRef}
          cx={circleWidthHeight / 2}
          cy={circleWidthHeight / 2}
          r={circleRadius}
          stroke="url(#ExperienceCircle-gradient)"
          strokeWidth={strokeThickness}
          fill="transparent"
          strokeDasharray={[0, circumference]}
          strokeLinecap="round"
        />
      </Svg>
    );
  }
}

export default ExperienceCircle;

/**
 * Styles
 */
const styles = StyleSheet.create({
  container: {
    transform: [
      {
        rotate: '90deg'
      }
    ]
  }
});

After fiddling around with setNativeProps approach I got it working by adding toString here

 this.circleRef.current.setNativeProps({
     strokeDasharray: [dashLength.toString(), circumference.toString()]
 });

But I am really confused to why this works, as both of these seemed fine when numeric values are used straight on the <Circle />

msand commented 5 years ago

Yeah, this is expected, the stroke and fill extraction logic isn't run in setNativeProps (yet, perhaps a pr would be in order 😄), you can replicate the logic in the way you've done, or reuse parts of extractStroke (which is run in extractProps): https://github.com/react-native-community/react-native-svg/blob/4e6ba9a786787672398dc32981ab81f2c3e8c187/elements/Circle.js#L24-L42

https://github.com/react-native-community/react-native-svg/blob/e7d0eb6df676d4f63f9eba7c0cf5ddd6c4c85fbe/lib/extract/extractProps.js#L42-L43

https://github.com/react-native-community/react-native-svg/blob/d0c0b048bfb9035cede8254436875ab5e57ea3f7/lib/extract/extractStroke.js#L30-L41

The native side expects an array of strings in this case: https://github.com/react-native-community/react-native-svg/blob/864d7610d3aaea2b4e4f722f04ea52ddf292aae7/ios/ViewManagers/RNSVGRenderableManager.m#L39

https://github.com/react-native-community/react-native-svg/blob/fb4e877c2b23b4c52971c16d42c305d9f28a9eb5/ios/RNSVGRenderable.m#L114-L134

https://github.com/react-native-community/react-native-svg/blob/864d7610d3aaea2b4e4f722f04ea52ddf292aae7/android/src/main/java/com/horcrux/svg/RenderableViewManager.java#L1020-L1023

https://github.com/react-native-community/react-native-svg/blob/864d7610d3aaea2b4e4f722f04ea52ddf292aae7/android/src/main/java/com/horcrux/svg/RenderableShadowNode.java#L155-L167

I don't know if useNativeDriver allows animating an array of numbers this way, but at least it should work without it. Otherwise, you can use my fork of react-native which has support for string interpolation, it should work there at least.

msand commented 5 years ago

Reading the code now, I noticed that percentage values in strokeDashArray can't possibly be working correctly in iOS as it just takes the floatValue of the string.

xzilja commented 5 years ago

@msand gotcha, I wasn't using % values, just had to stringily numbers in the array when I pass them to setNativeProps. It works fine that way 👍

Thank you for clarification. One final side question regarding this whole issue: Do you have approximate eta when changes on master might go up to npm?

msand commented 5 years ago

Great. Yeah, just thought I would mention the % issue as I noticed it's not spec conformant, more a note to self for later.

Well, I would love to have a bit more testing of it before releasing. But as things usually go, the only way to get significant amounts of testing is to make a new release, most people don't live on the bleeding edge of the master branch. I could probably merge some PRs and cut a new release today, to speed up the process. People can always downgrade to whatever version worked the best previously and open issues to report any problems.

msand commented 5 years ago

Published v7.1.0 now

xzilja commented 5 years ago

So far works fine on my end, I will close this issue as its concern was addressed with this release. Thank you for amazing support!

msand commented 5 years ago

You're welcome, happy we got it fixed 🎉 I've simplified the property handling in the javascript side now, as the view managers can handle both string and number types with the recent changes: https://github.com/react-native-community/react-native-svg/commit/1e25870f5d04b91112e7167240dd1a7d4958815f