2

引言

一直以来,动画都是移动开发中极为特殊的一块。一方面,动画在交互体验上有着不可替代的优越处,然而另一方面,动画的开发又极为的耗时,需要消耗工程师大量的时间用于开发和调试。再来看前端,前端的动画实现,经过多年的发展,已分为 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 动画的开发方法。

图片描述

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...,是无法产生这样的效果的。

图片描述

必须要对库中的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
545 声望28 粉丝

Harbin Institute of Technology