Files
WAI_Project_UNIX/uni_modules/cool-ui/components/cl-slider/cl-slider.uvue
2025-07-29 19:18:58 +08:00

494 lines
11 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view
class="cl-slider"
:class="[
{
'cl-slider--disabled': disabled
},
pt.className
]"
>
<view
class="cl-slider__inner"
:style="{
height: blockSize + 'rpx'
}"
>
<view
class="cl-slider__track"
:class="[pt.track?.className]"
:style="{
height: trackHeight + 'rpx'
}"
>
<view class="cl-slider__progress" :style="progressStyle"></view>
<!-- 单滑块模式 -->
<view
v-if="!range"
class="cl-slider__thumb"
:class="[pt.thumb?.className]"
:style="singleThumbStyle"
></view>
<!-- 双滑块模式 -->
<template v-if="range">
<view
class="cl-slider__thumb cl-slider__thumb--min"
:class="[pt.thumb?.className]"
:style="minThumbStyle"
></view>
<view
class="cl-slider__thumb cl-slider__thumb--max"
:class="[pt.thumb?.className]"
:style="maxThumbStyle"
></view>
</template>
</view>
<view
class="cl-slider__picker"
:style="{
height: blockSize * 1.5 + 'rpx'
}"
@touchstart.prevent="onTouchStart"
@touchmove.prevent="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
></view>
</view>
<cl-text
v-if="showValue"
:pt="{
className: parseClass(['text-center w-[100rpx]', pt.value?.className])
}"
>
{{ displayValue }}
</cl-text>
</view>
</template>
<script setup lang="ts">
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch, type PropType } from "vue";
import { parseClass, parsePt, rpx2px } from "@/cool";
import type { PassThroughProps } from "../../types";
defineOptions({
name: "cl-slider"
});
// 组件属性定义
const props = defineProps({
// 样式穿透对象
pt: {
type: Object,
default: () => ({})
},
// v-model 绑定的值,单值模式使用
modelValue: {
type: Number,
default: 0
},
// v-model:values 绑定的值,范围模式使用
values: {
type: Array as PropType<number[]>,
default: () => [0, 0]
},
// 最小值
min: {
type: Number,
default: 0
},
// 最大值
max: {
type: Number,
default: 100
},
// 步长
step: {
type: Number,
default: 1
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 滑块的大小
blockSize: {
type: Number,
default: 40
},
// 线的高度
trackHeight: {
type: Number,
default: 8
},
// 是否显示当前值
showValue: {
type: Boolean,
default: false
},
// 是否启用范围选择
range: {
type: Boolean,
default: false
}
});
const emit = defineEmits(["update:modelValue", "update:values", "change", "changing"]);
const { proxy } = getCurrentInstance()!;
// 样式穿透类型定义
type PassThrough = {
className?: string;
track?: PassThroughProps;
progress?: PassThroughProps;
thumb?: PassThroughProps;
value?: PassThroughProps;
};
// 计算样式穿透对象
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 当前滑块的值,单值模式
const value = ref<number>(props.modelValue);
// 当前范围值,范围模式
const rangeValue = ref<number[]>([...props.values]);
// 轨道宽度(像素)
const trackWidth = ref<number>(0);
// 轨道左侧距离屏幕的距离(像素)
const trackLeft = ref<number>(0);
// 当前活动的滑块索引0: min, 1: max仅在范围模式下使用
const activeThumbIndex = ref<number>(0);
// 计算当前值的百分比(单值模式)
const percentage = computed(() => {
if (props.range) return 0;
return ((value.value - props.min) / (props.max - props.min)) * 100;
});
// 计算范围值的百分比(范围模式)
type RangePercentage = {
min: number;
max: number;
};
const rangePercentage = computed<RangePercentage>(() => {
if (!props.range) return { min: 0, max: 0 };
const val = rangeValue.value;
const minPercent = ((val[0] - props.min) / (props.max - props.min)) * 100;
const maxPercent = ((val[1] - props.min) / (props.max - props.min)) * 100;
return { min: minPercent, max: maxPercent };
});
// 进度条样式
const progressStyle = computed(() => {
const style = new Map<string, string>();
if (props.range) {
const { min, max } = rangePercentage.value;
style.set("left", `${min}%`);
style.set("width", `${max - min}%`);
} else {
style.set("width", `${percentage.value}%`);
}
return style;
});
// 创建滑块样式的通用函数
function createThumbStyle(percent: number) {
const style = new Map<string, string>();
// 使用像素定位,确保滑块始终在轨道范围内
const effectiveTrackWidth = trackWidth.value - rpx2px(props.blockSize) + 1;
const leftPosition = (percent / 100) * effectiveTrackWidth;
const finalLeft = Math.max(0, Math.min(effectiveTrackWidth, leftPosition));
style.set("left", `${finalLeft}px`);
style.set("width", `${rpx2px(props.blockSize)}px`);
style.set("height", `${rpx2px(props.blockSize)}px`);
return style;
}
// 单滑块样式
const singleThumbStyle = computed(() => {
return createThumbStyle(percentage.value);
});
// 最小值滑块样式
const minThumbStyle = computed(() => {
return createThumbStyle(rangePercentage.value.min);
});
// 最大值滑块样式
const maxThumbStyle = computed(() => {
return createThumbStyle(rangePercentage.value.max);
});
// 显示的值
const displayValue = computed<string>(() => {
if (props.range) {
const val = rangeValue.value;
return `${val[0]} - ${val[1]}`;
}
return `${value.value}`;
});
// 获取滑块轨道的宽度和左边距,用于后续触摸计算
function getTrackInfo() {
uni.createSelectorQuery()
.in(proxy)
.select(".cl-slider__track")
.boundingClientRect((node) => {
trackWidth.value = (node as NodeInfo).width ?? 0;
trackLeft.value = (node as NodeInfo).left ?? 0;
})
.exec();
}
// 根据触摸点的clientX计算对应的滑块值
function calculateValue(clientX: number): number {
// 如果轨道宽度为0直接返回最小值
if (trackWidth.value == 0) return props.min;
// 计算触摸点距离轨道左侧的偏移
const offset = clientX - trackLeft.value;
// 计算百分比限制在0~1之间
const percentage = Math.max(0, Math.min(1, offset / trackWidth.value));
// 计算值区间
const range = props.max - props.min;
// 计算实际值
let val = props.min + percentage * range;
// 按步长取整
if (props.step > 0) {
val = Math.round((val - props.min) / props.step) * props.step + props.min;
}
// 限制在[min, max]区间
return Math.max(props.min, Math.min(props.max, val));
}
// 在范围模式下,确定应该移动哪个滑块
function determineActiveThumb(clientX: number) {
if (!props.range) return 0;
const val = rangeValue.value;
const clickValue = calculateValue(clientX);
// 计算点击位置到两个滑块的距离
const distToMin = Math.abs(clickValue - val[0]);
const distToMax = Math.abs(clickValue - val[1]);
// 选择距离更近的滑块
return distToMin <= distToMax ? 0 : 1;
}
// 更新滑块的值,并触发相应的事件
function updateValue(val: number | number[]) {
if (props.range) {
const newVal = val as number[];
const oldVal = rangeValue.value;
// 如果最小值超过了最大值交换activeThumbIndex
if (newVal[0] > newVal[1]) {
activeThumbIndex.value = activeThumbIndex.value == 0 ? 1 : 0;
}
// 确保最小值不大于最大值,但允许通过拖动交换角色
const sortedVal = [Math.min(newVal[0], newVal[1]), Math.max(newVal[0], newVal[1])];
// 检查值是否真的改变了
if (JSON.stringify(oldVal) !== JSON.stringify(sortedVal)) {
rangeValue.value = sortedVal;
emit("update:values", sortedVal);
emit("changing", sortedVal);
}
} else {
const newVal = val as number;
const oldVal = value.value;
if (oldVal !== newVal) {
value.value = newVal;
emit("update:modelValue", newVal);
emit("changing", newVal);
}
}
}
// 触摸开始事件,获取轨道信息并初步设置值
function onTouchStart(e: TouchEvent) {
if (props.disabled) return;
getTrackInfo();
// 延迟10ms确保轨道信息已获取
setTimeout(() => {
const clientX = e.touches[0].clientX;
const newValue = calculateValue(clientX);
if (props.range) {
activeThumbIndex.value = determineActiveThumb(clientX);
const val = [...rangeValue.value];
val[activeThumbIndex.value] = newValue;
updateValue(val);
} else {
updateValue(newValue);
}
}, 10);
}
// 触摸移动事件,实时更新滑块值
function onTouchMove(e: TouchEvent) {
if (props.disabled) return;
const clientX = e.touches[0].clientX;
const newValue = calculateValue(clientX);
if (props.range) {
const val = [...rangeValue.value];
val[activeThumbIndex.value] = newValue;
updateValue(val);
} else {
updateValue(newValue);
}
}
// 触摸结束事件触发change事件
function onTouchEnd() {
if (props.disabled) return;
if (props.range) {
emit("change", rangeValue.value);
} else {
emit("change", value.value);
}
}
// 监听外部v-model的变化保持内部value同步单值模式
watch(
computed(() => props.modelValue),
(val: number) => {
if (val !== value.value) {
value.value = Math.max(props.min, Math.min(props.max, val));
}
},
{ immediate: true }
);
// 监听外部v-model:values的变化保持内部rangeValue同步范围模式
watch(
computed(() => props.values),
(val: number[]) => {
rangeValue.value = val.map((e) => {
return Math.max(props.min, Math.min(props.max, e));
});
},
{ immediate: true }
);
// 监听max的变化确保value不会超过max
watch(
computed(() => props.max),
(val: number) => {
if (props.range) {
const rangeVal = rangeValue.value;
if (rangeVal[0] > val || rangeVal[1] > val) {
updateValue([Math.min(rangeVal[0], val), Math.min(rangeVal[1], val)]);
}
} else {
const singleVal = value.value;
if (singleVal > val) {
updateValue(val);
}
}
},
{ immediate: true }
);
// 监听min的变化确保value不会小于min
watch(
computed(() => props.min),
(val: number) => {
if (props.range) {
const rangeVal = rangeValue.value;
if (rangeVal[0] < val || rangeVal[1] < val) {
updateValue([Math.max(rangeVal[0], val), Math.max(rangeVal[1], val)]);
}
} else {
const singleVal = value.value;
if (singleVal < val) {
updateValue(val);
}
}
},
{ immediate: true }
);
onMounted(() => {
watch(
computed(() => [props.showValue]),
() => {
nextTick(() => {
getTrackInfo();
});
}
);
getTrackInfo();
});
</script>
<style lang="scss" scoped>
.cl-slider {
@apply flex flex-row items-center w-full overflow-visible;
&--disabled {
opacity: 0.6;
pointer-events: none;
}
&__inner {
@apply flex-1 relative h-full flex flex-row items-center overflow-visible;
}
&__picker {
@apply absolute left-0 w-full;
}
&__track {
@apply relative w-full rounded-full overflow-visible;
@apply bg-surface-200;
}
&__progress {
@apply absolute top-0 h-full rounded-full;
@apply bg-primary-400;
}
&__thumb {
@apply absolute top-1/2 rounded-full border border-solid border-white;
@apply bg-primary-500;
transform: translateY(-50%);
pointer-events: none;
z-index: 1;
&--min {
z-index: 2;
}
&--max {
z-index: 2;
}
}
}
</style>