uni-app app移动端实现纵向滑块功能,并伴随自动播放

需求 :uni-app实现纵向滑动的时间轴,添加标记点,支持自动播放;自动播放默认直接滑动到最近的标记点;

一开始用的uni官方自带滑块和uView的滑块组件, 但是两者都不支持纵向,虽然样式可以实现,但是滑动有问题;最后自定义实现纵向滑块功能,以下是代码部分:(自动滑动方向, 目前我只用了down, up没有测试)

组件实现:verticalSlider.vue

html 复制代码
<template>
	<view class="vertical-slider-container">
		<!-- 当前值显示 -->
		<view class="value-display" v-if="showValue">{{ currentValue }}</view>

		<!-- 滑轨区域 -->
		<view class="slider-track" @tap="onTrackTap" ref="track">
			<!-- 滑轨背景 -->
			<view class="track-background"></view>

			<!-- 已填充区域 -->
			<view class="track-filled" :style="{ height: filledHeight + '%' }"></view>

			<!-- 标记点,从上往下滑动设置top值,如果从下往上需要改为bottom,滑块同理 -->
			<view v-for="(mark, index) in marks" :key="index" class="mark-point"
				:style="{ top: calculateMarkPosition(mark.value) + '%' }" @tap.stop="jumpToMark(mark.value)">
				<view class="mark-dot"></view>
				<view v-if="showMarkLabel" class="mark-label">{{ mark.label }}</view>
			</view>

			<!-- 滑块 -->
			<view class="slider-thumb" :style="{ top: thumbPosition + '%' }" @touchstart="onTouchStart"
				@touchmove="onTouchMove" @touchend="onTouchEnd">
				<view class="thumb-label" :class="{ 'label-visible': isDragging || showThumbLabel }">
					{{currentTime}}
				</view>
			</view>
		</view>
	</view>
