【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'] |
三、使用示例
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}><Wheelref={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, 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 (<Viewkey={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 (<Viewkey={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 (<Viewstyle={{width: wheelWidth,height: wheelHeight,overflow: 'hidden',transform: [{rotate: `${rotateOut}deg`},{translateX: wheelWidth / 2},],}}><Viewstyle={{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 (<Viewstyle={{width: wheelWidth,height: wheelHeight,overflow: 'hidden',transform: [{rotate: `${rotateOut}deg`}],}}><Viewstyle={{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},],}}/><Viewstyle={{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 (<Viewstyle={{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 (<Viewstyle={[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.Viewstyle={[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 && (<TouchableOpacityactiveOpacity={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);