一直以来,动画都是移动开发中极为特殊的一块。一方面,动画在交互体验上有着不可替代的优越处,然而另外一方面,动画的开发又极为的耗时,须要消耗工程师大量的时间用于开发和调试。再来看前端,前端的动画实现,通过多年的发展,已分为 CSS3 动画和 JavaScript 动画。html
React Native 做为一个复用前端思想的移动开发框架,并无完整实现CSS,而是使用JavaScript来给应用添加样式。这是一个有争议的决定,能够参考这个幻灯片来了解 Facebook 作的理由。天然,在动画上,由于缺乏大量的 CSS 属性,React Naive 中的动画均为 JavaScript 动画,即经过 JavaScript 代码控制图像的各类参数值的变化,从而产生时间轴上的动画效果。前端
React Native 的官方文档已经详细地介绍了 React Native 通常动画的使用方法和实例,在此再也不赘述。然而阅读官方文档后可知,官方的动画每每是给一个完整的物体添加各类动画效果,如透明度,翻转,移动等等。可是对于物体的自身变化,好比以下这个进度条,明显是在旋转的同时也在伸缩,则缺少必要的实现方法。这是由于,动画的本质既是图形的各类参数的数值变化的过程,文档中的 Animated.Value 就是用做被驱动的参数,能够,想要让一个圆环可以伸缩,就必须让数值变化的过程,深刻到图形生成的过程当中,而不是如官方文档的例子同样,仅仅是施加于图形生成完毕后的过程,那么也就没法实现改变图形自身的动画效果了。node
拙做初窥基于 react-art 库的 React Native SVG已讨论了 React Native 中静态 SVG 的开发方法,本文则致力于探究 React Native 中 SVG 与 Animation 结合所实现的 SVG 动画。也就是能够改变图形自身的动画效果。此外还探究了 Value 驱动动画在实现方法上的不一样之处。react
本节即以实现一个下图所示的旋转的进度条的例子,讲述 React Native SVG 动画的开发方法。git
Wedge.art.js 位于 react-art 库下 lib/ 文件夹内,提供了 SVG 扇形的实现,然而缺少对 cx, cy 属性的支持。另外拙做以前也提到了,Wedge中的扇形较为诡异,只有一条半径,为了实现进度条效果我把另外一条半径也去掉了。我将 Wedge.art.js 拷贝到工程中,自行小修改后的代码以下。github
// 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;
而后就是实现的主体。其中值得关注的点是:web
并不是任何 Component 均可以直接用 Animated.Value
去赋值 Props,而须要对 Component 作必定的改造。Animated.createAnimatedComponent(Component component)
,是 Animated 库提供的用于把普通 Component 改造为 AnimatedComponent 的函数。阅读 React Native 源代码会发现,Animated.Text, Animated.View, Animated.Image,都是直接调用了该函数去改造系统已有的组件,如Animated.createAnimatedComponent(React.Text)
。segmentfault
Easing 库较为隐蔽,明明在react-native/Library/Animated/
路径下,却又须要从React中直接引出。它为动画的实现提供了许多缓动函数,可根据实际需求选择。如 linear()
线性,quad()
二次(quad明明是四次方的意思,为毛代码实现是t*t....),cubic()
三次等等。官方文档中吹嘘 Easing 中提供了 tons of functions(成吨的函数),然而我数过了明明才14个,233333。react-native
该动画由起始角度和终止角度两个变化的参数来控制,所以,两个Animated.Value
须要同时启动,这涉及到了动画的组合问题。React Native 为此提供了 parallel
, sequence
,stagger
和 delay
四个函数。其主要实现都可在react-native/Library/Animated/Animate中找到,官方文档中亦有说明。这里用的是Animated.parallel
。框架
开发中遇到的问题有:
该动画在 Android 上能够运行,可是刷新频率看上去只有两帧,没法造成一个天然过渡的动画,笔者怀疑是 React Native Android 对 SVG 的支持仍有缺陷。
SVG 图形和普通 React Native View 的叠加问题,目前我尚未找到解决方法。感受只能等 React Native 开发组的进一步支持。
动画播放总会有一个莫名其妙的下拉回弹效果,然而代码上没有任何额外的控制。
// 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。
为何要特地强调这一点呢,若是咱们想要作一个以下图所示的从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。
言归正传,重要的修改有:
修改了 attachProps 函数。对于任何变更的 props,原来的代码会试图使用 setNativeProps 函数进行更新,若 setNativeProps 函数为空,才会使用 forceUpdate() 函数。对于 props,setNativeProps 函数是可行的,然而对 value 无效。我猜想,setNativeProps 方法在 Android 底层可能就是 setColor() 相似的 Java 方法,然而并无获得实证。目前这种 forceUpdate,由注释知,是完全更新了整个 Component,至关于先从 DOM 树上取下一个旧节点,再放上一个新节点,在性能的利用上较为浪费。
使用 PropTypes.xxx.isRequired 来进行参数的类型检查。PropTypes 检查支持的类型可在 react-native/node_modules/react/lib/ReactPropTypes.js
中看到,在此再也不赘述。
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