react-native | 一起来实现一个高性能循环滚动的轮播图吧!

Hi

Hey,可以叫我Doho,我是一名前端开发,目前在我司负责一款由RN开发的App。

很兴奋和大家分享我在使用react-native-reanimated并制作react-native-reanimated-carousel中学习到的经验,由于react-native-reanimated的中文资料及介绍视频较少所以我的学习途径大多来自于官方文档、油管上光头大哥的视频这两个。

官方文档内容大多是英文,所以查阅起来对英文不是很好的伙伴还是有一些障碍的。

油管上光头大哥的视频大部分视频还是不错的,但部分视频的讲解部分有些跳跃所以也会存在有些地方难以跟上的情况。

所以我打算把我学习过的视频尝试重新实现一遍,并且用中文文章的方式进行产出,其实也是想锻炼下自己的总结能力,和文笔。后续会以文章或者视频系列的方式推送!~致敬光头大哥

我的GitHub主页

我的Carousel开源库

Snack 演示

Reanimated 2

为什么选择Reanimated呢,因为在React Native中,默认情况下,所有更新都会至少延迟一帧,因为UI和JS线程之间的通信是异步的,UI线程永远不会等待JS线程处理完全所有事件。

而且除了JS在执行Diff、更新、执行应用程序的业务逻辑、处理网络请求…以外,事件通常也无法立即处理,从而导致会有更严重的延迟。

Reanimated的方式则是把处理动画和事件的逻辑从JSt线程放到UI线程去做。

大概是这样子,更细节的地方不是本文的重点,大家可以自行google~

起因

受业务需求要在首页增加一个支持循环滚动的轮播图,所以在github上搜索了一下社区里使用比较好用的组件。排除掉一些四、五年前才更新过的库,还剩下一个名叫react-native-snap-carousel的组件库。

但在使用过程中发现了问题,在循环滚动中快速滑动时会出现卡顿的情况,情况看起来像是滚动到头部或者末尾时需要等待元素追加到指定位置。

react-native-snap-carousel

因为时间问题,所以并没有深入去看源码的实现部分,中间通过社区里大家提到的各种办法进行了尝试也是无法解决,大多是通过增加前后的预渲染数量,但其实还是治标不治本的方式。

而且这个库的维护频率比较低,也堆积着大量未解决的问题,虽然README中有说到未来会有一个完全用react-native-gesture-handler+react-native-reanimated实现的版本,而且会非常好用。但距离他们宣布时间已经过去了很久还没有发布正式版,所以求人不如求己,自己撸一个吧,但误打误撞使用了和他们同样计划的库,就是上方的手势与动画库。

我们要解决什么问题

循环滚动时向一侧快速滑动不会出现卡顿的情况

实现思路

  1. 首先我们默认有三张图片,并且已经滑动到了中间第二张
  1. 我们拖动图片向右滑动
  1. 当第一张图片的部分进入轮播图视窗超过1/2后,我们将末尾的图片挪动到最前面
  1. 这样我们就完成了一次向一侧的循环滚动,反方向同理。其实原理就是滑动并且追加,这里依赖Reanimated实现,所以整个处理逻辑依然在UI线程完成,图片的挪动并不会导致动画卡顿。

前置

因为可能会有并没有用到这两个库的伙伴,所以在这部分讲一下一些API基本的用法,准确内容还是要参考各自的文档。

react-native-gesture-handler 、 react-native-reanimated

  • PanGestureHandler

包裹视图容器后可以通过回调来获得不同手势移动的参数。

  • useAnimatedGestureHandler

一个简单的hook,用在PanGestureHandleronHandlerStateChangeprops上,可以在hook中设置onStart、onActive、onEnd…各种事件的响应事件。

  • useSharedValue

Reanimated产生的动画值,它的变化将影响动画的行为。

  • useAnimatedStyle

Reanimated 需要使用useAnimatedStyle来生成style,因为它将在SharedValue变化时控制生成变化后的样式,同样他生成的样式也允许与Reanimated.View进行关联。

  • Reanimated中的View元素

使用Reanimated动画值的基本条件,将SharedValue置入useAnimatedStyle后,可将返回的style传递给styles属性,即可使元素产生动画效果。

  • useDerivedValue

响应一个SharedValue值的变化,并产生一个只读的值。

const number_a = useSharedValue(1);
const number_b = useDerivedValue(()=>{
    return number_a.value*10
},[])

// number_a = 1
// number_b = 10
  • interpolate

使SharedValue产生一个映射,这在修改一个动画效果的时候非常有用,比方说我们有一个头像,想没登录的时候它宽度是100,登录后放大到200。

