uniapp Slider 滑动选择器

92 阅读3分钟

其实封装这个组件的原因只是因为

1.市面上的大多数UI库中的滑动选择器都无法完全自定义滑块以及进度条,如下图

image.png

都是只能简单调整,但是如果遇到自定义要求很高的情况就无法满足了

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>
                                        

特殊版

先看实现效果

image.png

image.png

代码

<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)',
					}"
				/>