React實現動畫效果

code_xzh發表於2016-10-25

流暢、有意義的動畫對於移動應用使用者體驗來說是非常必要的。和React Native的其他部分一樣,動畫API也還在積極開發中,不過我們已經可以聯合使用兩個互補的系統:用於全域性的佈局動畫LayoutAnimation,和用於建立更精細的互動控制的動畫Animated

Animated

Animated庫使得開發者可以非常容易地實現各種各樣的動畫和互動方式,並且具備極高的效能。Animated僅關注動畫的輸入與輸出宣告,在其中建立一個可配置的變化函式,然後使用簡單的start/stop方法來控制動畫按順序執行。下面是一個在載入時帶有簡單的彈跳動畫的元件示例:

class Playground extends React.Component {
  constructor(props: any) {
    super(props);
    this.state = {
      bounceValue: new Animated.Value(0),
    };
  }
  render(): ReactElement {
    return (
      <Animated.Image                         // 可選的基本元件型別: Image, Text, View
        source={{uri: `http://i.imgur.com/XMKOH81.jpg`}}
        style={{
          flex: 1,
          transform: [                        // `transform`是一個有序陣列(動畫按順序執行)
            {scale: this.state.bounceValue},  // 將`bounceValue`賦值給 `scale`
          ]
        }}
      />
    );
  }
  componentDidMount() {
    this.state.bounceValue.setValue(1.5);     // 設定一個較大的初始值
    Animated.spring(                          // 可選的基本動畫型別: spring, decay, timing
      this.state.bounceValue,                 // 將`bounceValue`值動畫化
      {
        toValue: 0.8,                         // 將其值以動畫的形式改到一個較小值
        friction: 1,                          // Bouncier spring
      }
    ).start();                                // 開始執行動畫
  }
}

bounceValue在建構函式中初始化為state的一部分,然後和圖片的縮放比例進行繫結。在動畫執行的背後,其數值會被不斷的計算並用於設定縮放比例。當元件剛剛掛載的時候,縮放比例被設定到1.5。然後緊跟著在bounceValue上執行了一個彈跳動畫(spring),會逐幀重新整理數值,並同步更新所有依賴本數值的繫結(在這個例子裡,就是圖片的縮放比例)。比起呼叫setState然後重新渲染,這一執行過程要快得多。因為整個配置都是宣告式的,我們可以實現更進一步的優化,只要序列化好配置,然後我們可以在一個高優先順序的執行緒執行動畫。

核心API

大部分你需要的東西都來自Animated模組。它包括兩個值型別,Value用於單個的值,而ValueXY用於向量值;還包括三種動畫型別,springdecay,還有timing,以及三種元件型別,ViewTextImage。你可以使用Animated.createAnimatedComponent方法來對其它型別的元件建立動畫。

這三種動畫型別可以用來建立幾乎任何你需要的動畫曲線,因為它們每一個都可以被自定義:

  • spring: 基礎的單次彈跳物理模型,符合Origami設計標準
    • friction: 摩擦力,預設為7.
    • tension: 張力,預設40。
  • decay: 以一個初始速度開始並且逐漸減慢停止。
    • velocity: 起始速度,必填引數。
    • deceleration: 速度衰減比例,預設為0.997。
  • timing: 從時間範圍對映到漸變的值。
    • duration: 動畫持續的時間(單位是毫秒),預設為500。
    • easing:一個用於定義曲線的漸變函式。閱讀Easing模組可以找到許多預定義的函式。iOS預設為Easing.inOut(Easing.ease)
    • delay: 在一段時間之後開始動畫(單位是毫秒),預設為0。

動畫可以通過呼叫start方法來開始。start接受一個回撥函式,當動畫結束的時候會呼叫此回撥函式。如果動畫是因為正常播放完成而結束的,回撥函式被呼叫時的引數為{finished: true},但若動畫是在結束之前被呼叫了stop而結束(可能是被一個手勢或者其它的動畫打斷),它會收到引數{finished: false}

