【React Native】自定义轮盘(大转盘)组件Wheel

一、功能概述

本组件是一个自定义轮盘(大转盘)组件

  • 旋转动画:通过 Animated 实现平滑旋转效果,支持自定义旋转圈数、单圈时长和缓动曲线。
  • 自定义渲染:支持自定义奖品项(renderItem)和旋转按钮(renderRunButton)。
  • 精准控制:提供 scrollToIndex 方法,可编程滚动到指定奖品位置。
  • 状态回调:支持旋转开始(onRotationStart)和结束(onRotationEnd)的回调事件。
  • 视觉定制:支持自定义奖品底盘颜色(dataBgColor),适配不同设计需求。
  • 循环滚动:支持循环自动滚动功能,可通过 startLoopstopLoop 方法控制。
  • 状态查询isRunning 方法,可查询轮盘是否正在旋转。

二、组件 Props 说明

Prop 名称类型说明默认值
styleViewStyle转盘容器的外层样式{}
dataT[]奖品数据数组(必传)[]
rotationCountnumber旋转圈数(如 3 表示旋转 3 圈后停止)3
rotationOneTimenumber单圈旋转时长(单位:ms)2000
renderItem(item: T, index: number) => React.ReactNode自定义奖品项的渲染函数必传
renderRunButton() => React.ReactNode自定义旋转按钮的渲染函数(可选)undefined
keyExtractor(item: T, index: number) => string奖品项的唯一键提取函数(必传)必传
clickRunButton() => void点击旋转按钮的回调(触发旋转逻辑)必传
onRotationStart() => void旋转开始时的回调undefined
onRotationEnd(item: T, index: number) => void旋转结束时的回调(返回最终奖品和索引)undefined
dataBgColorColorValue[]扇区背景色数组(循环使用)['#FFD700', '#FFA500', '#008C00']

三、接口说明

WheelHandles 接口定义了组件暴露给父组件的方法,通过 useRefforwardRef 可以访问这些方法,从而实现对轮盘的编程控制。

方法名参数返回值说明
scrollToIndextargetIndex: numbervoid控制轮盘滚动到指定奖品的位置
startLoopvoid启动轮盘的循环滚动模式
stopLoopvoid停止轮盘的循环滚动
isRunningboolean查询轮盘当前是否正在旋转

四、使用示例

import React, { useRef } from 'react';
import { View, Text, Button, StyleSheet } from 'react-native';
import Wheel from './Wheel';

const App = () => {
  const wheelRef = useRef(null);
  const prizes = ['iPhone 15', 'iPad Pro', 'MacBook Air', 'AirPods Max', 'Apple Watch'];

  // 点击按钮触发旋转(滚动到随机位置)
  const handleSpin = () => {
    const randomIndex = Math.floor(Math.random() * prizes.length);
    wheelRef.current?.scrollToIndex(randomIndex);
  };

  // 开始循环滚动
  const handleStartLoop = () => {
    wheelRef.current?.startLoop();
  };

  // 停止循环滚动
  const handleStopLoop = () => {
    wheelRef.current?.stopLoop();
  };

  // 旋转结束回调
  const handleRotationEnd = (item: string, index: number) => {
    console.log(`抽中:${item}(索引 ${index}`);
  };

  return (
    <View style={styles.container}>
      <Wheel
        ref={wheelRef}
        data={prizes}
        rotationCount={3}
        rotationOneTime={2000}
        renderItem={(item, index) => (
          <View style={styles.item}>
            <Text style={styles.itemText}>{item}</Text>
          </View>
        )}
        renderRunButton={() => (
          <Button title="开始抽奖" onPress={handleSpin} />
        )}
        keyExtractor={(item, index) => index.toString()}
        onRotationStart={() => console.log('旋转开始...')}
        onRotationEnd={handleRotationEnd}
        dataBgColor={['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEEAD']}
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#F7FFF7',
  },
  item: {
    width: 80,
    height: 40,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: 'rgba(255, 255, 255, 0.9)',
    borderRadius: 20,
  },
  itemText: {
    color: '#333',
    fontSize: 14,
    fontWeight: 'bold',
  },
});

export default App;

五、源码

import React, {
  type Ref,
  useCallback,
  useImperativeHandle,
  useRef,
  useState,
} from 'react';
import {
  Animated,
  ColorValue,
  Easing,
  StyleSheet,
  TouchableOpacity,
  View,
  ViewStyle,
} from 'react-native';
import CompositeAnimation = Animated.CompositeAnimation;

interface WheelProps<T> {
  style?: ViewStyle;
  // 奖品数据
  data: T[] | undefined | null;
  // 旋转圈数
  rotationCount?: number;
  // 一圈旋转时间
  rotationOneTime?: number;
  // 渲染奖品
  renderItem: (item: T, index: number) => React.ReactNode;
  // 渲染奖品底盘颜色
  dataBgColor?: ColorValue[];
  // 渲染旋转按钮,默认没有
  renderRunButton?: () => React.ReactNode;
  // 键
  keyExtractor: (item: T, index: number) => string;
  // 点击旋转按钮回调
  clickRunButton?: () => void;
  // 旋转开始回调
  onRotationStart?: () => void;
  // 旋转结束回调
  onRotationEnd?: (item: T, index: number) => void;
}

