【React Native】自定义跑马灯组件Marquee

简介

这是一个基于 React Native 的通用跑马灯组件,支持以下特性:

  • 泛型数据支持
  • 自定义渲染函数
  • 水平/垂直双方向滚动
  • 动态速度与循环控制

基本用法

typescript 复制代码
          <Marquee
            style={{height: 300, width: 300}}
            speed={1}
            loop={true}
            delay={1000}
            isVertical={false}
            data={['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']}
            renderItem={(item: string, index: number) => (
              <View>
                {index % 2 === 0 ? (
                  <View>
                    <View
                      style={{width: 50, height: 50, backgroundColor: 'red'}}
                    />
                    <Text style={{width: 50, height: 50}}>
                      {item}
                      {index}
                    </Text>
                  </View>
                ) : (
                  <View>
                    <View
                      style={{width: 50, height: 50, backgroundColor: 'blue'}}
                    />
                    <Text style={{width: 50, height: 50}}>
                      {item}
                      {index}
                    </Text>
                  </View>
                )}
              </View>
            )}
          />

属性说明

属性名 类型 默认值 描述
data T[] [] 必填数据数组
renderItem (item: T, index: number) => React.ReactNode - 自定义渲染函数
speed number 30 滚动速度(像素/秒)
loop boolean true 是否循环播放
delay number 0 动画启动延迟(毫秒)
isVertical boolean false 是否垂直滚动(默认水平)
style ViewStyle {} 容器样式

源码

typescript 复制代码
import type {Ref} from 'react';
import React, {
  useCallback,
  useEffect,
  useImperativeHandle,
  useRef,
  useState,
} from 'react';
import type {ViewProps} from 'react-native';
import {
  Animated,
  Easing,
  findNodeHandle,
  PixelRatio,
  ScrollView,
  UIManager,
  View,
} from 'react-native';

export interface MarqueeProps<T> extends ViewProps {
  /**
   * 滚动数据
   */
  data: T[];
  /**
   * 渲染函数
   */
  renderItem: (item: T, index: number) => React.ReactNode;
  /**
   * 滚动速度(单位:像素/秒)
   */
  speed?: number;
  /**
   * 是否循环播放滚动动画
   */
  loop?: boolean;
  /**
   * 动画开始前的延迟时间(毫秒)
   */
  delay?: number;
  /**
   * 是否垂直滚动,默认为水平滚动
   */
  isVertical?: boolean;
}

export interface MarqueeHandles {
  start: () => void; // 启动滚动动画方法
  stop: () => void; // 停止滚动动画方法
}

/**
 * 创建动画配置(支持循环和连续动画)
 * @param animValue 动画值对象
 * @param config 动画配置(目标值、持续时间、是否循环、延迟时间)
 * @returns 组合动画对象
 */
const createAnim = (
  animValue: Animated.Value,
  config: {
    toValue: number;
    duration: number;
    loop: boolean;
    delay: number;
  },
): Animated.CompositeAnimation => {
  // 动画(线性缓动,原生驱动)
  const anim = Animated.timing(animValue, {
    easing: Easing.linear,
    useNativeDriver: true,
    ...config,
  });

  if (config.loop) {
    // 循环(动画完成后延迟1秒重复)
    return Animated.loop(Animated.sequence([anim]));
  }

  return anim; // 单次动画
};

