Hi
Hey,可以叫我Doho,我是一名前端開發(fā),目前在我司負(fù)責(zé)一款由RN開發(fā)的App。
很興奮和大家分享我在使用react-native-reanimated并制作react-native-reanimated-carousel中學(xué)習(xí)到的經(jīng)驗(yàn),由于react-native-reanimated的中文資料及介紹視頻較少所以我的學(xué)習(xí)途徑大多來自于官方文檔、油管上光頭大哥的視頻這兩個(gè)。
官方文檔內(nèi)容大多是英文,所以查閱起來對(duì)英文不是很好的伙伴還是有一些障礙的。
油管上光頭大哥的視頻大部分視頻還是不錯(cuò)的,但部分視頻的講解部分有些跳躍所以也會(huì)存在有些地方難以跟上的情況。
所以我打算把我學(xué)習(xí)過的視頻嘗試重新實(shí)現(xiàn)一遍,并且用中文文章的方式進(jìn)行產(chǎn)出,其實(shí)也是想鍛煉下自己的總結(jié)能力,和文筆。后續(xù)會(huì)以文章或者視頻系列的方式推送!~致敬光頭大哥
Reanimated 2
為什么選擇Reanimated呢,因?yàn)樵赗eact Native中,默認(rèn)情況下,所有更新都會(huì)至少延遲一幀,因?yàn)閁I和JS線程之間的通信是異步的,UI線程永遠(yuǎn)不會(huì)等待JS線程處理完全所有事件。
而且除了JS在執(zhí)行Diff、更新、執(zhí)行應(yīng)用程序的業(yè)務(wù)邏輯、處理網(wǎng)絡(luò)請(qǐng)求…以外,事件通常也無法立即處理,從而導(dǎo)致會(huì)有更嚴(yán)重的延遲。
Reanimated的方式則是把處理動(dòng)畫和事件的邏輯從JSt線程放到UI線程去做。
大概是這樣子,更細(xì)節(jié)的地方不是本文的重點(diǎn),大家可以自行g(shù)oogle~
起因
受業(yè)務(wù)需求要在首頁增加一個(gè)支持循環(huán)滾動(dòng)的輪播圖,所以在github上搜索了一下社區(qū)里使用比較好用的組件。排除掉一些四、五年前才更新過的庫,還剩下一個(gè)名叫react-native-snap-carousel的組件庫。
但在使用過程中發(fā)現(xiàn)了問題,在循環(huán)滾動(dòng)中快速滑動(dòng)時(shí)會(huì)出現(xiàn)卡頓的情況,情況看起來像是滾動(dòng)到頭部或者末尾時(shí)需要等待元素追加到指定位置。

因?yàn)闀r(shí)間問題,所以并沒有深入去看源碼的實(shí)現(xiàn)部分,中間通過社區(qū)里大家提到的各種辦法進(jìn)行了嘗試也是無法解決,大多是通過增加前后的預(yù)渲染數(shù)量,但其實(shí)還是治標(biāo)不治本的方式。
而且這個(gè)庫的維護(hù)頻率比較低,也堆積著大量未解決的問題,雖然README中有說到未來會(huì)有一個(gè)完全用react-native-gesture-handler+react-native-reanimated實(shí)現(xiàn)的版本,而且會(huì)非常好用。但距離他們宣布時(shí)間已經(jīng)過去了很久還沒有發(fā)布正式版,所以求人不如求己,自己擼一個(gè)吧,但誤打誤撞使用了和他們同樣計(jì)劃的庫,就是上方的手勢與動(dòng)畫庫。
我們要解決什么問題
循環(huán)滾動(dòng)時(shí)向一側(cè)快速滑動(dòng)不會(huì)出現(xiàn)卡頓的情況
實(shí)現(xiàn)思路
- 首先我們默認(rèn)有三張圖片,并且已經(jīng)滑動(dòng)到了中間第二張

- 我們拖動(dòng)圖片向右滑動(dòng)