組合動畫

多個動畫可以通過parallel(同時執行)、sequence(順序執行)、staggerdelay來組合使用。它們中的每一個都接受一個要執行的動畫陣列,並且自動在適當的時候呼叫start/stop。舉個例子:

Animated.sequence([            // 首先執行decay動畫,結束後同時執行spring和twirl動畫
  Animated.decay(position, {   // 滑行一段距離後停止
    velocity: {x: gestureState.vx, y: gestureState.vy}, // 根據使用者的手勢設定速度
    deceleration: 0.997,
  }),
  Animated.parallel([          // 在decay之後並行執行:
    Animated.spring(position, {
      toValue: {x: 0, y: 0}    // 返回到起始點開始
    }),
    Animated.timing(twirl, {   // 同時開始旋轉
      toValue: 360,
    }),
  ]),
]).start();                    // 執行這一整套動畫序列

預設情況下,如果任何一個動畫被停止或中斷了,組內所有其它的動畫也會被停止。Parallel有一個stopTogether屬性,如果設定為false,可以禁用自動停止。

插值(interpolate)

Animated API還有一個很強大的部分就是interpolate插值函式。它可以接受一個輸入區間,然後將其對映到另一個的輸出區間。下面是一個一個簡單的從0-1區間到0-100區間的對映示例:

value.interpolate({
  inputRange: [0, 1],
  outputRange: [0, 100],
});

interpolate還支援定義多個區間段落,常用來定義靜止區間等。舉個例子,要讓輸入在接近-300時取相反值,然後在輸入接近-100時到達0,然後在輸入接近0時又回到1,接著一直到輸入到100的過程中逐步回到0,最後形成一個始終為0的靜止區間,對於任何大於100的輸入都返回0。具體寫法如下:

value.interpolate({
  inputRange: [-300, -100, 0, 100, 101],
  outputRange: [300,    0, 1,   0,   0],
});

它的最終對映結果如下:

輸入 輸出
-400 450
-300 300
-200 150
-100 0
-50 0.5
0 1
50 0.5
100 0
101 0
200 0

interpolation還支援任意的漸變函式,其中有很多已經在Easing類中定義了,包括二次、指數、貝塞爾等曲線以及step、bounce等方法。interpolation還支援限制輸出區間outputRange。你可以通過設定extrapolateextrapolateLeftextrapolateRight屬性來限制輸出區間。預設值是extend(允許超出),不過你可以使用clamp選項來阻止輸出值超過outputRange

跟蹤動態值

動畫中所設的值還可以通過跟蹤別的值得到。你只要把toValue設定成另一個動態值而不是一個普通數字就行了。比如我們可以用彈跳動畫來實現聊天頭像的閃動,又比如通過timing設定duration:0來實現快速的跟隨。他們還可以使用插值來進行組合:

Animated.spring(follower, {toValue: leader}).start();
Animated.timing(opacity, {
  toValue: pan.x.interpolate({
    inputRange: [0, 300],
    outputRange: [1, 0],
  }),
}).start();

ValueXY是一個方便的處理2D互動的辦法,譬如旋轉或拖拽。它是一個簡單的包含了兩個Animated.Value例項的包裝,然後提供了一系列輔助函式,使得ValueXY在許多時候可以替代Value來使用。比如在上面的程式碼片段中,leaderfollower可以同時為valueXY型別,這樣x和y的值都會被跟蹤。

輸入事件

Animated.event是Animated API中與輸入有關的部分,允許手勢或其它事件直接繫結到動態值上。它通過一個結構化的對映語法來完成,使得複雜事件物件中的值可以被正確的解開。第一層是一個陣列,允許同時對映多個值,然後陣列的每一個元素是一個巢狀的物件。在下面的例子裡,你可以發現scrollX被對映到了event.nativeEvent.contentOffset.x(event通常是回撥函式的第一個引數),並且pan.xpan.y分別對映到gestureState.dxgestureState.dygestureState是傳遞給PanResponder回撥函式的第二個引數)。

