需求 :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>
实现效果:

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