其实封装这个组件的原因只是因为
1.市面上的大多数UI库中的滑动选择器都无法完全自定义滑块以及进度条,如下图
都是只能简单调整,但是如果遇到自定义要求很高的情况就无法满足了
2.市面上的滑动选择器的选择范围有最大值限制,一般到200就不能再大了
所以才会想着封装这个组件,这个组件其实主要是处理好滑动的逻辑,让调用者无需操心滑动逻辑从而根据要求自定义滑块、进度条、自定义最大值
常见版
常见版其实是根据uniapp官网的slider改编而来
代码
<template>
<div
class="slider"
:class="{ disabled }"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@click="onClickTrack"
>
<!-- 滑道 -->
<div
class="slider-track"
:style="{ backgroundColor: backgroundColor, ...sliderStyle }"
>
<!-- 已选中部分 -->
<div
class="slider-track-active"
:style="{
width: thumbCenterPercent + '%',
backgroundColor: activeColor,
}"
></div>
</div>
<!-- 滑块 -->
<div
class="slider-thumb"
ref="thumbRef"
:class="{ custom: !!$slots.thumb }"
:style="[thumbStyle, { backgroundColor: !!$slots.thumb ? 'transparent' : '' }]"
>
<slot name="thumb"></slot>
</div>
<!-- 显示数值 -->
<div
v-if="showValue"
class="slider-value"
>{{ displayValue }}</div
>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, getCurrentInstance } from 'vue';
const props = defineProps({
modelValue: { type: Number, default: 0 },
min: { type: Number, default: 0 },
max: { type: Number, default: 100 },
step: { type: Number, default: 1 },
disabled: { type: Boolean, default: false },
showValue: { type: Boolean, default: false },
sliderStyle: { type: Object, default: {} },
activeColor: { type: String, default: '#007aff' },
backgroundColor: { type: String, default: '#e9e9e9' },
blockColor: { type: String, default: '#ffffff' },
blockSize: { type: Number, default: 28 }, // rpx
});
const emit = defineEmits(['update:modelValue', 'change', 'changing']);
const currentValue = ref(props.modelValue);
const trackWidth = ref(0);
const startX = ref(0);
const startValue = ref(0);
const trackLeft = ref(0);
const systemInfo = uni.getSystemInfoSync();
const rpx2px = systemInfo.screenWidth / 750;
// 百分比(滑块中心)
const thumbCenterPercent = computed(() => {
const range = props.max - props.min;
return ((currentValue.value - props.min) / range) * 100;
});
// 显示的值
const displayValue = computed(() => Math.round(currentValue.value));
// 滑块像素位置(用于 translateX 计算)
const thumbTranslateX = computed(() => {
if (!trackWidth.value) return 0;
const centerX = (thumbCenterPercent.value / 100) * trackWidth.value;
const halfBlock = (props.blockSize / 2) * rpx2px; // rpx → px
return centerX - halfBlock;
});
// 滑块样式
const thumbStyle = computed(() => ({
width: props.blockSize + 'rpx',
height: props.blockSize + 'rpx',
backgroundColor: props.blockColor,
transform: `translateX(${thumbTranslateX.value}px) translateY(-50%)`,
transition: isSliding.value ? 'none' : 'transform 0.1s linear',
}));
// 添加一个响应式变量来标识是否正在滑动
const isSliding = ref(false);
// 同步外部 v-model
watch(
() => props.modelValue,
(val) => {
// 只有在非滑动状态下才同步外部值
if (!isSliding.value && val !== currentValue.value) {
currentValue.value = val;
}
}
);
onMounted(() => {
const query = uni.createSelectorQuery().in(getCurrentInstance());
query
.select('.slider-track')
.boundingClientRect((data) => {
if (data) {
trackWidth.value = data.width;
trackLeft.value = data.left;
}
})
.exec();
});
// 拖动开始
function onTouchStart(e) {
if (props.disabled) return;
isSliding.value = true;
startX.value = e.touches[0].clientX;
startValue.value = currentValue.value;
}
// 拖动中
function onTouchMove(e) {
if (props.disabled || !trackWidth.value) return;
const deltaX = e.touches[0].clientX - startX.value;
const range = props.max - props.min;
const deltaValue = (deltaX / trackWidth.value) * range;
let newValue = startValue.value + deltaValue;
updateValue(newValue, true);
}
// 拖动结束
function onTouchEnd() {
if (props.disabled) return;
isSliding.value = false;
emit('change', currentValue.value);
}
// 点击滑道
function onClickTrack(e) {
if (props.disabled || !trackWidth.value) return;
// 使用uni-app的方式获取点击位置
const clientX = e.detail.x || (e.touches && e.touches[0].clientX) || 0;
const clickX = clientX - trackLeft.value;
const percentClicked = clickX / trackWidth.value;
const range = props.max - props.min;
let newValue = props.min + range * percentClicked;
updateValue(newValue, false);
emit('change', currentValue.value);
}
// 更新数值
function updateValue(val, isChanging) {
let newValue = Math.min(props.max, Math.max(props.min, val));
newValue = Math.round(newValue / props.step) * props.step;
currentValue.value = newValue;
emit('update:modelValue', newValue);
if (isChanging) emit('changing', newValue);
}
</script>
<style lang="scss" scoped>
.slider {
position: relative;
display: flex;
align-items: center;
height: 80rpx;
user-select: none;
&.disabled {
opacity: 0.5;
pointer-events: none;
}
.slider-track {
position: relative;
flex: 1;
height: 8rpx;
border-radius: 4rpx;
background-color: #e9e9e9;
overflow: hidden;
.slider-track-active {
position: absolute;
top: 0;
left: 0;
height: 100%;
border-radius: 4rpx;
}
}
.slider-thumb {
position: absolute;
top: 50%;
border-radius: 50%;
box-shadow: 0 0 6rpx rgba(0, 0, 0, 0.2);
&.custom {
background: none;
box-shadow: none;
}
}
.slider-value {
margin-left: 20rpx;
font-size: 26rpx;
color: #333;
}
}
</style>
使用
<mySlider
v-model="audioControlStore.currentTime"
:min="0"
:max="audioControlStore.duration"
:step="1"
:sliderStyle="{
height: '10rpx',
'border-radius': '9rpx',
}"
activeColor="#8E97FE"
backgroundColor="#DBDDF3"
@change="sliderEnd"
>
<template #thumb>
<image
src="/static/images/AudioControl/jdd.png"
mode="scaleToFill"
style="width: 24rpx; height: 24rpx"
/> </template
></mySlider>
特殊版
先看实现效果
代码
<template>
<div class="schedule-container">
<div class="progress-bar-wrapper">
<!-- 进度条背景 -->
<div
class="progress-bar"
:style="{ background: ProgressBackgroundColor }"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseLeave"
>
<!-- 等级刻度 -->
<div
v-for="(level, index) in ProgressLevel"
:key="index"
class="level-mark"
:style="{ left: `${(index / (ProgressLevel.length - 1)) * 100}%` }"
>
<span class="level-number">{{ level }}</span>
</div>
<!-- 当前进度圆圈 -->
<div
class="current-progress"
:style="{
left: `${(currentIndex / (ProgressLevel.length - 1)) * 100}%`,
...CurrentProgressStyle,
}"
></div>
</div>
</div>
<!-- 底部描述文本 -->
<div class="progress-desc">
<div class="desc-item" v-if="ProgressDesc[0]">
<span>{{ ProgressDesc[0] }}</span>
</div>
<div class="desc-item" v-if="ProgressDesc[ProgressDesc.length - 1]">
<span>{{ ProgressDesc[ProgressDesc.length - 1] }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, watchEffect, getCurrentInstance, nextTick } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
const { proxy } = getCurrentInstance();
const props = defineProps({
// 进度条背景渐变色
ProgressBackgroundColor: {
type: String,
default: 'linear-gradient(to right, #4CAF50, #FFC107, #FF9800, #F44336)',
},
// 进度条等级数组
ProgressLevel: {
type: Array,
default: () => [0, 1, 2, 3, 4, 5, 6],
},
// 底部描述文本数组
ProgressDesc: {
type: Array,
default: () => ['无痛', '剧痛'],
},
// 当前进度圆圈样式
CurrentProgressStyle: {
type: Object,
default: () => ({
width: '20px',
height: '20px',
backgroundColor: '#fff',
border: '2px solid #2196F3',
borderRadius: '50%',
}),
},
});
// 双向绑定的进度值
const ProgressValue = defineModel('ProgressValue', { type: Number, required: true });
onMounted(() => {
// 确保初始值有效
if (ProgressValue.value === undefined || ProgressValue.value === null) {
ProgressValue.value = props.ProgressLevel[0];
}
});
// 当前进度索引
const currentIndex = computed(() => {
const index = props.ProgressLevel.indexOf(ProgressValue.value);
return index >= 0 ? index : 0;
});
// 拖拽相关状态
const isDragging = ref(false);
const progressBarRef = ref(null);
// 触摸事件处理
const handleTouchStart = (e) => {
isDragging.value = true;
updateProgressFromEvent(e);
};
const handleTouchMove = (e) => {
if (isDragging.value) {
e.preventDefault();
updateProgressFromEvent(e);
}
};
const handleTouchEnd = () => {
isDragging.value = false;
};
// 鼠标事件处理
const handleMouseDown = (e) => {
isDragging.value = true;
updateProgressFromEvent(e);
};
const handleMouseMove = (e) => {
if (isDragging.value) {
updateProgressFromEvent(e);
}
};
const handleMouseUp = () => {
isDragging.value = false;
};
const handleMouseLeave = () => {
isDragging.value = false;
};
// 根据事件位置更新进度
const updateProgressFromEvent = (e) => {
const rect = e.currentTarget.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const offsetX = clientX - rect.left;
const percentage = Math.max(0, Math.min(1, offsetX / rect.width));
// 计算最近的等级索引
const targetIndex = Math.round(percentage * (props.ProgressLevel.length - 1));
// 确保步进值为1
const currentIndexValue = currentIndex.value;
const diff = targetIndex - currentIndexValue;
if (Math.abs(diff) >= 1) {
const newIndex = currentIndexValue + (diff > 0 ? 1 : -1);
const clampedIndex = Math.max(0, Math.min(props.ProgressLevel.length - 1, newIndex));
ProgressValue.value = props.ProgressLevel[clampedIndex];
}
};
</script>
<style lang="scss" scoped>
.schedule-container {
width: 100%;
padding: 40rpx;
box-sizing: border-box;
}
.progress-bar-wrapper {
position: relative;
margin-bottom: 40rpx;
}
.progress-bar {
position: relative;
width: 100%;
height: 16rpx;
border-radius: 8rpx;
cursor: pointer;
user-select: none;
touch-action: none;
}
.level-mark {
position: absolute;
bottom: -60rpx;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
.level-number {
font-size: 24rpx;
color: #666;
font-weight: 500;
}
}
.current-progress {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
transition: left 0.1s ease;
z-index: 10;
box-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.2);
}
.progress-desc {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 80rpx;
.desc-item {
font-size: 28rpx;
color: #333;
font-weight: 500;
}
}
</style>
调用
<schedule
v-model:ProgressValue="form.professionalEvaluation.comprehensivePainScore"
ProgressBackgroundColor="linear-gradient(to right, #4CAF50, #8BC34A, #CDDC39, #FFEB3B, #FFC107, #FF9800, #FF5722, #F44336, #E91E63, #9C27B0, #673AB7)"
:ProgressLevel="[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]"
:ProgressDesc="['无痛', '剧痛']"
:CurrentProgressStyle="{
width: '24px',
height: '24px',
backgroundColor: '#fff',
border: '3px solid #2196F3',
borderRadius: '50%',
boxShadow: '0 3px 6px rgba(0, 0, 0, 0.3)',
}"
/>