onScroll={Animated.event(
  [{nativeEvent: {contentOffset: {x: scrollX}}}]   // scrollX = e.nativeEvent.contentOffset.x
)}
onPanResponderMove={Animated.event([
  null,                                          // 忽略原生事件
  {dx: pan.x, dy: pan.y}                         // 從gestureState中解析出dx和dy的值
]);

響應當前的動畫值

你可能會注意到這裡沒有一個明顯的方法來在動畫的過程中讀取當前的值——這是出於優化的角度考慮,有些值只有在原生程式碼執行階段中才知道。如果你需要在JavaScript中響應當前的值,有兩種可能的辦法:

  • spring.stopAnimation(callback)會停止動畫並且把最終的值作為引數傳遞給回撥函式callback——這在處理手勢動畫的時候非常有用。
  • spring.addListener(callback) 會在動畫的執行過程中持續非同步呼叫callback回撥函式,提供一個最近的值作為引數。這在用於觸發狀態切換的時候非常有用,譬如當使用者拖拽一個東西靠近的時候彈出一個新的氣泡選項。不過這個狀態切換可能並不會十分靈敏,因為它不像許多連續手勢操作(如旋轉)那樣在60fps下執行。

後續工作

如前面所述,我們計劃繼續優化Animated,以進一步提升效能。我們還想嘗試一些宣告式的手勢響應和觸發動畫,譬如垂直或者水平的傾斜操作。

上面的API提供了一個強大的工具來簡明、健壯、高效地組織各種各種不同的動畫。你可以在UIExplorer/AnimationExample中看到更多的樣例程式碼。不過還有些時候Animated並不能支援你想要的效果,下面的章節包含了一些其它的動畫系統。

LayoutAnimation

LayoutAnimation允許你在全域性範圍內建立更新動畫,這些動畫會在下一次渲染或佈局週期執行。它常用來更新flexbox佈局,因為它可以無需測量或者計算特定屬性就能直接產生動畫。尤其是當佈局變化可能影響到父節點(譬如“檢視更多”展開動畫既增加父節點的尺寸又會將位於本行之下的所有行向下推動)時,如果不使用LayoutAnimation,可能就需要顯式宣告元件的座標,才能使得所有受影響的元件能夠同步執行動畫。

注意儘管LayoutAnimation非常強大且有用,但它對動畫本身的控制沒有Animated或者其它動畫庫那樣方便,所以如果你使用LayoutAnimation無法實現一個效果,那可能還是要考慮其他的方案。

另外,如果要在Android上使用LayoutAnimation,那麼目前還需要在UIManager中啟用:

UIManager.setLayoutAnimationEnabledExperimental && UIManager.setLayoutAnimationEnabledExperimental(true);

var App = React.createClass({
  componentWillMount() {
    // 建立動畫
    LayoutAnimation.spring();
  },

  getInitialState() {
    return { w: 100, h: 100 }
  },

  _onPress() {
    // 讓檢視的尺寸變化以動畫形式展現
    LayoutAnimation.spring();
    this.setState({w: this.state.w + 15, h: this.state.h + 15})
  },

  render: function() {
    return (
      <View style={styles.container}>
        <View style={[styles.box, {width: this.state.w, height: this.state.h}]} />
        <TouchableOpacity onPress={this._onPress}>
          <View style={styles.button}>
            <Text style={styles.buttonText}>Press me!</Text>
          </View>
        </TouchableOpacity>
      </View>
    );
  }
});

執行這個例子

上面這個例子使用了一個預設值,不過你也可以自己配置你需要的動畫。參見LayoutAnimation.js

requestAnimationFrame

requestAnimationFrame是一個對瀏覽器標準API的相容實現,你可能已經熟悉它了。它接受一個函式作為唯一的引數,並且在下一次重繪之前呼叫此函式。一些基於JavaScript的動畫庫高度依賴於這一API。通常你不必直接呼叫它——那些動畫庫會替你管理好幀的更新。

react-tween-state(不推薦,用Animated來替代)

react-tween-state是一個極小的庫,正如它名字(tween:補間)表示的含義:它生成一個節點的狀態的中間值,從一個開始值,結束於一個到達值。這意味著它會生成這兩者之間的值,然後在每次requestAnimationFrame的時候修改狀態。

Wikipedia上對於補間動畫(tweening)的定義:

“補間是在兩個影像之間生成中間幀的過程,以使得第一個影像能夠平滑的變化為第二個影像”。補間幀是指在關鍵幀之間用於建立過渡假象的圖畫。”

一個最基礎的從一個值運動到另一個值的辦法就是線性過渡:只需要將結束值減去開始值,然後除以動畫總共需要經歷的幀數,再在每一幀加到當前值上,一直到結束值位置。線性過渡有時候看起來怪異且不自然,所以react-tween-state提供了一系列常用的過渡函式,可以用於使你的動畫更加自然。

這個庫並未隨React Native一起釋出——要在你的工程中使用它,則需要先在你的工程目錄下執行npm i react-tween-state --save來安裝。

import tweenState from `react-tween-state`;
import reactMixin from `react-mixin`; // https://github.com/brigand/react-mixin

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = { opacity: 1 };
    this._animateOpacity = this._animateOpacity.bind(this);
  }

  _animateOpacity() {
    this.tweenState(`opacity`, {
      easing: tweenState.easingTypes.easeOutQuint,
      duration: 1000,
      endValue: this.state.opacity === 0.2 ? 1 : 0.2,
    });
  }

  render() {
    return (
      <View style={{flex: 1, justifyContent: `center`, alignItems: `center`}}>
        <TouchableWithoutFeedback onPress={this._animateOpacity}>
          <View ref={component => this._box = component}
                style={{width: 200, height: 200, backgroundColor: `red`,
                        opacity: this.getTweeningValue(`opacity`)}} />
        </TouchableWithoutFeedback>
      </View>
    )
  }
}