- 當(dāng)?shù)谝粡垐D片的部分進(jìn)入輪播圖視窗超過1/2后,我們將末尾的圖片挪動(dòng)到最前面

- 這樣我們就完成了一次向一側(cè)的循環(huán)滾動(dòng),反方向同理。其實(shí)原理就是滑動(dòng)并且追加,這里依賴Reanimated實(shí)現(xiàn),所以整個(gè)處理邏輯依然在UI線程完成,圖片的挪動(dòng)并不會(huì)導(dǎo)致動(dòng)畫卡頓。

前置
因?yàn)榭赡軙?huì)有并沒有用到這兩個(gè)庫的伙伴,所以在這部分講一下一些API基本的用法,準(zhǔn)確內(nèi)容還是要參考各自的文檔。
- PanGestureHandler
包裹視圖容器后可以通過回調(diào)來獲得不同手勢移動(dòng)的參數(shù)。
- useAnimatedGestureHandler
一個(gè)簡單的hook,用在PanGestureHandler的onHandlerStateChangeprops上,可以在hook中設(shè)置onStart、onActive、onEnd…各種事件的響應(yīng)事件。
- useSharedValue
Reanimated產(chǎn)生的動(dòng)畫值,它的變化將影響動(dòng)畫的行為。
- useAnimatedStyle
Reanimated 需要使用useAnimatedStyle來生成style,因?yàn)樗鼘⒃?code>SharedValue變化時(shí)控制生成變化后的樣式,同樣他生成的樣式也允許與Reanimated.View進(jìn)行關(guān)聯(lián)。
- Reanimated中的View元素
使用Reanimated動(dòng)畫值的基本條件,將SharedValue置入useAnimatedStyle后,可將返回的style傳遞給styles屬性,即可使元素產(chǎn)生動(dòng)畫效果。
- useDerivedValue
響應(yīng)一個(gè)SharedValue值的變化,并產(chǎn)生一個(gè)只讀的值。
const number_a = useSharedValue<number>(1);
const number_b = useDerivedValue(()=>{
return number_a.value*10
},[])
// number_a = 1
// number_b = 10
- interpolate
使SharedValue產(chǎn)生一個(gè)映射,這在修改一個(gè)動(dòng)畫效果的時(shí)候非常有用,比方說我們有一個(gè)頭像,想沒登錄的時(shí)候它寬度是100,登錄后放大到200。
Types: interpolate(SharedValue,inputRange,outputRange,?ExtrapolateParameter)
SharedValue: 動(dòng)畫的值
inputRange: 輸入?yún)^(qū)間
outputRange: 輸出區(qū)間
ExtrapolateParameter?: 當(dāng)輸入?yún)^(qū)間溢出后,是否繼續(xù)按照輸出區(qū)間變化(可選)
// 偽代碼
const loginStatusAnim = useSharedValue<number>(0);
const style = useAnimatedStyle(()=>{
return {
transform:[{
scale:interpolate(
loginStatusAnim.value,
// 這里我們的loginStatusAnim只會(huì)在0-1之間變化
[0,1],
// 但我們想讓他輸出的值映射到100-200的樣子,當(dāng)然我們可以直接變化0、1為100、200,所以這里只做演示
[100,200]
)
}]
}
},[])
return <Reanimated.View style={style}></Reanimated.View>
開整
這里的代碼并不是無法粘貼使用的偽代碼,可以按步驟貼到編輯器中,即可看到效果。
- 首先我們可以使用Expo初始化一個(gè)項(xiàng)目,這里使用expo可以避免一些零碎的問題,更專注在這次的嘗試上。
這樣我們就有了一個(gè)可以使用的初始項(xiàng)目,他將抹平我們后續(xù)可能會(huì)帶來的各種依賴差異。
expo init my-project
cd ./my-project
- 安裝我們需要使用的庫,在這里避免后期庫升級(jí),api或許會(huì)發(fā)生變化,所以指定了版本。
yarn add react-native-reanimated@2.2.0 react-native-gesture-handler@1.10.2
- 首先我們需要一個(gè)處理手勢邏輯的容器,并且容器會(huì)將元素橫向排列,我們會(huì)使用手勢來創(chuàng)建一個(gè)類似ScrollView的容器,更靈活的控制左右滑動(dòng),主要的邏輯在
animatedListScrollHandler中。
然后想讓元素動(dòng)起來,我們還需要兩個(gè)步驟生成偏移值X、使用偏移值X的元素。
Carousel.tsx
import React from 'react';
import { Dimensions, Text, View } from 'react-native';
import Animated, {
useAnimatedGestureHandler,
useSharedValue,
useDerivedValue
} from 'react-native-reanimated';
import { PanGestureHandler, PanGestureHandlerGestureEvent } from 'react-native-gesture-handler';
import { useComputedAnim } from './useComputedAnim';
import { Layouts } from './Layouts';
const data = [1, 2, 3];
const { width } = Dimensions.get('window');
const height = 300;
const Carousel: React.FC = () => {
// 1.獲取計(jì)算要用到的`基礎(chǔ)值`。(只是一個(gè)封裝邏輯)
const computedAnimResult = useComputedAnim(width, data.length);
const animatedListScrollHandler = useAnimatedGestureHandler<PanGestureHandlerGestureEvent>({
onStart:…,
onActive:…,
}, [])
return <PanGestureHandler onHandlerStateChange={animatedListScrollHandler}>
{/* // 2. 手勢容器規(guī)定內(nèi)部需要嵌套R(shí)eanimated的布局元素 */}
<Animated.View
style={{
// 3. 指定輪播圖容器的寬高,我們的大部分計(jì)算需要依賴已知的width。
width,
height,
flexDirection: 'row',
position: 'relative',
}}
>
{data.map((_, i) => {
return (
// 3. Layouts 是用來控制元素位置的容器,會(huì)在下面講到
<Layouts width={width} index={i} key={i} offsetX={offsetX} computedAnimResult={computedAnimResult}>
<View style={{ flex: 1, backgroundColor: "red", justifyContent: "center", alignItems: "center", borderWidth: 1, borderColor: "black" }}>
<Text style={{ fontSize: 100 }}>{i}</Text>
</View>
</Layouts>
);
})}
</Animated.View>
</PanGestureHandler>
}
export default Carousel;
useComputedAnim.ts
export interface IComputedAnimResult {
MAX: number;
MIN: number;
WL: number;
LENGTH: number;
}
export function useComputedAnim(
width: number,
LENGTH: number
): IComputedAnimResult {
/*
* 1. 去掉頭、尾兩個(gè)元素的寬度后,中間可以滑動(dòng)的距離
* 因?yàn)榕R近頭、尾時(shí)要將它們的位置挪到另一側(cè)
*/
const MAX = (LENGTH - 2) * width;
// 2. 反方向取反
const MIN = -MAX;
// 3. 元素排列開的總長度
const WL = width * LENGTH;
return {
MAX,
MIN,
WL,
LENGTH,
};
}
- 現(xiàn)在我們將完善
animatedListScrollHandler中的邏輯,讓手勢的滑動(dòng)可以讓容器內(nèi)部的偏移量X發(fā)生變化。
這里我們完成了生成偏移值X。
Carousel.tsx
const Carousel:React.FC = () => {
// …
// 1. 位置的偏移量
const handlerOffsetX = useSharedValue<number>(0);
// 2. 這里需要對(duì)偏移值做一次轉(zhuǎn)換,讓其循環(huán)一周后歸0,這也是我們實(shí)際用到的值
const offsetX = useDerivedValue(() => {
const x = handlerOffsetX.value % computedAnimResult.WL;
return isNaN(x) ? 0 : x;
}, [computedAnimResult]);
// 這個(gè)Hook會(huì)在手勢發(fā)生時(shí)對(duì)所設(shè)置的方法進(jìn)行調(diào)用,并返回一些參數(shù),告知手勢信息
const animatedListScrollHandler = useAnimatedGestureHandler<PanGestureHandlerGestureEvent>({
/**
* 3. ctx是方法執(zhí)行時(shí)所提供的臨時(shí)上下文,我們可以記錄一些臨時(shí)用到的變量
* 在這里我們拖動(dòng)前把當(dāng)前的偏移量添加到上下文中
*/
onStart: (_, ctx: any) => {
ctx.startContentOffsetX = handlerOffsetX.value;
},
/**
* 4. 我們從上下文中取出初始位置,并與onActive返回的這次滑動(dòng)的X偏移量相加,就可以使元素左右挪動(dòng)了
*/
onActive: (e, ctx: any) => {
handlerOffsetX.value = ctx.startContentOffsetX + e.translationX;
},
},[])
// …
}
- 這樣我們便得到了一個(gè)最重要的值
offsetX,因?yàn)橐?dú)立的挪動(dòng)每個(gè)即將到末尾或者頭部的元素到另外一邊,所以要精準(zhǔn)的控制他們的位置,那便需要把這個(gè)值運(yùn)用給每個(gè)CarouselItem元素。

