一、功能概述
本组件是一个自定义轮盘(大转盘)组件
- 旋转动画:通过
Animated实现平滑旋转效果,支持自定义旋转圈数、单圈时长和缓动曲线。 - 自定义渲染:支持自定义奖品项(
renderItem)和旋转按钮(renderRunButton)。 - 精准控制:提供
scrollToIndex方法,可编程滚动到指定奖品位置。 - 状态回调:支持旋转开始(
onRotationStart)和结束(onRotationEnd)的回调事件。 - 视觉定制:支持自定义奖品底盘颜色(
dataBgColor),适配不同设计需求。 - 循环滚动:支持循环自动滚动功能,可通过
startLoop和stopLoop方法控制。 - 状态查询:
isRunning方法,可查询轮盘是否正在旋转。
二、组件 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'] |
三、接口说明
WheelHandles 接口定义了组件暴露给父组件的方法,通过 useRef 和 forwardRef 可以访问这些方法,从而实现对轮盘的编程控制。
| 方法名 | 参数 | 返回值 | 说明 |
|---|---|---|---|
scrollToIndex | targetIndex: number | void | 控制轮盘滚动到指定奖品的位置 |
startLoop | 无 | void | 启动轮盘的循环滚动模式 |
stopLoop | 无 | void | 停止轮盘的循环滚动 |
isRunning | 无 | boolean | 查询轮盘当前是否正在旋转 |
四、使用示例
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);
4838

被折叠的 条评论
为什么被折叠?



