探讨 React Native 中 Props 驱动的 SVG 动画和 Value 驱动动画

弁言

一直以来,动画都是挪动开辟中极为特别的一块。一方面,动画在交互体验上有着不可替换的优胜处,然则另一方面,动画的开辟又极为的耗时,须要斲丧工程师大批的时候用于开辟和调试。再来看前端,前端的动画完成,经由多年的生长,已分为 CSS3 动画和 JavaScript 动画。

React Native 作为一个复用前端头脑的挪动开辟框架,并没有完全完成CSS,而是运用JavaScript来给运用增加款式。这是一个有争议的决议,可以参考这个幻灯片来相识 Facebook 做的来由。天然,在动画上,由于缺少大批的 CSS 属性,React Naive 中的动画均为 JavaScript 动画,即经由历程 JavaScript 代码掌握图象的种种参数值的变化,从而发生时候轴上的动画结果。

React Native 的官方文档已细致地引见了 React Native 平常动画的运用要领和实例,在此不再赘述。然则浏览官方文档后可知,官方的动画往往是给一个完全的物体增加种种动画结果,如透明度,翻转,挪动等等。然则关于物体的本身变化,比方以下这个进度条,显著是在扭转的同时也在伸缩,则缺少必要的完成要领。这是由于,动画的实质既是图形的种种参数的数值变化的历程,文档中的 Animated.Value 就是用作被驱动的参数,可以,想要让一个圆环可以伸缩,就必需让数值变化的历程,深切到图形天生的历程当中,而不是如官方文档的例子一样,仅仅是施加于图形天生终了后的历程,那末也就没法完成转变图形本身的动画结果了。

拙作初窥基于 react-art 库的 React Native SVG已议论了 React Native 中静态 SVG 的开辟要领,本文则致力于探讨 React Native 中 SVG 与 Animation 连系所完成的 SVG 动画。也就是可以转变图形本身的动画结果。别的还探讨了 Value 驱动动画在完成要领上的不同之处。

Props 驱动的 SVG 动画

本节即以完成一个下图所示的扭转的进度条的例子,报告 React Native SVG 动画的开辟要领。

《探讨 React Native 中 Props 驱动的 SVG 动画和 Value 驱动动画》

Wedge.art.js 位于 react-art 库下 lib/ 文件夹内,供应了 SVG 扇形的完成,然则缺少对 cx, cy 属性的支撑。别的拙作之前也提到了,Wedge中的扇形较为诡异,只要一条半径,为了完成进度条结果我把另一条半径也去掉了。我将 Wedge.art.js 拷贝到工程中,自行小修正后的代码以下。

// wedge.js

/**
 * Copyright 2013-2014 Facebook, Inc.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 *
 * @providesModule Wedge.art
 * @typechecks
 *
 * Example usage:
 * <Wedge
 *   outerRadius={50}
 *   startAngle={0}
 *   endAngle={360}
 *   fill="blue"
 * />
 *
 * Additional optional property:
 *   (Int) innerRadius
 *
 */
'use strict';
var React = require('react-native');
var ReactART = React.ART;

var $__0 =  React,PropTypes = $__0.PropTypes;
var Shape = ReactART.Shape;
var Path = ReactART.Path;

/**
 * Wedge is a React component for drawing circles, wedges and arcs.  Like other
 * ReactART components, it must be used in a <Surface>.
 */