Types: interpolate(SharedValue,inputRange,outputRange,?ExtrapolateParameter)

SharedValue: 动画的值

inputRange: 输入区间

outputRange: 输出区间

ExtrapolateParameter?: 当输入区间溢出后,是否继续按照输出区间变化(可选)

// 伪代码
const loginStatusAnim = useSharedValue(0); 

const style = useAnimatedStyle(()=>{
    return {
        transform:[{
            scale:interpolate(
                loginStatusAnim.value,
                // 这里我们的loginStatusAnim只会在0-1之间变化
                [0,1],
                // 但我们想让他输出的值映射到100-200的样子,当然我们可以直接变化0、1为100、200,所以这里只做演示
                [100,200]
            )
        }]
    }
},[])

return 

开整

这里的代码并不是无法粘贴使用的伪代码,可以按步骤贴到编辑器中,即可看到效果。

  1. 首先我们可以使用Expo初始化一个项目,这里使用expo可以避免一些零碎的问题,更专注在这次的尝试上。

这样我们就有了一个可以使用的初始项目,他将抹平我们后续可能会带来的各种依赖差异。

expo init my-project
cd ./my-project
  1. 安装我们需要使用的库,在这里避免后期库升级,api或许会发生变化,所以指定了版本。
yarn add [email protected] [email protected]
  1. 首先我们需要一个处理手势逻辑的容器,并且容器会将元素横向排列,我们会使用手势来创建一个类似ScrollView的容器,更灵活的控制左右滑动,主要的逻辑在animatedListScrollHandler中。

然后想让元素动起来,我们还需要两个步骤生成偏移值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.获取计算要用到的`基础值`。(只是一个封装逻辑)
  const computedAnimResult = useComputedAnim(width, data.length);

  const animatedListScrollHandler = useAnimatedGestureHandler({
    onStart:…,
    onActive:…,
  }, [])

  return 
    {/* // 2. 手势容器规定内部需要嵌套Reanimated的布局元素 */}
    
      {data.map((_, i) => {
        return (
          // 3. Layouts 是用来控制元素位置的容器,会在下面讲到
          
            
              {i}
            
          
        );
      })}
    
  
}

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. 去掉头、尾两个元素的宽度后,中间可以滑动的距离
       * 因为临近头、尾时要将它们的位置挪到另一侧
       */
    const MAX = (LENGTH - 2) * width;
      // 2. 反方向取反
    const MIN = -MAX;
      // 3. 元素排列开的总长度
    const WL = width * LENGTH;

    return {
        MAX,
        MIN,
        WL,
        LENGTH,
    };
}
  1. 现在我们将完善animatedListScrollHandler中的逻辑,让手势的滑动可以让容器内部的偏移量X发生变化。

这里我们完成了生成偏移值X

Carousel.tsx

const Carousel:React.FC = () => {
    // …

    // 1. 位置的偏移量
  const handlerOffsetX = useSharedValue(0);

    // 2. 这里需要对偏移值做一次转换,让其循环一周后归0,这也是我们实际用到的值
  const offsetX = useDerivedValue(() => {
    const x = handlerOffsetX.value % computedAnimResult.WL;
    return isNaN(x) ? 0 : x;
  }, [computedAnimResult]);

    // 这个Hook会在手势发生时对所设置的方法进行调用,并返回一些参数,告知手势信息
    const animatedListScrollHandler = useAnimatedGestureHandler({
        /**
        * 3. ctx是方法执行时所提供的临时上下文,我们可以记录一些临时用到的变量
        * 在这里我们拖动前把当前的偏移量添加到上下文中
        */ 
        onStart: (_, ctx: any) => {
            ctx.startContentOffsetX = handlerOffsetX.value;
     },
        /**
        * 4. 我们从上下文中取出初始位置,并与onActive返回的这次滑动的X偏移量相加,就可以使元素左右挪动了
        */  
     onActive: (e, ctx: any) => {
            handlerOffsetX.value = ctx.startContentOffsetX + e.translationX;
        },
    },[])

    // …
}
  1. 这样我们便得到了一个最重要的值offsetX,因为要独立的挪动每个即将到末尾或者头部的元素到另外一边,所以要精准的控制他们的位置,那便需要把这个值运用给每个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;
  computedAnimResult: IComputedAnimResult;
}> = (props) => {
  const {
    index,
    width,
    children,
    height = '100%',
    offsetX,
    computedAnimResult,
  } = props;

  /*
   * 1. 这里是让CarouselItem元素正确移动的核心逻辑,我们会在下面讲到这里
   */
  const x = useOffsetX({
    offsetX,
    index,
    width,
    computedAnimResult,
  });

  /*
   * 2. Reanimated 需要使用 useAnimatedStyle来生成style
   * 因为它将在sharedValue变化时控制生成变化后的样式
   * 同样他生成的样式也允许与Reanimated.View进行关联
   */
  const offsetXStyle = useAnimatedStyle(() => {
    return {
      /*
       * 3. 这里我们需要使用`index * width`让元素都回到原点(即他们都是叠在一起的)
       * 然后完全使用我们通过`useOffsetX `计算的值`x.value`来控制他的位置
      */
      transform: [{ translateX: x.value - index * width }],
    };
  }, []);

  return (
    // 4. 设置样式
    
      {children}
    
  );
}

