一、功能概述
本组件是一个自定义轮盘(大转盘)组件
- 旋转动画 :通过
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);