var Wedge = React.createClass({displayName: "Wedge",

  propTypes: {
    outerRadius: PropTypes.number.isRequired,
    startAngle: PropTypes.number.isRequired,
    endAngle: PropTypes.number.isRequired,
    innerRadius: PropTypes.number,
    cx: PropTypes.number,
    cy: PropTypes.number
  },

  circleRadians: Math.PI * 2,

  radiansPerDegree: Math.PI / 180,

  /**
   * _degreesToRadians(degrees)
   *
   * Helper function to convert degrees to radians
   *
   * @param {number} degrees
   * @return {number}
   */
  _degreesToRadians: function(degrees) {
    if (degrees !== 0 && degrees % 360 === 0) { // 360, 720, etc.
      return this.circleRadians;
    } else {
      return degrees * this.radiansPerDegree % this.circleRadians;
    }
  },

  /**
   * _createCirclePath(or, ir)
   *
   * Creates the ReactART Path for a complete circle.
   *
   * @param {number} or The outer radius of the circle
   * @param {number} ir The inner radius, greater than zero for a ring
   * @return {object}
   */
  _createCirclePath: function(or, ir) {
    var path = Path();

    path.move(this.props.cx, or + this.props.cy)
        .arc(or * 2, 0, or)
        .arc(-or * 2, 0, or);

    if (ir) {
      path.move(this.props.cx + or - ir, this.props.cy)
          .counterArc(ir * 2, 0, ir)
          .counterArc(-ir * 2, 0, ir);
    }

    path.close();

    return path;
  },

  /**
   * _createArcPath(sa, ea, ca, or, ir)
   *
   * Creates the ReactART Path for an arc or wedge.
   *
   * @param {number} startAngle The starting degrees relative to 12 o'clock
   * @param {number} endAngle The ending degrees relative to 12 o'clock
   * @param {number} or The outer radius in pixels
   * @param {number} ir The inner radius in pixels, greater than zero for an arc
   * @return {object}
   */
  _createArcPath: function(startAngle, endAngle, or, ir) {
      var path = Path();

      // angles in radians
      var sa = this._degreesToRadians(startAngle);
      var ea = this._degreesToRadians(endAngle);

      // central arc angle in radians
      var ca = sa > ea ? this.circleRadians - sa + ea : ea - sa;

      // cached sine and cosine values
      var ss = Math.sin(sa);
      var es = Math.sin(ea);
      var sc = Math.cos(sa);
      var ec = Math.cos(ea);

      // cached differences
      var ds = es - ss;
      var dc = ec - sc;
      var dr = ir - or;

      // if the angle is over pi radians (180 degrees)
      // we will need to let the drawing method know.
      var large = ca > Math.PI;

      // TODO (sema) Please improve theses comments to make the math
      // more understandable.
      //
      // Formula for a point on a circle at a specific angle with a center
      // at (0, 0):
      // x = radius * Math.sin(radians)
      // y = radius * Math.cos(radians)
      //
      // For our starting point, we offset the formula using the outer
      // radius because our origin is at (top, left).
      // In typical web layout fashion, we are drawing in quadrant IV
      // (a.k.a. Southeast) where x is positive and y is negative.
      //
      // The arguments for path.arc and path.counterArc used below are:
      // (endX, endY, radiusX, radiusY, largeAngle)

      path.move(or + or * ss + this.props.cx, or - or * sc + this.props.cy) // move to starting point
          .arc(or * ds, or * -dc, or, or, large) // outer arc

        //   .line(dr * es, dr * -ec);  // width of arc or wedge

      if (ir) {
        path.counterArc(ir * -ds, ir * dc, ir, ir, large); // inner arc
      }

      return path;
  },

  render: function() {
    // angles are provided in degrees
    var startAngle = this.props.startAngle;
    var endAngle = this.props.endAngle;
    if (startAngle - endAngle === 0) {
      return;
    }

    // radii are provided in pixels
    var innerRadius = this.props.innerRadius || 0;
    var outerRadius = this.props.outerRadius;

    // sorted radii
    var ir = Math.min(innerRadius, outerRadius);
    var or = Math.max(innerRadius, outerRadius);

    var path;
    if (endAngle >= startAngle + 360) {
      path = this._createCirclePath(or, ir);
    } else {
      path = this._createArcPath(startAngle, endAngle, or, ir);
    }

    return React.createElement(Shape, React.__spread({},  this.props, {d: path}));
  }

});

module.exports = Wedge;

然后就是完成的主体。个中值得关注的点是:

  1. 并不是任何 Component 都可以直接用 Animated.Value 去赋值 Props,而须要对 Component 做肯定的革新。Animated.createAnimatedComponent(Component component),是 Animated 库供应的用于把一般 Component 革新为 AnimatedComponent 的函数。浏览 React Native 源代码会发明,Animated.Text, Animated.View, Animated.Image,都是直接挪用了该函数去革新体系已有的组件,如Animated.createAnimatedComponent(React.Text)

  2. Easing 库较为隐藏,明显在react-native/Library/Animated/途径下,却又须要从React中直接引出。它为动画的完成供应了很多缓动函数,可根据现实需求挑选。如 linear() 线性,quad() 二次(quad明显是四次方的意义,为毛代码完成是t*t….),cubic() 三次等等。官方文档中揄扬 Easing 中供应了 tons of functions(成吨的函数),然则我数过了明显才14个,233333。

  3. 该动画由肇端角度和停止角度两个变化的参数来掌握,因而,两个Animated.Value须要同时启动,这触及到了动画的组合题目。React Native 为此供应了 parallelsequencestaggerdelay 四个函数。其主要完成均可在react-native/Library/Animated/Animate中找到,官方文档中亦有申明。这里用的是Animated.parallel