reactMixin.onClass(App, tweenState.Mixin);

執行這個例子

在上面的例子裡我們變化的是透明度,但你可能也猜到了,我們能變化任何數值的值。可以參考它的說明文件來了解更多資訊。

Rebound (不推薦 – 使用Animated來替代)

Rebound.js是一個安卓版Rebound的JavaScript移植版。它在概念上類似react-tween-state:你有一個起始值,然後定義一個結束值,然後Rebound會生成所有中間的值並用於你的動畫。Rebound基於彈性物理模型,你不需要提供一個動畫的持續時間,它會自動根據彈性係數、助力、當前值和結束值來計算。我們在React Native內部應用了Rebound,比如NavigatorWarningBox

需要注意的是Rebound動畫可以被中斷——如果你在按下動畫的過程中釋放手指,它會從當前狀態彈回初始值。

var rebound = require(`rebound`);

var App = React.createClass({
  // 首先我們初始化一個spring動畫,並新增監聽函式,
  // 這個函式會在spring更新時呼叫setState
  componentWillMount() {
    // 初始化spring
    this.springSystem = new rebound.SpringSystem();
    this._scrollSpring = this.springSystem.createSpring();
    var springConfig = this._scrollSpring.getSpringConfig();
    springConfig.tension = 230;
    springConfig.friction = 10;

    this._scrollSpring.addListener({
      onSpringUpdate: () => {
        this.setState({scale: this._scrollSpring.getCurrentValue()});
      },
    });

    // 將spring的初始值設為1
    this._scrollSpring.setCurrentValue(1);
  },

  _onPressIn() {
    this._scrollSpring.setEndValue(0.5);
  },

  _onPressOut() {
    this._scrollSpring.setEndValue(1);
  },

  render: function() {
    var imageStyle = {
      width: 250,
      height: 200,
      transform: [{scaleX: this.state.scale}, {scaleY: this.state.scale}],
    };

    var imageUri = "https://facebook.github.io/react-native/img/ReboundExample.png";

    return (
      <View style={styles.container}>
        <TouchableWithoutFeedback onPressIn={this._onPressIn}
                                  onPressOut={this._onPressOut}>
          <Image source={{uri: imageUri}} style={imageStyle} />
        </TouchableWithoutFeedback>
      </View>
    );
  }
});

