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

一、功能概述

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

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

二、组件 Props 说明

Prop 名称 类型 说明 默认值
style ViewStyle 转盘容器的外层样式 {}
data T[] 奖品数据数组(必传) []
rotationCount number 旋转圈数(如 3 表示旋转 3 圈后停止) 3
rotationOneTime number 单圈旋转时长(单位: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
dataBgColor ColorValue[] 扇区背景色数组(循环使用) ['#FFD700', '#FFA500', '#008C00']

三、使用示例

typescript 复制代码
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 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;

四、源码

typescript 复制代码
import React, {type Ref, useImperativeHandle, useRef, useState} from 'react';
import {
  Animated,
  ColorValue,
  Easing,
  StyleSheet,
  TouchableOpacity,
  View,
  ViewStyle,
} from 'react-native';

interface WheelProps<T> {
  style?: ViewStyle;
  // 奖品数据
  data: T[];
  // 旋转圈数
  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; // 滚动到指定下标的方法
}

const Wheel = <T,>(
  props: WheelProps<T>,
  ref: Ref<WheelHandles>,
): JSX.Element => {
  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);

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

  // 计算每个奖品扇区的角度
  const getSectorAngle = () => 360 / data.length;

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

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

  const renderItems = (
    data: T,
    index: number,
    rotate: number,
    isMeasure = false,
  ) => {
    return (
      <View
        key={keyExtractor(data, 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(data, 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 * rotate + rotate / 2;
    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 - rotate / 2;
    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],
        }}
      />
    );
  };

  // 在 Wheel 组件内部添加
  const scrollToIndex = (targetIndex: number) => {
    if (isRunning.current) {
      return;
    }

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

    const sectorAngle = getSectorAngle(); // 扇区角度(360°/数据长度)
    // 计算目标项的原始旋转角度(未滚动时的角度)
    const targetItemOriginalRotate = targetIndex * sectorAngle;
    // 转盘需要旋转的角度(反向抵消原始角度,使目标项到顶部)
    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);
    });
  };

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