react-native | 一起來實(shí)現(xiàn)一個(gè)高性能循環(huán)滾動(dòng)的輪播圖吧!

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ì)以文章或者視頻系列的方式推送!~致敬光頭大哥

我的GitHub主頁

我的Carousel開源庫

Snack 演示

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í)需要等待元素追加到指定位置。

react-native-snap-carousel

因?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)思路

  1. 首先我們默認(rèn)有三張圖片,并且已經(jīng)滑動(dòng)到了中間第二張
  1. 我們拖動(dòng)圖片向右滑動(dòng)
  1. 當(dāng)?shù)谝粡垐D片的部分進(jìn)入輪播圖視窗超過1/2后,我們將末尾的圖片挪動(dòng)到最前面
  1. 這樣我們就完成了一次向一側(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)容還是要參考各自的文檔。

react-native-gesture-handlerreact-native-reanimated

  • PanGestureHandler

包裹視圖容器后可以通過回調(diào)來獲得不同手勢移動(dòng)的參數(shù)。

  • useAnimatedGestureHandler

一個(gè)簡單的hook,用在PanGestureHandleronHandlerStateChangeprops上,可以在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>

開整

這里的代碼并不是無法粘貼使用的偽代碼,可以按步驟貼到編輯器中,即可看到效果。

  1. 首先我們可以使用Expo初始化一個(gè)項(xiàng)目,這里使用expo可以避免一些零碎的問題,更專注在這次的嘗試上。

這樣我們就有了一個(gè)可以使用的初始項(xiàng)目,他將抹平我們后續(xù)可能會(huì)帶來的各種依賴差異。

expo init my-project
cd ./my-project
  1. 安裝我們需要使用的庫,在這里避免后期庫升級(jí),api或許會(huì)發(fā)生變化,所以指定了版本。
yarn add react-native-reanimated@2.2.0 react-native-gesture-handler@1.10.2
  1. 首先我們需要一個(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,
    };
}
  1. 現(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;
        },
    },[])

    // …
}
  1. 這樣我們便得到了一個(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中的邏輯。

  1. 最后我們需要讓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;
};
  1. 到這里你應(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)注明出處

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容