Files
digital-pilates/components/MoodIntensitySlider.tsx
2025-09-26 08:54:02 +08:00

295 lines
7.2 KiB
TypeScript

import * as Haptics from 'expo-haptics';
import { LinearGradient } from 'expo-linear-gradient';
import React from 'react';
import {
StyleSheet,
Text,
View,
} from 'react-native';
import {
Gesture,
GestureDetector,
} from 'react-native-gesture-handler';
import Animated, {
interpolate,
interpolateColor,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
interface MoodIntensitySliderProps {
value: number;
onValueChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
width?: number;
height?: number;
}
export default function MoodIntensitySlider({
value,
onValueChange,
min = 1,
max = 10,
step = 1,
width = 320,
height = 16, // 更粗的进度条
}: MoodIntensitySliderProps) {
const thumbSize = 32; // 合适的触摸区域
const translateX = useSharedValue(0);
const isDragging = useSharedValue(0);
const sliderWidth = width - thumbSize; // thumb中心移动的有效范围
// 计算初始位置
React.useEffect(() => {
const initialPosition = ((value - min) / (max - min)) * sliderWidth;
translateX.value = withSpring(initialPosition);
}, [value, min, max, sliderWidth, translateX]);
const triggerHaptics = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
const startX = useSharedValue(0);
const lastValue = useSharedValue(value);
const gestureHandler = Gesture.Pan()
.onBegin(() => {
startX.value = translateX.value;
lastValue.value = value;
isDragging.value = withSpring(1);
runOnJS(triggerHaptics)();
})
.onUpdate((event) => {
const newX = startX.value + event.translationX;
const clampedX = Math.max(0, Math.min(sliderWidth, newX));
translateX.value = clampedX;
// 计算当前值
const currentValue = Math.round((clampedX / sliderWidth) * (max - min) + min);
// 当值改变时触发震动和回调
if (currentValue !== lastValue.value) {
lastValue.value = currentValue;
runOnJS(triggerHaptics)();
runOnJS(onValueChange)(currentValue);
}
})
.onEnd(() => {
// 计算最终值并吸附到最近的步长
const currentValue = Math.round((translateX.value / sliderWidth) * (max - min) + min);
const snapPosition = ((currentValue - min) / (max - min)) * sliderWidth;
translateX.value = withSpring(snapPosition);
isDragging.value = withSpring(0);
runOnJS(triggerHaptics)();
runOnJS(onValueChange)(currentValue);
});
const thumbStyle = useAnimatedStyle(() => {
const positionScale = interpolate(
translateX.value,
[0, sliderWidth],
[1, 1.1]
);
const dragScale = interpolate(
isDragging.value,
[0, 1],
[1, 1.2]
);
const finalScale = positionScale * dragScale;
// 让thumb在滑动条中正确居中
const thumbPosition = translateX.value + thumbSize / 2;
return {
transform: [
{ translateX: thumbPosition },
{ scale: withSpring(finalScale) }
],
};
});
const thumbInnerStyle = useAnimatedStyle(() => {
const borderColor = interpolateColor(
isDragging.value,
[0, 1],
['#7a5af8', '#ff6b6b']
);
return {
borderColor: borderColor,
};
});
const progressStyle = useAnimatedStyle(() => {
const progressWidth = translateX.value + thumbSize / 2;
return {
width: progressWidth,
};
});
return (
<View style={styles.container}>
<View style={[styles.sliderContainer, { width: width }]}>
{/* 背景轨道 - 更粗的灰色轨道 */}
<View style={[styles.track, { height }]}>
<LinearGradient
colors={['#f3f4f6', '#e5e7eb']}
style={[styles.trackGradient, { height }]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
/>
</View>
{/* 进度条 - 动态渐变颜色 */}
<Animated.View style={[styles.progress, { height }, progressStyle]}>
<LinearGradient
colors={['#22c55e', '#84cc16', '#eab308', '#f97316', '#ef4444']}
locations={[0, 0.25, 0.5, 0.75, 1]}
style={[styles.progressGradient, { height }]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
/>
</Animated.View>
{/* 可拖拽的thumb */}
<GestureDetector gesture={gestureHandler}>
<Animated.View style={[styles.thumb, { width: thumbSize, height: thumbSize }, thumbStyle]}>
{/* <LinearGradient
colors={['#ffffff', '#f8fafc']}
style={styles.thumbGradient}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/> */}
<Animated.View style={[styles.thumbInner, thumbInnerStyle]} />
</Animated.View>
</GestureDetector>
</View>
{/* 标签 */}
<View style={[styles.labelsContainer, { width: width }]}>
<Text style={styles.labelText}></Text>
<Text style={styles.labelText}></Text>
</View>
{/* 刻度 */}
<View style={[styles.scaleContainer, { width: width }]}>
{Array.from({ length: max - min + 1 }, (_, i) => i + min).map((num) => (
<View key={num} style={styles.scaleItem}>
<View style={[styles.scaleMark, value === num && styles.scaleMarkActive]} />
</View>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
paddingTop: 8,
paddingBottom: 4,
},
sliderContainer: {
height: 10,
justifyContent: 'center',
position: 'relative',
},
track: {
position: 'absolute',
left: 0,
right: 0,
borderRadius: 6,
overflow: 'hidden',
},
trackGradient: {
flex: 1,
borderRadius: 6,
},
progress: {
position: 'absolute',
left: 0,
borderRadius: 6,
overflow: 'hidden',
},
progressGradient: {
flex: 1,
borderRadius: 6,
},
thumb: {
position: 'absolute',
left: 0,
overflow: 'hidden',
},
thumbGradient: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
thumbInner: {
width: 16,
height: 28,
borderRadius: 8,
backgroundColor: '#ffffff',
borderWidth: 2,
borderColor: '#7a5af8',
},
valueContainer: {
marginTop: 20,
marginBottom: 12,
paddingHorizontal: 16,
paddingVertical: 10,
backgroundColor: '#7a5af8',
borderRadius: 16,
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.3,
shadowRadius: 6,
elevation: 6,
},
valueText: {
fontSize: 20,
fontWeight: '800',
color: '#ffffff',
textAlign: 'center',
minWidth: 28,
},
labelsContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 6,
},
labelText: {
fontSize: 12,
color: '#5d6676',
fontWeight: '500',
},
scaleContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 12,
},
scaleItem: {
alignItems: 'center',
flex: 1,
},
scaleMark: {
width: 1.5,
height: 8,
backgroundColor: '#e5e7eb',
borderRadius: 0.75,
},
scaleMarkActive: {
backgroundColor: '#7a5af8',
width: 2,
height: 10,
},
});