开辟中碰到的题目有:

  1. 该动画在 Android 上可以运转,然则革新频次看上去只要两帧,没法构成一个天然过渡的动画,笔者怀疑是 React Native Android 对 SVG 的支撑仍有缺点。

  2. SVG 图形和一般 React Native View 的叠加题目,现在我还没有找到解决要领。以为只能等 React Native 开辟组的进一步支撑。

  3. 动画播放总会有一个稀里糊涂的下拉回弹结果,然则代码上没有任何分外的掌握。

// RotatingWedge.js
'use strict';

var React = require('react-native');

var {
  ART,
  View,
  Animated,
  Easing,
} = React;

var Group = ART.Group;
var Surface = ART.Surface;
var Wedge = require('./Wedge');

var AnimatedWedge = Animated.createAnimatedComponent(Wedge);

var VectorWidget = React.createClass({

  getInitialState: function() {
    return {
      startAngle: new Animated.Value(90),
      endAngle: new Animated.Value(100),
    };
  },

  componentDidMount: function() {
    Animated.parallel([
      Animated.timing(
        this.state.endAngle,
        {
          toValue: 405,
          duration: 700,
          easing: Easing.linear,
        }
      ),
      Animated.timing(
        this.state.startAngle,
        {
          toValue: 135,
          duration: 700,
          easing: Easing.linear,
        })
    ]).start();
  },
  
  render: function() {
    return (
      <View>
        <Surface
          width={700}
          height={700}
        >
          {this.renderGraphic()}
        </Surface>
      </View>
    );
  },

  renderGraphic: function() {
    console.log(this.state.endAngle.__getValue());
    return (
      <Group>
        <AnimatedWedge
          cx={100}
          cy={100}
          outerRadius={50}
          stroke="black"
          strokeWidth={2.5}
          startAngle={this.state.startAngle}
          endAngle={this.state.endAngle}
          fill="FFFFFF"/>
      </Group>
    );
  }
});

module.exports = VectorWidget;

Value 驱动的动画

接下来看 Value 驱动的 SVG 动画。先解释一下 Value 和 Props 的区分。<Text color='black'></Text>,这里的 color 就是 Props,<Text>black</Text>这里的 black 就是 value。

为何要特地强调这一点呢,假如我们想要做一个以下图所示的从10到30更改的数字,根据上节所述的要领,直接挪用 Animated.createAnimatedComponent(React.Text)所天生的 Component ,然后给 Value 赋值一个Animated.Value(),然后Animated.timing…,是没法发生如许的结果的。

《探讨 React Native 中 Props 驱动的 SVG 动画和 Value 驱动动画》

必需要对库中的createAnimatedComponent()函数做肯定的革新。革新后的函数以下:

var AnimatedProps = Animated.__PropsOnlyForTests;

function createAnimatedTextComponent() {
    var refName = 'node';

    class AnimatedComponent extends React.Component {
        _propsAnimated: AnimatedProps;

        componentWillUnmount() {
            this._propsAnimated && this._propsAnimated.__detach();
        }

        setNativeProps(props) {
            this.refs[refName].setNativeProps(props);
        }

        componentWillMount() {
            this.attachProps(this.props);
        }

        attachProps(nextProps) {
            var oldPropsAnimated = this._propsAnimated;

            /** 症结修正,强迫革新。
            本来的代码是:
             var callback = () => {
               if (this.refs[refName].setNativeProps) {
                 var value = this._propsAnimated.__getAnimatedValue();
                 this.refs[refName].setNativeProps(value);
               } else {
                 this.forceUpdate();
               }
             };
            **/
            var callback = () => {
                this.forceUpdate();
            };

            this._propsAnimated = new AnimatedProps(
                nextProps,
                callback,
            );

            oldPropsAnimated && oldPropsAnimated.__detach();
        }

        componentWillReceiveProps(nextProps) {
            this.attachProps(nextProps);
        }

        render() {
            var tmpText = this._propsAnimated.__getAnimatedValue().text;
            return (
                <Text
                    {...this._propsAnimated.__getValue()}
                    ref={refName}
                >
                    {Math.floor(tmpText)}
                </Text>
            );
        }
    }

    return AnimatedComponent;
}