</template>
javascript 复制代码
<script>
	import moment from 'moment';
	export default {
		name: "VerticalSlider",
		props: {
			// 最小值
			min: {
				type: Number,
				default: 0
			},
			// 最大值
			max: {
				type: Number,
				default: 100
			},
			// 当前值
			value: {
				type: Number,
				default: 0
			},
			// 是否显示当前值
			showValue: {
				type: Boolean,
				default: true
			},
			// 是否显示滑块label
			showThumbLabel: {
				type: Boolean,
				default: true
			},
			// 步长
			step: {
				type: Number,
				default: 1
			},
			// 标记点数组
			marks: {
				type: Array,
				default: () => []
			},
			// 是否显示标记点
			showMarks: {
				type: Boolean,
				default: true
			},
			// 是否显示标记点label
			showMarkLabel: {
				type: Boolean,
				default: true
			},
			// 是否自动滑动
			autoPlay: {
				type: Boolean,
				default: false
			},
			// 自动滑动方向, down或up
			autoPlayDirection: {
				type: String,
				default: 'down'
			},
			// 自动滑动速度 (毫秒)
			autoPlaySpeed: {
				type: Number,
				default: 1000
			},
		},
		data() {
			return {
				currentValue: this.value,
				isDragging: false,
				trackHeight: 0,
				trackTop: 0,
				autoPlayTimer: null, // 自动播放定时器
				animationTimer: null, // 动画定时器
				isAutoPlaying: false,
			};
		},
		computed: {
			// 滑块位置百分比(从上到下)
			thumbPosition() {
				return ((this.currentValue - this.min) / (this.max - this.min)) * 100;
			},
			// 已填充区域高度百分比(从上到下)
			filledHeight() {
				return this.thumbPosition;
			},
			currentTime() {
				return moment.unix(this.currentValue).format('YYYY-MM-DD HH:mm:ss');
			},
			sortedMarks() {
				return [...this.marks].sort((a, b) => a.value - b.value)
			}
		},
		watch: {
			// 监听 autoPlay 属性变化
			autoPlay(newVal) {
				if (newVal) {
					this.startAutoPlay();
				} else {
					this.stopAutoPlay();
				}
			}, 
			// 监听 value 属性变化
			value(newVal) {
				if (newVal !== this.currentValue) {
					this.currentValue = newVal;
				}
			},
			// 监听当前值变化,到达边界时停止自动播放
			currentValue(newVal) {
				if (this.isAutoPlaying) {
					if ((this.autoPlayDirection === 'down' && newVal >= this.max) ||
						(this.autoPlayDirection === 'up' && newVal <= this.min)) {
						this.stopAutoPlay();
						this.$emit('autoPlayEnd');
					}
				}
			}
		},
		mounted() {
			this.$nextTick(() => {
				this.initTrackSize();
			});
			// 如果初始设置为自动播放,则开始
			if (this.autoPlay) {
				this.startAutoPlay();
			}
		},
		methods: {
			// 初始化滑轨尺寸
			initTrackSize() {
				const query = uni.createSelectorQuery().in(this);
				query.select('.slider-track').boundingClientRect(data => {
					if (data) {
						this.trackHeight = data.height;
						this.trackTop = data.top;
					}
				}).exec();
			},
			// 触摸开始
			onTouchStart(e) {
				this.isDragging = true;
				this.updateValueFromEvent(e);
			},
			// 触摸移动
			onTouchMove(e) {
				if (!this.isDragging) return;
				this.updateValueFromEvent(e);
				e.preventDefault();
			},

			// 触摸结束
			onTouchEnd() {
				this.isDragging = false;
				// 如果有标记点,尝试吸附到最近的标记点
				if (this.marks.length > 0) {
					this.snapToNearestMark();
				}
			},
			// 点击滑轨跳转
			onTrackTap(e) {
				this.updateValueFromEvent(e);
				// 如果有标记点,尝试吸附到最近的标记点
				if (this.marks.length > 0) {
					this.snapToNearestMark();
				}
			},
			// 根据事件更新值(从上到下)	
			updateValueFromEvent(e) {
				let clientY;
				if (e.type === 'tap') {
					clientY = e.detail.y;
				} else {
					clientY = e.touches[0].clientY;
				}
				// 计算点击位置在滑轨中的百分比(从上到下)
				const position = (clientY - this.trackTop) / this.trackHeight;
				let newValue = this.min + position * (this.max - this.min);
				// 限制在[min, max]范围内
				newValue = Math.max(this.min, Math.min(this.max, newValue));
				// 应用步长
				if (this.step > 0) {
					newValue = Math.round(newValue / this.step) * this.step;
				}
				this.updateValue(newValue);
			},
			// 更新值
			updateValue(newValue) {
				if (newValue !== this.currentValue) {
					this.currentValue = newValue;
					this.$emit('change', newValue);
					this.$emit('input', newValue);
				}
			},
			// 计算标记点位置(从上到下)
			calculateMarkPosition(value) {
				return ((value - this.min) / (this.max - this.min)) * 100;
			},
			// 跳转到标记点
			jumpToMark(value) {
				this.updateValue(value);
			},
			// 吸附到最近的标记点
			snapToNearestMark() {
				if (this.marks.length === 0) return;
				let nearestMark = this.marks[0];
				let minDiff = Math.abs(this.currentValue - nearestMark.value);
				for (let i = 1; i < this.marks.length; i++) {
					const diff = Math.abs(this.currentValue - this.marks[i].value);
					if (diff < minDiff) {
						minDiff = diff;
						nearestMark = this.marks[i];
					}
				}
				// 如果距离足够近,则吸附
				if (minDiff <= (this.max - this.min) * 0.05) {
					this.updateValue(nearestMark.value);
				}
			},
			// 开始自动播放
			startAutoPlay() {
				if (this.isAutoPlaying) return;
				this.isAutoPlaying = true;
				this.$emit('autoPlayStart');
				// this.autoPlayBySeacond();
				this.jumpToNextTarget();
			},
			// 按秒滑动
			autoPlayBySecond() {
				this.autoPlayTimer = setInterval(() => {
					let newValue;
					if (this.autoPlayDirection === 'down') {
						newValue = this.currentValue + this.step;
						if (newValue > this.max) newValue = this.max;
					} else {
						newValue = this.currentValue - this.step;
						if (newValue < this.min) newValue = this.min;
					}
					this.updateValue(newValue);
					// 检查是否到达边界
					if ((this.autoPlayDirection === 'down' && newValue >= this.max) ||
						(this.autoPlayDirection === 'up' && newValue <= this.min)) {
						this.stopAutoPlay();
						this.$emit('autoPlayEnd');
					}
				}, this.autoPlaySpeed);
			},
			// 跳转到下一个目标(标记点或终点)
			jumpToNextTarget() {
				const nextTarget = this.getNextTarget();
				if (nextTarget !== null) {
					// 使用动画平滑过渡到下一个目标
					this.animateToValue(nextTarget, 1000, () => {
						// 动画完成后,检查是否到达终点
						if (this.isAutoPlaying && nextTarget !== this.getEndValue()) {
							// 设置定时器进行下一次跳转
							this.autoPlayTimer = setTimeout(() => {
								this.jumpToNextTarget();
							}, this.autoPlaySpeed);
						} else {
							// 到达终点,停止自动播放
							this.stopAutoPlay();
							this.$emit('autoPlayEnd');
						}
					});
				} else {
					// 没有下一个目标,停止自动播放
					this.stopAutoPlay();
					this.$emit('autoPlayEnd');
				}
			},
			// 获取下一个目标值
			getNextTarget() {
				if (this.autoPlayDirection === 'down') {
					return this.getNextTargetDown();
				} else {
					return this.getNextTargetUp();
				}
			},
			// 向下播放时获取下一个目标
			getNextTargetDown() {
				// 如果当前值已经到达终点
				if (this.currentValue >= this.max) {
					return null;
				}
				// 查找当前值之后的下一个标记点
				const nextMark = this.sortedMarks.find(mark => mark.value > this.currentValue);

				if (nextMark) {
					return nextMark.value;
				} else {
					// 没有标记点,直接跳到终点
					return this.max;
				}
			},
			// 向上播放时获取下一个目标
			getNextTargetUp() {
				// 如果当前值已经到达起点
				if (this.currentValue <= this.min) {
					return null;
				}
				// 查找当前值之前的上一个标记点(按值降序查找)
				const prevMark = [...this.sortedMarks]
					.reverse()
					.find(mark => mark.value < this.currentValue);

				if (prevMark) {
					return prevMark.value;
				} else {
					// 没有标记点,直接跳到起点
					return this.min;
				}
			},
			// 获取终点值(根据方向)
			getEndValue() {
				return this.autoPlayDirection === 'down' ? this.max : this.min;
			},
			// 平滑动画到指定值
			animateToValue(targetValue, duration = 1000, onComplete = null) {
				const startValue = this.currentValue;
				const startTime = Date.now();
				const frameRate = 16; // 大约 60fps
				// 停止之前的动画
				if (this.animationTimer) {
					clearTimeout(this.animationTimer);
				}
				const animate = () => {
					const elapsed = Date.now() - startTime;
					const progress = Math.min(elapsed / duration, 1);
					// 使用缓动函数让动画更自然
					const easeOutCubic = 1 - Math.pow(1 - progress, 3);
					const newValue = startValue + (targetValue - startValue) * easeOutCubic;
					this.updateValue(newValue);
					if (progress < 1) {
						// 使用 setTimeout 替代 requestAnimationFrame
						this.animationTimer = setTimeout(animate, frameRate);
					} else {
						// 确保最终值准确
						this.updateValue(targetValue);
						if (onComplete) onComplete();
					}
				};
				this.animationTimer = setTimeout(animate, frameRate);
			},
			// 停止自动播放
			stopAutoPlay() {
				if (this.autoPlayTimer) {
					clearInterval(this.autoPlayTimer);
					this.autoPlayTimer = null;
				}
				if (this.isAutoPlaying) {
					this.isAutoPlaying = false;
					this.$emit('autoPlayStop');
				}
			},
		},
		beforeDestroy() {
			if (this.autoPlayTimer) {
				clearInterval(this.autoPlayTimer);
			}
			if (this.animationTimer) {
				clearInterval(this.animationTimer);
			}
		}
	};