export interface WheelHandles {
  scrollToIndex: (targetIndex: number) => void; // 滚动到指定下标的方法
  startLoop: () => void; // 开始循环滚动
  stopLoop: () => void; // 停止循环滚动
  isRunning: () => boolean; // 是否正在旋转
}

const Wheel = <T,>(props: WheelProps<T>, ref: Ref<WheelHandles>) => {
  const {
    style,
    data,
    rotationCount = 3,
    rotationOneTime = 2000,
    dataBgColor = ['#FFD700', '#FFA500', '#008C00'],
    clickRunButton,
    renderItem,
    renderRunButton,
    keyExtractor,
    onRotationStart,
    onRotationEnd,
  } = props;
  const [wheelWidth, setWheelWidth] = useState(0);
  const [wheelHeight, setWheelHeight] = useState(0);
  const [itemWidth, setItemWidth] = useState(0);
  const [itemHeight, setItemHeight] = useState(0);
  const rotateAnim = useRef(new Animated.Value(0)).current;
  const _isRunning = useRef(false);
  const loopAnimate = useRef<CompositeAnimation>();
  const curRotate = useRef(0);

  // 计算每个奖品扇区的角度
  const getSectorAngle = useCallback(() => 360 / (data?.length || 1), [data]);

  // 暴露方法给父组件
  useImperativeHandle(ref, () => ({
    scrollToIndex,
    isRunning,
    startLoop,
    stopLoop,
  }));

  const renderPrizeItems = () => {
    const sectorAngle = getSectorAngle();

    return data?.map((prize, index) => {
      const rotate = index * sectorAngle - 90 + sectorAngle / 2;
      return renderItems(prize, index, rotate);
    });
  };

  const renderItems = (
    item: T,
    index: number,
    rotate: number,
    isMeasure = false,
  ) => {
    return (
      <View
        key={keyExtractor(item, index)}
        style={[
          styles.item,
          {
            transform: [
              {
                translateX: wheelWidth / 2 - itemWidth / 2,
              },
              {
                translateY: wheelHeight / 2 - itemHeight / 2,
              },
              {rotate: `${rotate}deg`},
              {
                translateX: wheelHeight / 2 - itemHeight / 2,
              },
              {rotate: `${90}deg`},
            ],
          },
        ]}
        onLayout={event => {
          if (isMeasure) {
            setItemWidth(event.nativeEvent.layout.width);
            setItemHeight(event.nativeEvent.layout.height);
          }
        }}>
        {renderItem(item, index)}
      </View>
    );
  };

  const renderItemBg = () => {
    let sectorAngle = getSectorAngle();
    return data?.map((prize, index) => {
      return (
        <View
          key={keyExtractor?.(prize, index)}
          style={{
            position: 'absolute',
            width: wheelWidth,
            height: wheelHeight,
            borderRadius: 1000,
            overflow: 'hidden',
          }}>
          {sectorAngle >= 90
            ? sectorAngle === 360
              ? renderBgItem360()
              : renderBgItemGt90(index, sectorAngle)
            : renderBgItemLt90(index, sectorAngle)}
        </View>
      );
    });
  };

  const renderBgItemLt90 = (index: number, rotate: number) => {
    const rotateOut = 180 + (index + 1) * rotate;
    return (
      <View
        style={{
          width: wheelWidth,
          height: wheelHeight,
          overflow: 'hidden',
          transform: [
            {rotate: `${rotateOut}deg`},
            {translateX: wheelWidth / 2},
          ],
        }}>
        <View
          style={{
            width: wheelWidth,
            height: wheelHeight,
            position: 'absolute',
            left: 0,
            top: 0,
            backgroundColor: dataBgColor[index % dataBgColor.length],
            transform: [
              {translateX: -wheelWidth / 2},
              {rotate: `${90 - rotate}deg`},
              {translateX: wheelWidth / 2},
              {translateY: wheelWidth / 2},
            ],
          }}
        />
      </View>
    );
  };

  const renderBgItemGt90 = (index: number, rotate: number) => {
    const rotateOut = 180 + index * rotate;
    return (
      <View
        style={{
          width: wheelWidth,
          height: wheelHeight,
          overflow: 'hidden',
          transform: [{rotate: `${rotateOut}deg`}],
        }}>
        <View
          style={{
            width: wheelWidth,
            height: wheelHeight,
            position: 'absolute',
            left: 0,
            top: 0,
            backgroundColor: dataBgColor[index % dataBgColor.length],
            transform: [
              {rotate: `${90}deg`},
              {translateX: wheelWidth / 2},
              {translateY: wheelWidth / 2},
            ],
          }}
        />
        <View
          style={{
            width: wheelWidth,
            height: wheelHeight,
            position: 'absolute',
            left: 0,
            top: 0,
            backgroundColor: dataBgColor[index % dataBgColor.length],
            transform: [
              {rotate: `${rotate}deg`},
              {translateX: wheelWidth / 2},
              {translateY: wheelWidth / 2},
            ],
          }}
        />
      </View>
    );
  };

  const renderBgItem360 = () => {
    return (
      <View
        style={{
          width: wheelWidth,
          height: wheelHeight,
          overflow: 'hidden',
          backgroundColor: dataBgColor[0],
        }}
      />
    );
  };

  const scrollToIndex = (targetIndex: number) => {
    if (_isRunning.current) {
      return;
    }

    if (!data) return;

    if (data.length === 0 || targetIndex < 0 || targetIndex >= data.length) {
      return;
    }

    const sectorAngle = getSectorAngle(); // 扇区角度(360°/数据长度)
    // 计算目标项的原始旋转角度(未滚动时的角度)
    const targetItemOriginalRotate =
      targetIndex * sectorAngle + sectorAngle / 2;
    // 转盘需要旋转的角度(反向抵消原始角度,使目标项到顶部)
    let targetRotation = -targetItemOriginalRotate - 360 * rotationCount;

    _isRunning.current = true;

    onRotationStart?.();

    // 执行动画
    Animated.timing(rotateAnim, {
      toValue: targetRotation,
      duration: rotationOneTime * rotationCount, // 动画时长
      easing: Easing.bezier(0.42, 0, 0.58, 1), // 动画曲线
      useNativeDriver: true, // 使用原生驱动提升性能
    }).start(() => {
      _isRunning.current = false;
      rotateAnim.setValue(-targetItemOriginalRotate);
      onRotationEnd?.(data?.[targetIndex], targetIndex);
    });
  };

  const startLoop = () => {
    if (_isRunning.current) {
      return;
    }

    if (!data) return;

    _isRunning.current = true;

    onRotationStart?.();
    rotateAnim.addListener(v => {
      curRotate.current = v.value;
    });

    // 执行动画
    const duration = (rotationOneTime * (360 + curRotate.current)) / 360;
    loopAnimate.current = Animated.timing(rotateAnim, {
      toValue: -360,
      duration: duration,
      easing: Easing.linear, // 动画曲线
      useNativeDriver: true, // 使用原生驱动提升性能
    });
    loopAnimate.current.start(() => {
      if (_isRunning.current) {
        // 执行动画
        const anim = Animated.timing(rotateAnim, {
          toValue: -360,
          duration: rotationOneTime,
          easing: Easing.linear, // 动画曲线
          useNativeDriver: true, // 使用原生驱动提升性能
        });
        loopAnimate.current = Animated.loop(Animated.sequence([anim]));
        loopAnimate.current.start(() => {
          _isRunning.current = false;
          onRotationEndWhileLoop();
        });
      } else {
        onRotationEndWhileLoop();
      }
    });
  };

  const onRotationEndWhileLoop = () => {
    if (!data || data.length === 0) return;
    const idx = Math.floor(
      (curRotate.current + 360 * rotationCount) / getSectorAngle(),
    );
    const targetIndex = data.length - (idx % data.length) - 1;
    onRotationEnd?.(data[targetIndex], targetIndex);
  };

  const stopLoop = () => {
    _isRunning.current = false;
    loopAnimate.current?.stop();
  };

  const isRunning = () => _isRunning.current;

  return (
    <View
      style={[styles.wheelContainer, style]}
      onLayout={event => {
        setWheelWidth(event.nativeEvent.layout.width);
        setWheelHeight(event.nativeEvent.layout.height);
      }}>
      <View style={{opacity: 0, position: 'absolute', left: 0, top: 0}}>
        {data && renderItems(data?.[0], 0, 0, true)}
      </View>
      <View>
        <Animated.View
          style={[
            styles.wheel,
            {
              width: wheelWidth,
              height: wheelHeight,
              transform: [
                {
                  rotate: rotateAnim.interpolate({
                    inputRange: [0, 360],
                    outputRange: ['0deg', '360deg'],
                  }),
                },
              ],
            },
          ]}>
          <View style={{position: 'absolute', left: 0, top: 0}}>
            {renderItemBg()}
          </View>
          <>{renderPrizeItems()}</>
        </Animated.View>
      </View>
      {renderRunButton && (
        <TouchableOpacity
          activeOpacity={1}
          onPress={clickRunButton}
          style={{
            position: 'absolute',
          }}>
          {renderRunButton()}
        </TouchableOpacity>
      )}
    </View>
  );
};

const styles = StyleSheet.create({
  wheelContainer: {
    position: 'relative',
    alignItems: 'center',
    justifyContent: 'center', // 水平居中(主轴)
  },
  wheel: {
    overflow: 'hidden',
    borderRadius: 150,
    position: 'relative',
  },
  item: {
    position: 'absolute',
    alignItems: 'center',
    padding: 0,
    margin: 0,
  },
});

export default React.forwardRef<WheelHandles, WheelProps<any>>(Wheel);
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值