你還可以為彈跳值啟用邊界,這樣它們不會超出,而是會緩緩接近最終值。在上面的例子裡,我們可以新增this._scrollSpring.setOvershootClampingEnabled(true)來啟用邊界。參見下面的gif動畫來看一個啟用了邊界的效果:

 截圖來自react-native-scrollable-tab-view

你可以在這裡看到一個類似的例子。

關於setNativeProps

正如直接操作文件所說,setNativeProps方法可以使我們直接修改基於原生檢視的元件的屬性,而不需要使用setState來重新渲染整個元件樹。

我們可以把這個用在Rebound樣例中來更新縮放比例——如果我們要更新的元件有一個非常深的內嵌結構,並且沒有使用shouldComponentUpdate來優化,那麼使用setNativeProps就將大有裨益。

// 回到上面示例的那個元件中,找到componentWillMount方法,
// 然後將scrollSpring的監聽函式替換為如下程式碼:
this._scrollSpring.addListener({
  onSpringUpdate: () => {
    if (!this._photo) { return }
    var v = this._scrollSpring.getCurrentValue();
    var newProps = {style: {transform: [{scaleX: v}, {scaleY: v}]}};
    this._photo.setNativeProps(newProps);
  },
});

// 最後,我們修改render方法,不再通過style來傳入transform(避免
// 重新渲染時產生衝突);然後給圖片加上ref引用。 
render: function() {
  return (
    <View style={styles.container}>
      <TouchableWithoutFeedback onPressIn={this._onPressIn} onPressOut={this._onPressOut}>
        <Image ref={component => this._photo = component}
               source={{uri: "https://facebook.github.io/react-native/img/ReboundExample.png"}}
               style={{width: 250, height: 200}} />
      </TouchableWithoutFeedback>
    </View>
  );
}

執行這個例子

不過你沒辦法把setNativeProps和react-tween-state結合使用,因為更新的補間值會自動被庫設定到state上——Rebound則不同,它通過onSprintUpdate函式在每一幀中給我們提供一個更新後的值。

如果你發現你的動畫丟幀(低於60幀每秒),可以嘗試使用setNativeProps或者shouldComponentUpdate來優化它們。你還可能需要將部分計算工作放在動畫完成之後進行,這時可以使用InteractionManager。你還可以使用應用內的開發者選單中的“FPS Monitor”工具來監控應用的幀率。

導航器場景切換

正如文件導航器對比所說,Navigator使用JavaScript實現,而NavigatoIOS則是一個對於UINavigationController提供的原生功能的包裝。所以這些場景切換動畫僅僅對Navigator有效。為了在Navigator中重新建立UINavigationController所提供的動畫並且使之可以被自定義,React Native匯出了一個NavigatorSceneConfigsAPI。

import { Dimensions } from `react-native`;
var SCREEN_WIDTH = Dimensions.get(`window`).width;
var BaseConfig = Navigator.SceneConfigs.FloatFromRight;

var CustomLeftToRightGesture = Object.assign({}, BaseConfig.gestures.pop, {
  // 使用者中斷返回手勢時,迅速彈回  
  snapVelocity: 8,

  // 如下設定可以使我們在螢幕的任何地方拖動它
  edgeHitWidth: SCREEN_WIDTH,
});

var CustomSceneConfig = Object.assign({}, BaseConfig, {
  // 如下設定使過場動畫看起來很快
  springTension: 100,
  springFriction: 1,

  // 使用上面我們自定義的手勢
  gestures: {
    pop: CustomLeftToRightGesture,
  }
});

執行這個例子


相關文章