</script>
css 复制代码
<style lang="scss" scoped>
	.vertical-slider-container {
		display: flex;
		flex-direction: column;
		align-items: center;
		height: 100%;

		.value-display {
			font-size: 32rpx;
			font-weight: bold;
			margin-bottom: 20rpx;
			color: #333;
		}

		.slider-track {
			position: relative;
			width: 60rpx;
			height: 100%;
			background-color: transparent;
			touch-action: pan-y;

			.track-background {
				position: absolute;
				top: 0;
				left: 50%;
				transform: translateX(-50%);
				width: 12rpx;
				height: 100%;
				background-color: #e0e0e0;
				border-radius: 6rpx;
			}

			.track-filled {
				position: absolute;
				top: 0;
				left: 50%;
				transform: translateX(-50%);
				width: 12rpx;
				background: linear-gradient(0deg, #3281F4 2%, #7DD8F2 100%);
				border-radius: 6rpx;
				transition: height 0.1s ease;
			}

			.slider-thumb {
				position: absolute;
				left: 50%;
				transform: translateX(-50%);
				width: 30rpx;
				height: 30rpx;
				border-radius: 50%;
				background-color: #007AFF;
				display: flex;
				justify-content: center;
				align-items: center;
				z-index: 10;
				border: 6rpx solid #fff;

				/* 滑块label样式 */
				.thumb-label {
					position: absolute;
					left: 100%;
					top: 50%;
					transform: translateY(-50%);
					background: rgba(194, 59, 59, 0.8);
					border-radius: 12rpx;
					border: 1px solid #D98D8D;
					color: #fff;
					padding: 0 5rpx;
					font-size: 20rpx;
					white-space: nowrap;
					margin-left: 10rpx;
					opacity: 0;
					transition: opacity 0.3s ease;
					pointer-events: none;

					&.label-visible {
						opacity: 1;
					}
				}
			}

			.mark-point {
				position: absolute;
				left: 0;
				display: flex;
				align-items: center;
				flex-direction: column;
				z-index: 5;
				position: absolute;
				left: 50%;
				transform: translateX(-50%);

				.mark-dot {
					width: 12rpx;
					height: 12rpx;
					border-radius: 50%;
					background-color: #C23B3B;
					border: 2rpx solid #fff;
				}

				.mark-label {
					font-size: 20rpx;
					color: #fff;
					margin-left: 10rpx;
					white-space: nowrap;
					background: rgba(194, 59, 59, 0.8);
					border-radius: 12rpx;
					border: 2rpx solid #D98D8D;
					padding: 0 5rpx;
					position: absolute;
					left: 50%;
					top: -50%;
					box-sizing: border-box;
				}
			}
		}
	}
</style>

父组件使用:

html 复制代码
<template>
	<view class="slider-box">
		<!-- 控制按钮 -->
		<view class="play-btn" @click="hanldeAutoPlay">
			<u-icon v-show="!autoPlay" name="play-right-fill" size="13" color="#fff"></u-icon>
			<u-icon v-show="autoPlay" name="pause" size="13" color="#fff"></u-icon>
		</view>
		<!-- 垂直滑块 -->
		<view class="vertical-slider-container">
			<VerticalSlider :value="sliderValue" :min="min" :max="max" :step="1" :showValue="false" :marks="marks"
				:autoPlay="autoPlay" :showMarkLabel="false" @change="onChange" @autoPlayStop="onAutoPlayStop"
				@autoPlayEnd="onAutoPlayEnd" />
		</view>
	</view>
</template>
javascript 复制代码
<script>
	import VerticalSlider from '@/components/verticalSlider.vue';
	import moment from 'moment';
	
	export default {
		components: {
			VerticalSlider
		},
		props: {
			startTime: {
				type: String,
				default: "1970-01-01 08:00:00"
			},
			endTime: {
				type: String,
				default: "1970-01-01 08:10:00"
			},
			markList: {
				type: Array,
				default: []
			},
		},
		data() {
			return {
				sliderValue: 0,
				min: 0,
				max: 100,
				marks: [],
				autoPlay: false
			};
		},
		mounted() {
			this.handleTime();
			this.marks = this.markList;
		},
		methods: {
			// 最大最小值转换为秒时间戳,方便添加标记点
			handleTime() {
				this.min = moment(this.startTime).unix(); // 转为时间戳秒
				this.max = moment(this.endTime).unix();
				// 滑块默认在最高点,从上往下滑动
				this.sliderValue = this.min;
			},
			// 自动播放
			hanldeAutoPlay() {
				this.autoPlay = !this.autoPlay;
				if (this.sliderValue == this.max) {
					this.sliderValue = this.min;
				}
			},
			// 改变触发
			onChange(val) {
				this.sliderValue = val;
				// 整数执行
				if (Number.isInteger(val)) {
					this.$emit("onChange", val)
				}
			},
			// 自动播放事件
			onAutoPlayStop() {
				console.log('自动播放停止');
				this.autoPlay = false;
			},
			onAutoPlayEnd() {
				console.log('自动播放结束');
				this.autoPlay = false;
			}
		}
	};
</script>
css 复制代码
<style lang="scss" scoped>
	.slider-box { 
		height: 60vh;
		position: absolute;
		top: 300rpx;
		left: 40rpx;
		z-index: 9999;
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: space-between;
		.play-btn {
			width: 44rpx;
			height: 44rpx;
			background: #3281F4;
			border-radius: 50%;
			border: 2px solid #FFFFFF;
			position: absolute;
			top: -60rpx;
			display: flex;
			cursor: pointer;
			box-sizing: border-box;
			.u-icon {
				margin: 0 auto;
			}
		}
		.vertical-slider-container {
			height: 100%;
		}
	}
</style>

实现效果:

如有错误或不足,请大佬们评论指正

相关推荐
dcloud_jibinbin2 小时前
【uniapp】解决小程序分包下的json文件编译后生成到主包的问题
前端·性能优化·微信小程序·uni-app·vue·json
茶憶2 小时前
uniapp移动端实现触摸滑动功能:上下滑动展开收起内容,左右滑动删除列表
前端·javascript·vue.js·uni-app
蒲公英源码2 小时前
uniapp开源ERP多仓库管理系统
mysql·elementui·uni-app·php
shykevin2 小时前
uni-app x开发商城系统,小程序发布,h5发布,安卓打包
android·小程序·uni-app
且白2 小时前
uniapp接入安卓端极光推送离线打包
android·uni-app
Ayn慢慢2 小时前
uni-app PDA焦点录入实现
前端·javascript·uni-app
lemonboy2 小时前
可视化大屏适配方案:用 Tailwind CSS 直接写设计稿像素值
前端·vue.js
鹏仔工作室2 小时前
vue中实现1小时不操作则退出登录功能
前端·javascript·vue.js
坚持就完事了2 小时前
001-初识HTML
前端·html