一直以来,动画都是移动开发中极为特殊的一块。一方面,动画在交互体验上有着不可替代的优越处,然而另一方面,动画的开发又极为的耗时,需要消耗工程师大量的时间用于开发和调试。再来看前端,前端的动画实现,经过多年的发展,已分为 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 驱动动画在实现方法上的不同之处。
本节即以实现一个下图所示的旋转的进度条的例子,讲述 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;      然后就是实现的主体。其中值得关注的点是:
Animated.Value 去赋值 Props,而需要对 Component 做一定的改造。Animated Animated.createAnimatedComponent(Component component) ,是 Animated 库提供的用于把普通 Component 改造为 AnimatedComponent 的函数。阅读 React Native 源代码会发现,Animated.Text, Animated.View, Animated.Image,都是直接调用了该函数去改造系统已有的组件,如 Animated.createAnimatedComponent(React.Text) 。 react-native/Library/Animated/ 路径下,却又需要从React中直接引出。它为动画的实现提供了许多缓动函数,可根据实际需求选择。如 linear() 线性, quad() 二次(quad明明是四次方的意思,为毛代码实现是t*t….), cubic() 三次等等。官方文档中吹嘘 Easing 中提供了 tons of functions(成吨的函数),然而我数过了明明才14个,233333。 Animated.Value 需要同时启动,这涉及到了动画的组合问题。React Native 为此提供了 parallel , sequence , stagger 和 delay 四个函数。其主要实现均可在react-native/Library/Animated/Animate中找到,官方文档中亦有 说明 。这里用的是 Animated.parallel 。 开发中遇到的问题有:
// 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 驱动的 SVG 动画。先解释一下 Value 和 Props 的区别。 <Text color='black'></Text> ,这里的 color 就是 Props, <Text>black</Text> 这里的 black 就是 value。 
 为什么要特意强调这一点呢,如果我们想要做一个如下图所示的从1到20变动的数字,按照上节所述的方法,直接调用 Animated.createAnimatedComponent(React.Text) 所生成的 Component ,然后给 Value 赋值一个Animated.Value(),然后Animated.timing…,是无法产生这样的效果的。 
 
 
 必须要对库中的 createAnimatedComponent() 函数做一定的改造。改造后的函数如下: 
var AnimatedProps = Animated.__PropsOnlyForTests;   function createAnimatedTextComponent() {     var refName = 'node';       class AnimatedComponentextends 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。 
言归正传,重要的修改有:
react-native/node_modules/react/lib/ReactPropTypes.js 中看到,在此不再赘述。 值得注意的是,该动画在 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 AnimatedComponentextends 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>                 <Textstyle={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;