// 跑马灯组件实现
const Marquee = <T,>(
  props: MarqueeProps<T>,
  ref: Ref<MarqueeHandles>, // 暴露给父组件的句柄引用
): JSX.Element => {
  // 解构组件属性(带默认值)
  const {
    style,
    data,
    renderItem,
    speed = 1, // 默认速度1像素/秒
    loop = true, // 默认循环播放
    delay = 0, // 默认无延迟
    isVertical = false,
    children, // 子组件内容
    ...restProps // 其他传递属性
  } = props;

  // 状态:是否正在动画中
  const [isRunning, setIsRunning] = useState<boolean>(false);
  // 缓存:容器宽度(初始为null)
  const outWidth = useRef<number | null>(null);
  // 缓存:容器高度(初始为null)
  const outHeight = useRef<number | null>(null);
  // 缓存:跑马灯内容实际宽度(初始为null)
  const innerViewWidth = useRef<number | null>(null);
  // 缓存:跑马灯内容实际高度(初始为null)
  const innerViewHeight = useRef<number | null>(null);
  // 动画值(控制跑马灯内容水平位移)
  const animatedValue = useRef<Animated.Value>(new Animated.Value(0));
  // 跑马灯内容引用(用于测量宽度)
  const innerRef = useRef<typeof Animated.View & View>(null);
  // 滚动容器引用(用于测量容器宽度)
  const outRef = useRef<ScrollView>(null);
  // 动画实例引用(用于控制启动/停止)
  const animRef = useRef<Animated.CompositeAnimation>();
  // 配置缓存(避免重复读取props)
  const conf = useRef<{
    speed: number;
    loop: boolean;
    delay: number;
  }>({
    speed,
    loop,
    delay,
  });

  // 停止动画方法
  const stopAnim = useCallback(() => {
    setIsRunning(false); // 更新状态
    clearSize(); // 清空尺寸缓存(下次需要重新测量)
  }, []);

  // 启动动画方法(核心逻辑)
  const startAnim = useCallback(async (): Promise<void> => {
    setIsRunning(true); // 标记动画开始

    await calSize(); // 计算容器和内容的实际宽度

    // 计算需要滚动的距离(内容宽度的一半,因为内容重复了一次)
    let distance = 0;
    // 计算动画时长(根据速度和距离)
    let animDuration = 0;

    if (!isVertical) {
      if (!outWidth.current || !innerViewWidth.current) {
        // 如果宽度缓存未获取到(测量失败)
        return;
      }
      distance = innerViewWidth.current / 2;
      if (distance < outWidth.current) {
        // 内容宽度小于容器宽度,不需要滚动
        return;
      }
      // 计算动画时长(根据速度和距离)
      animDuration =
        PixelRatio.getPixelSizeForLayoutSize(innerViewWidth.current) /
        conf.current.speed;
    } else {
      if (!outHeight.current || !innerViewHeight.current) {
        // 如果高度缓存未获取到(测量失败)
        return;
      }
      distance = innerViewHeight.current / 2;
      if (distance < outHeight.current) {
        // 内容高度小于容器高度,不需要滚动
        return;
      }
      // 计算动画时长(根据速度和距离)
      animDuration =
        PixelRatio.getPixelSizeForLayoutSize(innerViewHeight.current) /
        conf.current.speed;
    }

    // 创建动画配置(使用循环模式)
    animRef.current = createAnim(animatedValue.current, {
      ...conf.current,
      toValue: -distance, // 目标位移(向左/下滚动内容宽度的一半)
      duration: animDuration, // 动画时长
    });

    // 启动动画(无完成回调)
    animRef.current.start((): void => {});
  }, [isVertical]);

  // 暴露命令式句柄给父组件(start/stop方法)
  useImperativeHandle(ref, () => {
    return {
      start: () => {
        startAnim().then(); // 调用启动方法
      },
      stop: () => {
        stopAnim(); // 调用停止方法
      },
    };
  });

  // 副作用:当isStart变化或子组件更新时触发
  useEffect(() => {
    stopAnim(); // 先停止现有动画
    startAnim().then(); // 重新启动动画
  }, [children, startAnim, stopAnim]); // 依赖子组件和动画方法

  // 测量容器和内容宽度的核心方法(异步)
  const calSize = async (): Promise<void> => {
    try {
      // 如果容器或内容引用不存在则返回
      if (!outRef.current || !innerRef.current) {
        return;
      }

      // 通用测量函数(通过UIManager获取组件宽度)
      const measureWidth = (component: ScrollView | View): Promise<number[]> =>
        new Promise(resolve => {
          UIManager.measure(
            findNodeHandle(component) as number, // 获取组件节点句柄
            (_x: number, _y: number, w: number, h: number) => {
              // 测量回调(返回宽度w和高度h)
              return resolve([w, h]); // 解析宽高
            },
          );
        });

      // 并行测量容器宽度和内容宽度和高度
      const [oWidth, oHeight, iWidth, iHeight] = await Promise.all([
        ...(await measureWidth(outRef.current)), // 容器宽度和高度
        ...(await measureWidth(innerRef.current)), // 内容实际宽度和高度
      ]);

      // 缓存测量结果
      outWidth.current = oWidth;
      outHeight.current = oHeight;
      innerViewWidth.current = iWidth;
      innerViewHeight.current = iHeight;
    } catch (error) {
      console.error(error);
    }
  };

  // 清空尺寸缓存(用于动画停止后重新测量)
  const clearSize = () => {
    outWidth.current = null;
    outHeight.current = null;
    innerViewWidth.current = null;
    innerViewHeight.current = null;
  };

  // 组件渲染结构
  return (
    <View style={[{overflow: 'hidden'}, style]}>
      <ScrollView
        ref={outRef} // 绑定容器引用
        showsHorizontalScrollIndicator={false} // 隐藏水平滚动条
        showsVerticalScrollIndicator={false} // 隐藏垂直滚动条
        horizontal={!isVertical} // 水平滚动
        scrollEnabled={false} // 禁用用户手动滚动
        onContentSizeChange={calSize}>
        <Animated.View
          ref={innerRef} // 绑定内容引用
          {...restProps} // 传递其他属性
          style={[
            style, // 原生样式
            {
              display: 'flex',
              flexDirection: isVertical ? 'column' : 'row', // 子元素横向排列(使内容重复显示)
              transform: [
                isVertical
                  ? {translateY: animatedValue.current}
                  : {translateX: animatedValue.current},
              ], // 应用水平位移动画
              opacity: isRunning ? 1 : 0, // 动画时显示,停止时隐藏
            },
            isVertical ? {height: '100%'} : {width: '100%'},
          ]}>
          {data.map((item, index) => (
            <View key={index}>{renderItem(item, index)}</View>
          ))}
          {data.map((item, index) => (
            <View key={index + data.length}>{renderItem(item, index)}</View>
          ))}
        </Animated.View>
      </ScrollView>
    </View>
  );
};

// 导出带ref的组件(支持命令式调用)
export default React.forwardRef<MarqueeHandles, MarqueeProps<any>>(Marquee);
相关推荐
青红光硫化黑3 小时前
React-native之组件
javascript·react native·react.js
Bug改不动了1 天前
React Native 与 UniApp 对比
react native·react.js·uni-app
木西1 天前
React Native DApp 开发全栈实战·从 0 到 1 系列(eas构建自定义客户端)
react native·web3·app
wayne2142 天前
企业级 RN Android 完整 CI/CD 自动化解决方案
react native
lexiangqicheng3 天前
rn入口文件setup.js解读
react native
sure2824 天前
react native 编写一个歌词组件
前端·react native
风景_fengjing5 天前
React Native + Expo搭建APP项目+安卓模拟器
react native·expo
pe7er7 天前
Reactnative 项目开发(最佳?)实践
react native