为了猎取必需要用到的AnimatedProps,笔者以至违犯了品德的束缚,访问了双下划线前缀的变量Animated.__PropsOnlyForTests,真是罪行啊XD。

言归正传,主要的修正有:

  1. 修正了 attachProps 函数。关于任何更改的 props,本来的代码会试图运用 setNativeProps 函数举行更新,若 setNativeProps 函数为空,才会运用 forceUpdate() 函数。关于 props,setNativeProps 函数是可行的,然则对 value 无效。我猜想,setNativeProps 要领在 Android 底层能够就是 setColor() 相似的 Java 要领,然则并没有获得实证。现在这类 forceUpdate,由解释知,是完全更新了悉数 Component,相当于先从 DOM 树上取下一个旧节点,再放上一个新节点,在机能的利用上较为糟蹋。

  2. 运用 PropTypes.xxx.isRequired 来举行参数的范例搜检。PropTypes 搜检支撑的范例可在 react-native/node_modules/react/lib/ReactPropTypes.js 中看到,在此不再赘述。

  3. Animated.value() 从10到30变化的历程是一个随机采样的历程,并不肯定会卡在整数值上,因而还须要做一些小处置惩罚。

值得注意的是,该动画在 Android 上虽然可以一般运转,但也存在丢帧的题目,远远不能如 iOS 上流通天然。关于这一点,只能守候 Facebook 的进一步优化。

悉数的代码以下:

// RisingNumber.js
'use strict';

var React = require('react-native');

var {
    Text,
    Animated,
    Easing,
    PropTypes,
    View,
    StyleSheet,
} = React;

var AnimatedText = createAnimatedTextComponent();
var AnimatedProps = Animated.__PropsOnlyForTests;

function createAnimatedTextComponent() {
    var refName = 'node';

    class AnimatedComponent extends React.Component {
        _propsAnimated: AnimatedProps;

        componentWillUnmount() {
            this._propsAnimated && this._propsAnimated.__detach();
        }

        setNativeProps(props) {
            this.refs[refName].setNativeProps(props);
        }

        componentWillMount() {
            this.attachProps(this.props);
        }

        attachProps(nextProps) {
            var oldPropsAnimated = this._propsAnimated;

            var callback = () => {
                this.forceUpdate();
            };

            this._propsAnimated = new AnimatedProps(
                nextProps,
                callback,
            );

            oldPropsAnimated && oldPropsAnimated.__detach();
        }

        componentWillReceiveProps(nextProps) {
            this.attachProps(nextProps);
        }

        render() {
            var tmpText = this._propsAnimated.__getAnimatedValue().text;
            return (
                <Text
                    {...this._propsAnimated.__getValue()}
                    ref={refName}
                >
                    {Math.floor(tmpText)}
                </Text>
            );
        }
    }

    return AnimatedComponent;
}

var RisingNumber = React.createClass({
    propTypes: {
        startNumber: PropTypes.number.isRequired,
        toNumber: PropTypes.number.isRequired,
        startFontSize: PropTypes.number.isRequired,
        toFontSize: PropTypes.number.isRequired,
        duration: PropTypes.number.isRequired,
        upperText: PropTypes.string.isRequired,
    },

    getInitialState: function() {
        return {
            number: new Animated.Value(this.props.startNumber),
            fontSize: new Animated.Value(this.props.startFontSize),
        };
    },

    componentDidMount: function() {
        Animated.parallel([
            Animated.timing(
                this.state.number,
                {
                    toValue: this.props.toNumber,
                    duration: this.props.duration,
                    easing: Easing.linear,
                },
            ),
            Animated.timing(
                this.state.fontSize,
                {
                    toValue: this.props.toFontSize,
                    duration: this.props.duration,
                    easing: Easing.linear,
                }
            )
        ]).start();
    },

    render: function() {
        return (
            <View>
                <Text style={styles.kind}>{this.props.upperText}</Text>
                <AnimatedText
                    style={{fontSize: this.state.fontSize, marginLeft: 15}}
                    text={this.state.number} />
            </View>
        );
    },
});

var styles = StyleSheet.create({
    kind: {
        fontSize: 15,
        color: '#01A971',
    },
    number: {
        marginLeft: 15,
    },
});

module.exports = RisingNumber;

====================================
假如您以为我的文章对您有所启示,请点击文末的引荐按钮,您的勉励将会成为我对峙写作的莫大鼓励。 by DesGemini

    原文作者:DesGemini
    原文地址: https://segmentfault.com/a/1190000004711886
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