探究 React Native 中 Props 驅動的 SVG 動畫和 Value 驅動動畫

引言

一直以來,動畫都是移動開發中極爲特殊的一塊。一方面,動畫在交互體驗上有着不可替代的優越處,然而另外一方面,動畫的開發又極爲的耗時,須要消耗工程師大量的時間用於開發和調試。再來看前端,前端的動畫實現,通過多年的發展,已分爲 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

Props 驅動的 SVG 動畫

本節即以實現一個下圖所示的旋轉的進度條的例子,講述 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

  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)segmentfault

  2. Easing 庫較爲隱蔽,明明在react-native/Library/Animated/路徑下,卻又須要從React中直接引出。它爲動畫的實現提供了許多緩動函數,可根據實際需求選擇。如 linear() 線性,quad() 二次(quad明明是四次方的意思,爲毛代碼實現是t*t....),cubic() 三次等等。官方文檔中吹噓 Easing 中提供了 tons of functions(成噸的函數),然而我數過了明明才14個,233333。react-native

  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

相關文章
相關標籤/搜索