Layouts.tsx
import React from 'react';
import { FlexStyle, View } from 'react-native';
import Animated, { useAnimatedStyle } from 'react-native-reanimated';
import { IComputedAnimResult } from './useComputedAnim';
import { useOffsetX } from './useOffsetX';
export const Layouts: React.FC<{
index: number;
width: number;
height?: FlexStyle['height'];
offsetX: Animated.SharedValue<number>;
computedAnimResult: IComputedAnimResult;
}> = (props) => {
const {
index,
width,
children,
height = '100%',
offsetX,
computedAnimResult,
} = props;
/*
* 1. 這里是讓CarouselItem元素正確移動(dòng)的核心邏輯,我們會(huì)在下面講到這里
*/
const x = useOffsetX({
offsetX,
index,
width,
computedAnimResult,
});
/*
* 2. Reanimated 需要使用 useAnimatedStyle來生成style
* 因?yàn)樗鼘⒃趕haredValue變化時(shí)控制生成變化后的樣式
* 同樣他生成的樣式也允許與Reanimated.View進(jìn)行關(guān)聯(lián)
*/
const offsetXStyle = useAnimatedStyle(() => {
return {
/*
* 3. 這里我們需要使用`index * width`讓元素都回到原點(diǎn)(即他們都是疊在一起的)
* 然后完全使用我們通過`useOffsetX `計(jì)算的值`x.value`來控制他的位置
*/
transform: [{ translateX: x.value - index * width }],
};
}, []);
return (
// 4. 設(shè)置樣式
<Animated.View style={offsetXStyle}>
<View style={{ width, height }}>{children}</View>
</Animated.View>
);
}
export default Layouts;
這里我們完成了使用偏移值X的元素,現(xiàn)在我們的輪播圖已經(jīng)有了一個(gè)基本的樣子,他已經(jīng)可以朝著一側(cè)進(jìn)行滑動(dòng)了,但如果想讓他進(jìn)行循環(huán),則還需要完成useOffsetX中的邏輯。
- 最后我們需要讓
useOffsetXhook能夠產(chǎn)生元素正確的偏移值,使它可以在臨近末尾或者頭部的時(shí)候完成轉(zhuǎn)換到另一側(cè)的效果。
這個(gè)方法內(nèi)計(jì)算部分比較繞,但大概思路就是當(dāng)偏移值X變化后,確認(rèn)他是否超過我們所設(shè)定的邊界,超過了就放到另一邊。不理解也沒關(guān)系,因?yàn)榛蛟S你可以理清思路實(shí)現(xiàn)一套自己的運(yùn)算邏輯