export default Layouts;

这里我们完成了使用偏移值X的元素,现在我们的轮播图已经有了一个基本的样子,他已经可以朝着一侧进行滑动了,但如果想让他进行循环,则还需要完成useOffsetX中的逻辑。

  1. 最后我们需要让useOffsetXhook能够产生元素正确的偏移值,使它可以在临近末尾或者头部的时候完成转换到另一侧的效果。

这个方法内计算部分比较绕,但大概思路就是当偏移值X变化后,确认他是否超过我们所设定的边界,超过了就放到另一边。不理解也没关系,因为或许你可以理清思路实现一套自己的运算逻辑

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;
}

export const useOffsetX = (opts: IOpts) => {
    const { offsetX, index, width, computedAnimResult } = opts;
    const { MAX, WL, MIN, LENGTH } = computedAnimResult;
    const x = useDerivedValue(() => {
            // 每个元素距离原点的偏移值
            const Wi = width * index;

            // 每个元素的起始值,如果越过边界则起始位置应该调转到另一侧
            const startPos = Wi > MAX ? MAX - Wi : Wi < MIN ? MIN - Wi : Wi;

            const inputRange = [
                // WL为去掉头、尾的可移动区域
                -WL,
                // 这里是越过边界前的位置条件
                -((LENGTH - 2) * width + width / 2) - startPos - 1,
                // 这里是越过边界后的位置条件
                -((LENGTH - 2) * width + width / 2) - startPos,
                // 原点
                0,
                // 反方向
                (LENGTH - 2) * width + width / 2 - startPos,
                // 反方向
                (LENGTH - 2) * width + width / 2 - startPos + 1,
                // 反方向
                WL,
            ];

            const outputRange = [
               // 对应WL循环了一周,所以回到起始位置
                startPos,
                1.5 * width - 1,
                // 越过后调转到另一侧
                -((LENGTH - 2) * width + width / 2),
               // 回到起始位置
                startPos,
                // 越过后调转到另一侧
                (LENGTH - 2) * width + width / 2,
                -(1.5 * width - 1),
               // 对应WL循环了一周,所以回到起始位置
                startPos,
            ];

            // 返回计算后的X值,这个值是一个相对原点的绝对位置,但我们的元素是依次排开的,所以再减去 index*width ,把他们归置原点即可
            return interpolate(
                offsetX.value,
                inputRange,
                outputRange,
                Extrapolate.CLAMP
            );
    }, []);
    return x;
};
  1. 到这里你应该已经获得了一个可以向两侧肆意滑动而且不会卡顿的轮播图啦,当然这是一个非常简单的版本。实际我的库中还围绕react-native-gesture-handler 、 react-native-reanimated这两个库的bug做了一些hack fix,还有一些交互上的优化,比方说拖动后会有惯性的效果、paging效果…,这些不在这篇文章的范围,所以大家感兴趣的话可以去我的仓库看看~!react-native-reanimated-carousel

本篇文章的完整版Demo react-native-reanimated-carousel-example

更多功能

后续会在我的项目react-native-reanimated-carousel中完善更多的API,让这个组件变得更易用,但或许并不会增加react-native-snap-carousel中那样复杂的UI效果,目的还是让这个组件变得更加简单与灵活。

希望更多伙伴能参与进来一起维护,或者来提更多建议,来吧来吧!~项目

末尾

感谢大家的阅读,希望能收到建议、问题或指正。

觉得好用就来个star吧,嘻嘻 react-native-reanimated-carousel ,谢谢~!

后续我会写更多关于react-native-reanimated v2的系列文章,希望对大,家有所帮助!我的GitHub主页

ps: 转载请注明出处

你可能感兴趣的:(react-native | 一起来实现一个高性能循环滚动的轮播图吧!)