RN - 動畫
流暢、有意義的動畫對於移動應用用戶體驗來說是非常重要的。現實生活中的物體在開始移動和停下來的時候都具有一定的慣性,我們在界面中也可以使用動畫來實現契合物理規律的交互。
React Native 提供了兩個互補的動畫系統:用於創建精細的交互控制的動畫Animated和用於全局的布局動畫LayoutAnimation。
- Animated Animated使得開發者可以簡潔地實現各種各樣的動畫和交互方式,並且具備極高的性能。Animated旨在以聲明的形式來定義動畫的輸入與輸出,在其中建立一個可配置的變化函數,然后使用start/stop方法來控制動畫按順序執行。 Animated僅封裝了6個可以動畫化的組件:View、Text、Image、ScrollView、FlatList和SectionList,不過你也可以使用Animated.createAnimatedComponent()來封裝你自己的組件。下面是一個在加載時帶有淡入動畫效果的視圖:
import React, { useState, useEffect } from 'react'; import { Animated, Text, View } from 'react-native'; const FadeInView = (props) => { const [fadeAnim] = useState(new Animated.Value(0)) // 透明度初始值設為0 React.useEffect(() => { Animated.timing( // 隨時間變化而執行動畫 fadeAnim, // 動畫中的變量值 { toValue: 1, // 透明度最終變為1,即完全不透明 duration: 10000, // 讓動畫持續一段時間 } ).start(); // 開始執行動畫 }, []) return ( <Animated.View // 使用專門的可動畫化的View組件 style={{ ...props.style, opacity: fadeAnim, // 將透明度綁定到動畫變量值 }} > {props.children} </Animated.View> ); } // 然后你就可以在組件中像使用`View`那樣去使用`FadeInView`了 export default () => { return ( <View style={{flex: 1, alignItems: 'center', justifyContent: 'center'}}> <FadeInView style={{width: 250, height: 50, backgroundColor: 'powderblue'}}> <Text style={{fontSize: 28, textAlign: 'center', margin: 10}}>Fading in</Text> </FadeInView> </View> ) }
- 配置動畫
動畫擁有非常靈活的配置項。自定義的或預定義的 easing 函數、延遲、持續時間、衰減系數、彈性常數等都可以在對應類型的動畫中進行配置。
下面這個例子創建了一個2秒長的動畫,在移動目標到最終位置前會稍微往后退一點:
Animated.timing(this.state.xPosition, { toValue: 100, easing: Easing.back(), duration: 2000 }).start();
- 組合動畫
多個動畫可以通過parallel(同時執行)、sequence(順序執行)、stagger和delay來組合使用。它們中的每一個都接受一個要執行的動畫數組,並且自動在適當的時候調用start/stop。
默認情況下,如果任何一個動畫被停止或中斷了,組內所有其它的動畫也會被停止。Parallel 有一個stopTogether屬性,如果設置為false,可以禁用自動停止。
在Animated文檔的組合動畫一節中列出了所有的組合方法。
合成動畫值
你可以使用加減乘除以及取余等運算來把兩個動畫值合成為一個新的動畫值。
const a = new Animated.Value(1); const b = Animated.divide(1, a); Animated.spring(a, { toValue: 2 }).start();
value.interpolate({ inputRange: [0, 1], outputRange: [0, 100] });
style={{ opacity: this.state.fadeAnim, // Binds directly transform: [{ translateY: this.state.fadeAnim.interpolate({ inputRange: [0, 1], outputRange: [150, 0] // 0 : 150, 0.5 : 75, 1 : 0 }), }], }}
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] });
interpolate()還支持到字符串的映射,從而可以實現顏色以及帶有單位的值的動畫變換。例如你可以像下面這樣實現一個旋轉動畫:
value.interpolate({ inputRange: [0, 360], outputRange: ["0deg", "360deg"] });
interpolate()還支持任意的漸變函數,其中有很多已經在Easing類中定義了,包括二次、指數、貝塞爾等曲線以及 step、bounce 等方法。interpolation還支持限制輸出區間outputRange。你可以通過設置extrapolate、extrapolateLeft或extrapolateRight屬性來限制輸出區間。默認值是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();
The leader and follower animated values would be implemented using Animated.ValueXY(). 是一個方便的處理 2D 交互的辦法,譬如旋轉或拖拽。它是一個簡單的包含了兩個Animated.Value實例的包裝,然后提供了一系列輔助函數,使得ValueXY在許多時候可以替代Value來使用。比如在上面的代碼片段中,leader和follower可以同時為valueXY類型,這樣 x 和 y 的值都會被跟蹤。
- 跟蹤手勢
Animated.event是 Animated 中與輸入有關的部分,允許手勢或其它事件直接綁定到動態值上。它通過一個結構化的映射語法來完成,使得復雜事件對象中的值可以被正確的解開。第一層是一個數組,允許同時映射多個值,然后數組的每一個元素是一個嵌套的對象。在下面的例子里,你可以發現scrollX被映射到了event.nativeEvent.contentOffset.x(event通常是回調函數的第一個參數),並且pan.x和pan.y分別映射到gestureState.dx和gestureState.dy(gestureState是傳遞給PanResponder回調函數的第二個參數)。
onScroll={Animated.event( // scrollX = e.nativeEvent.contentOffset.x [{ nativeEvent: { contentOffset: { x: scrollX } } }] )}
onPanResponderMove={Animated.event( [null, // ignore the native event // extract dx and dy from gestureState // like 'pan.x = gestureState.dx, pan.y = gestureState.dy' {dx: pan.x, dy: pan.y} ])}
響應當前的動畫值
你可能會注意到這里沒有一個明顯的方法來在動畫的過程中讀取當前的值——這是出於優化的角度考慮,有些值只有在原生代碼運行階段中才知道。如果你需要在 JavaScript 中響應當前的值,有兩種可能的辦法:
-
spring.stopAnimation(callback)會停止動畫並且把最終的值作為參數傳遞給回調函數callback——這在處理手勢動畫的時候非常有用。
-
spring.addListener(callback)會在動畫的執行過程中持續異步調用callback回調函數,提供一個最近的值作為參數。這在用於觸發狀態切換的時候非常有用,譬如當用戶拖拽一個東西靠近的時候彈出一個新的氣泡選項。不過這個狀態切換可能並不會十分靈敏,因為它不像許多連續手勢操作(如旋轉)那樣在 60fps 下運行。
-
啟用原生動畫驅動
Animated的 API 是可序列化的(即可轉化為字符串表達以便通信或存儲)。通過啟用原生驅動,我們在啟動動畫前就把其所有配置信息都發送到原生端,利用原生代碼在 UI 線程執行動畫,而不用每一幀都在兩端間來回溝通。如此一來,動畫一開始就完全脫離了 JS 線程,因此此時即便 JS 線程被卡住,也不會影響到動畫了。
在動畫中啟用原生驅動非常簡單。只需在開始動畫之前,在動畫配置中加入一行useNativeDriver: true,如下所示:
Animated.timing(this.state.animatedValue, { toValue: 1, duration: 500, useNativeDriver: true // <-- 加上這一行 }).start();
動畫值在不同的驅動方式之間是不能兼容的。因此如果你在某個動畫中啟用了原生驅動,那么所有和此動畫依賴相同動畫值的其他動畫也必須啟用原生驅動。
<Animated.ScrollView // <-- 使用可動畫化的ScrollView組件 scrollEventThrottle={1} // <-- 設為1以確保滾動事件的觸發頻率足夠密集 onScroll={Animated.event( [ { nativeEvent: { contentOffset: { y: this.state.animatedValue } } } ], { useNativeDriver: true } // <-- 加上這一行 )} > {content} </Animated.ScrollView>
- LayoutAnimation API
LayoutAnimation允許你在全局范圍內創建和更新動畫,這些動畫會在下一次渲染或布局周期運行。它常用來更新 flexbox 布局,因為它可以無需測量或者計算特定屬性就能直接產生動畫。尤其是當布局變化可能影響到父節點(譬如“查看更多”展開動畫既增加父節點的尺寸又會將位於本行之下的所有行向下推動)時,如果不使用LayoutAnimation,可能就需要顯式聲明組件的坐標,才能使得所有受影響的組件能夠同步運行動畫。
注意盡管LayoutAnimation非常強大且有用,但它對動畫本身的控制沒有Animated或者其它動畫庫那樣方便,所以如果你使用LayoutAnimation無法實現一個效果,那可能還是要考慮其他的方案。
另外,如果要在Android上使用 LayoutAnimation,那么目前還需要在UIManager中啟用::
/ 在執行任何動畫代碼之前,比如在入口文件App.js中執行 UIManager.setLayoutAnimationEnabledExperimental && UIManager.setLayoutAnimationEnabledExperimental(true); import React from 'react'; import { NativeModules, LayoutAnimation, Text, TouchableOpacity, StyleSheet, View, } from 'react-native'; const { UIManager } = NativeModules; UIManager.setLayoutAnimationEnabledExperimental && UIManager.setLayoutAnimationEnabledExperimental(true); export default class App extends React.Component { state = { w: 100, h: 100, }; _onPress = () => { // Animate the update LayoutAnimation.spring(); this.setState({w: this.state.w + 15, h: this.state.h + 15}) } render() { 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> ); } } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', }, box: { width: 200, height: 200, backgroundColor: 'red', }, button: { backgroundColor: 'black', paddingHorizontal: 20, paddingVertical: 15, marginTop: 15, }, buttonText: { color: '#fff', fontWeight: 'bold', }, });
- 其他要注意的地方
requestAnimationFrame
requestAnimationFrame是一個對瀏覽器標准 API 的兼容實現,你可能已經熟悉它了。它接受一個函數作為唯一的參數,並且在下一次重繪之前調用此函數。一些基於 JavaScript 的動畫庫高度依賴於這一 API。通常你不必直接調用它——那些動畫庫會替你管理好幀的更新。
setNativeProps
正如直接操作文檔所說,setNativeProps方法可以使我們直接修改基於原生視圖的組件的屬性,而不需要使用setState來重新渲染整個組件樹。
如果我們要更新的組件有一個非常深的內嵌結構,並且沒有使用shouldComponentUpdate來優化,那么使用setNativeProps就將大有裨益。
如果你發現你的動畫丟幀(低於 60 幀每秒),可以嘗試使用setNativeProps或者shouldComponentUpdate來優化它們。Or you could run the animations on the UI thread rather than the JavaScript thread with the useNativeDriver option. 你還可以考慮將部分計算工作放在動畫完成之后進行,這時可以使用InteractionManager。你還可以使用應用內的開發者菜單中的“FPS Monitor”工具來監控應用的幀率。
無障礙功能屬性
accessible (iOS, Android)
設置為true時表示當前視圖是一個“無障礙元素”(accessibility element)。無障礙元素會將其所有子組件視為一整個可以選中的組件。默認情況下,所有可點擊的組件(Touchable 系列組件)都是無障礙元素。
在 Android 上,React Native 視圖的accessible={true}屬性會被轉譯為原生視圖對應的focusable={true}屬性。
<View accessible={true}> <Text>text one</Text> <Text>text two</Text> </View>
無障礙標簽 accessibilityLabel (iOS, Android)
當一個視圖啟用無障礙屬性后,最好再加上一個 accessibilityLabel(無障礙標簽),這樣可以讓使用 VoiceOver 的人們清楚地知道自己選中了什么。VoiceOver 會讀出選中元素的無障礙標簽。
設定accessibilityLabel屬性並賦予一個字符串內容即可在 View、Text 或是 Touchable 中啟用無障礙標簽:
<TouchableOpacity accessible={true} accessibilityLabel="Tap me!" onPress={this._onPress}> <View style={styles.button}> <Text style={styles.buttonText}>Press me!</Text> </View> </TouchableOpacity>
無障礙提示 accessibilityHint (iOS, Android)
無障礙提示用於幫助用戶理解操作可能導致什么后果,尤其是當這些后果並不能從無障礙標簽中清楚地了解時。
要啟用無障礙提示只需在需要設置的元素上設置accessibilityHint屬性,並賦予用於解釋的文本:
<TouchableOpacity accessible={true} accessibilityLabel="返回" accessibilityHint="返回到上一個頁面" onPress={this._onPress}> <View style={styles.button}> <Text style={styles.buttonText}>Back</Text> </View> </TouchableOpacity>