useOffsetX.ts
import Animated, {
Extrapolate,
interpolate,
useDerivedValue,
} from 'react-native-reanimated';
import type { IComputedAnimResult } from './useComputedAnim';
interface IOpts {
index: number;
width: number;
computedAnimResult: IComputedAnimResult;
offsetX: Animated.SharedValue<number>;
}
export const useOffsetX = (opts: IOpts) => {
const { offsetX, index, width, computedAnimResult } = opts;
const { MAX, WL, MIN, LENGTH } = computedAnimResult;
const x = useDerivedValue(() => {
// 每個(gè)元素距離原點(diǎn)的偏移值
const Wi = width * index;
// 每個(gè)元素的起始值,如果越過邊界則起始位置應(yīng)該調(diào)轉(zhuǎn)到另一側(cè)
const startPos = Wi > MAX ? MAX - Wi : Wi < MIN ? MIN - Wi : Wi;
const inputRange = [
// WL為去掉頭、尾的可移動(dòng)區(qū)域
-WL,
// 這里是越過邊界前的位置條件
-((LENGTH - 2) * width + width / 2) - startPos - 1,
// 這里是越過邊界后的位置條件
-((LENGTH - 2) * width + width / 2) - startPos,
// 原點(diǎn)
0,
// 反方向
(LENGTH - 2) * width + width / 2 - startPos,
// 反方向
(LENGTH - 2) * width + width / 2 - startPos + 1,
// 反方向
WL,
];
const outputRange = [
// 對(duì)應(yīng)WL循環(huán)了一周,所以回到起始位置
startPos,
1.5 * width - 1,
// 越過后調(diào)轉(zhuǎn)到另一側(cè)
-((LENGTH - 2) * width + width / 2),
// 回到起始位置
startPos,
// 越過后調(diào)轉(zhuǎn)到另一側(cè)
(LENGTH - 2) * width + width / 2,
-(1.5 * width - 1),
// 對(duì)應(yīng)WL循環(huán)了一周,所以回到起始位置
startPos,
];
// 返回計(jì)算后的X值,這個(gè)值是一個(gè)相對(duì)原點(diǎn)的絕對(duì)位置,但我們的元素是依次排開的,所以再減去 index*width ,把他們歸置原點(diǎn)即可
return interpolate(
offsetX.value,
inputRange,
outputRange,
Extrapolate.CLAMP
);
}, []);
return x;
};
- 到這里你應(yīng)該已經(jīng)獲得了一個(gè)可以向兩側(cè)肆意滑動(dòng)而且不會(huì)卡頓的輪播圖啦,當(dāng)然這是一個(gè)非常簡單的版本。實(shí)際我的庫中還圍繞react-native-gesture-handler 、 react-native-reanimated這兩個(gè)庫的bug做了一些hack fix,還有一些交互上的優(yōu)化,比方說拖動(dòng)后會(huì)有慣性的效果、paging效果…,這些不在這篇文章的范圍,所以大家感興趣的話可以去我的倉庫看看~!react-native-reanimated-carousel
本篇文章的完整版Demo react-native-reanimated-carousel-example
更多功能
后續(xù)會(huì)在我的項(xiàng)目react-native-reanimated-carousel中完善更多的API,讓這個(gè)組件變得更易用,但或許并不會(huì)增加react-native-snap-carousel中那樣復(fù)雜的UI效果,目的還是讓這個(gè)組件變得更加簡單與靈活。
希望更多伙伴能參與進(jìn)來一起維護(hù),或者來提更多建議,來吧來吧!~項(xiàng)目
末尾
感謝大家的閱讀,希望能收到建議、問題或指正。
覺得好用就來個(gè)star??吧,嘻嘻 react-native-reanimated-carousel ,謝謝~!
后續(xù)我會(huì)寫更多關(guān)于react-native-reanimated v2的系列文章,希望對(duì)大,家有所幫助!我的GitHub主頁
ps: 轉(zhuǎn)載請(qǐng)注明出處