在做项目的时候,发现现有移动端组件 Picker,在 PC 端使用的时候,用户会反馈说不能满足鼠标的滚轮事件,用起来有些许的不方便。毕竟有的时候移动端 H5 页面也会被使用 PC 的浏览器打开。所以决定自己实现一个同时支持手机端和 PC 的 Picker。
一个支持手机端和 PC 端的 Picker。功能具体包括:
✅ 在移动端支持 手指滚动 选择。
✅ 在 PC 端支持 拖放 滚动选择;
✅ 在 PC 端支持 鼠标滚轮 滚动选择;
✅ 支持触摸板的横向滚动
✅ 支持点选
准备
组件滚动原理是 transform: translateX(XXX)。
组件向外暴露的属性包括:
- 指定内部渲染的内容
columns: Array,表示具体显示的数据列 - 每条数据的宽度
itemWidth: Number - 选中项目的 index 值
v-model: Number dampingFactor: Number阻尼因子,值的范围是[0, 1],当 BetterScroll 滚出边界的时候,需要施加阻力,防止滚动幅度过大,值越小,阻力越大。
<template>
<div
id="scroll-picker-container"
class="scroll-picker-container noselect"
>
<div
id="scroll-picker-move"
class="scroll-picker-move"
:style="{transform: `translateX(${translateX}px) translateY(0px) translateZ(1px)`, transitionDuration: `${transitionDuration}ms`}"
>
<div
v-for="(item, index) in columns"
:key="index"
class="scroll-picker-move__item"
:style="{width: itemWidth+'px'}"
>
<span
:class="[index===modelVal ? 'scroll-picker-move__item--active':
index===modelVal+1 || index===modelVal-1 ? 'scroll-picker-move__item--gray' :
index===modelVal+2 || index===modelVal-2 ? 'scroll-picker-move__item--light' :
'scroll-picker-move__item--default']"
>{{ item }}</span>
</div>
</div>
</div>
</template>
准备页面,在页面初始化的时候,根据scroll-picker-container 容器的宽度,计算 Picker 可以移动的最小值 minMoveX 和最大值 maxMoveX
const initPicker = ()=>{
wrapWidth.value = document.getElementById('scroll-picker-container')?.offsetWidth || 0
// 当前 translateX 的值
translateX.value = -(modelVal.value * itemWidth + itemWidth/2 - wrapWidth.value / 2)
// 最小值
minMoveX.value = -(props.columns.length * itemWidth - itemWidth/2 - wrapWidth.value / 2)
// 最大值
maxMoveX.value = wrapWidth.value / 2 - itemWidth/2
}
onMounted(() => {
initPicker()
})
移动端手指滚动事件
移动端 H5 页面特有的事件是:
touchstart手指触摸屏幕时触发,即使已经有手指在屏幕上也会触发。touchmove手指在屏幕滑动时触发。touchend手指从屏幕时移开时触发。 实现手指滚动的原理是:在touchstart开始时,记录一下当前 touch 的位置mouseStartX,移动的时候会有一个新位置,将新位置减去 touch 开始的位置mouseStartX,得到差值,将这个差值反应到目标元素transform: translateX(XXX)上面;最后在touchend手机离开屏幕的时候,根据从开始到结束移动的总距离,计算 Picker 选中状态的位置。
这样便实现了手指移动 Picker 滚动的效果。
事件:
const onEnd = () => {
if (translateX.value === maxMoveX.value) {
modelVal.value = 0
} else if (translateX.value === minMoveX.value) {
modelVal.value = props.columns.length - 1
} else {
const dis = translateX.value - transXBf.value
// 向上取整,轻轻一拨就是一个刻度跳动
const count = Math.ceil(Math.abs(dis) / itemWidth)
if (dis > 0) {
modelVal.value = Math.max(0, modelVal.value - count)
} else {
modelVal.value = Math.min(props.columns.length - 1, modelVal.value + count)
}
translateX.value = -(modelVal.value * itemWidth + itemWidth/2 - wrapWidth.value / 2)
}
}
// 手机端 touch
const onTouchStart = (event: TouchEvent) => {
const touch = event.touches[0]
mouseStartX.value = touch.clientX
transXBf.value = translateX.value
}
const onTouchMove = (event: TouchEvent) => {
event.preventDefault()
const touch = event.touches[0]
onMove(touch.clientX)
}
const onTouchEnd = () => {
onEnd()
}
PC 端支持拖放滚动选择
监控容器 scroll-picker-container 的 mousedown 事件,也就是鼠标在 scroll-picker-container 上触发 mousedown 事件的时候,给 document 添加 mousemove 和 mouseup,这样鼠标的移动和放开的时候都会被监控到。mousedown 记录移动开始的初始值;mousemove 根据移动的差值不断修改 Picker 的位置,实现滚动;mouseup鼠标放开的时候,清除掉 document 对象上绑定的事件,同时,根据最终移动距离计算 Picker 选中状态的位置。
// PC 端
const mouseMove = (e: MouseEvent) => {
onMove(e.clientX)
}
const mouseUpEnd = (e: MouseEvent) => {
transitionDuration.value = 300
document.removeEventListener('mousemove', mouseMove, false)
document.removeEventListener('mouseup', mouseUpEnd, false)
onEnd()
}
const mouseDownStart = (e: MouseEvent) => {
transitionDuration.value = 0
mouseStartX.value = e.clientX
transXBf.value = translateX.value
document.addEventListener('mousemove', mouseMove, false)
document.addEventListener('mouseup', mouseUpEnd, false)
}
鼠标滚轮滚动选择
滚轮事件
滚轮滚动是一种离散的运动,并没有 start、move、end 的事件类型。参考了 Better-Scroll 里面的写法:设置一个离散时间 discreteTime,在离散时间内都属于同一次滚动事件;超过该离散时间定义为第二次滚动事件。
wheelStart 用来判断滚动事件的开始,默认 false, 第一次触发滚动事件后,wheelStart 变为 true, 在离散时间之内滚动鼠标或者滑动触摸板的时候,wheelStart 一直为 false 的状态,接下来的滚动事件都是同一次滚动。初始化 deltaCache.x 和 deltaCache.y 为 0.
// MouseWheel.ts
const wheelStartHandler = (event: ICompatibleWheelEvent) => {
deltaCache = {
x: 0,
y: 0,
}
wheelCount = 0
lastWheelTimestamp = 0
// 执行回调
handleStart(deltaCache)
}
由于各种浏览器关于鼠标滚轮定义的不一致,需要对滚动距离做一个统一的处理。也就是对鼠标滚动距离进行一个统一处理,然后将这个距离累加到 deltaCache.x 和 deltaCache.y 上,为了防止有些电脑的鼠标灵敏距离设置过大,需要对鼠标滚动距离进行判断,太大则取 deltaXStep。
// MouseWheel.ts
// getWheelDelta 统一处理鼠标滚动距离
const getWheelDelta = (event: ICompatibleWheelEvent) => {
const direction = invert ? -1 : 1
let wheelDeltaX = 0
let wheelDeltaY = 0
switch (true) {
case 'deltaX' in event:
if (event.deltaMode === 1) {
wheelDeltaX = -event.deltaX * speed
wheelDeltaY = -event.deltaY * speed
} else {
wheelDeltaX = -event.deltaX
wheelDeltaY = -event.deltaY
}
break
case 'wheelDeltaX' in event:
wheelDeltaX = (event.wheelDeltaX / 120) * speed
wheelDeltaY = (event.wheelDeltaY / 120) * speed
break
case 'wheelDelta' in event:
wheelDeltaX = (event.wheelDelta / 120) * speed
wheelDeltaY = wheelDeltaX
break
case 'detail' in event:
wheelDeltaX = (-event.detail / 3) * speed
wheelDeltaY = wheelDeltaX
break
default:
break
}
wheelDeltaX *= direction
wheelDeltaY *= direction
return {
x: wheelDeltaX,
y: wheelDeltaY,
}
}
// wheelMove 处理
const wheelMoveHandler = (event: ICompatibleWheelEvent, delta: IWheelDelta) => {
// 处理 windows 企业微信内置浏览器 会同时出发多次的问题
if ((event.timeStamp - lastWheelTimestamp) < 2) {
return
}
wheelCount += 1
lastWheelTimestamp = event.timeStamp
// 防止滚动过大
deltaCache.x += Math.min(Math.abs(delta.x), deltaXStep) * (delta.x < 0 ? -1 : 1)
deltaCache.y += Math.min(Math.abs(delta.y), deltaYStep) * (delta.y < 0 ? -1 : 1)
if (throttleTime && wheelMoveTimer) {
return
}
handleMove(deltaCache)
if (throttleTime) {
// 由于滚轮滚动是高频率的动作,因此可以通过 throttleTime 来限制触发频率,
// mouseWheel 内部会缓存滚动的距离,并且每隔 throttleTime 会计算缓存的距离并且滚动。
// 修改 throttleTime 可能会造成滚动动画不连贯,请根据实际场景进行调整。
wheelMoveTimer = window.setTimeout(() => {
wheelMoveTimer = 0
}, throttleTime)
}
}
最后是处理滚动结束事件,做了一个如果类似“防抖”的逻辑处理,在 discreteTime 内触发的,clear 掉之前,重新计时。
// MouseWheel.ts
const wheelEndDetector = (event: ICompatibleWheelEvent) => {
if (wheelEndTimer) {
window.clearTimeout(wheelEndTimer)
wheelEndTimer = 0
}
wheelEndTimer = window.setTimeout(() => {
wheelStart = false
window.clearTimeout(wheelMoveTimer)
wheelMoveTimer = 0
if (wheelCount < 2 && (Math.abs(deltaCache.x) < deltaXStep || Math.abs(deltaCache.y) < deltaYStep)) {
//防止鼠标滚动距离太小
deltaCache.x = Math.max(Math.abs(deltaCache.x), deltaXStep) * (deltaCache.x < 0 ? -1 : 1)
deltaCache.y = Math.max(Math.abs(deltaCache.y), deltaYStep) * (deltaCache.y < 0 ? -1 : 1)
handleMove(deltaCache)
}
handleEnd(deltaCache)
}, discreteTime)
}
问题
在实际操作中发现,使用触摸板进行滚动的时候,由于触摸板的惯性设计,也就是触摸板滑动了一下结束后,仍然会触发 wheel 事件。惯性结束了,才停止 wheel 事件。
同时,又有滚动距离的限制,不能超出滚轮范围内。这就导致一个问题:一直向一个方向滑动触摸板页面看起来没有反应。其实是有反应的,一直在触发滚轮事件,只是 Picker 已经在边界了不能滚动了而已。加入了一个最大距离 maxDis 的判断,当 deltaCache.x 或 deltaCache.y 超过 maxDis 的时候,不会触发接下来的 wheel 事件,同时将 deltaCache.x 和 deltaCache.y 清空为 0,等待下一次滚动。
// MouseWheel.ts
const wheelHandler = (e: Event) => {
const event = e as ICompatibleWheelEvent
// 最大距离判断,防止无效滚动
if((Math.abs(deltaCache.x)>maxDis || Math.abs(deltaCache.y)>maxDis) && maxDis !==0){
// start
if (!wheelStart) {
wheelStartHandler(event)
wheelStart = true
}
return
}
beforeHandler(event)
const delta = getWheelDelta(event)
// start
if (!wheelStart) {
wheelStartHandler(event)
wheelStart = true
}
// move
wheelMoveHandler(event, delta)
// end
wheelEndDetector(event)
}
到这里 MouseWheel.ts 文件封装的差不多了。具体详细代码:github.com/YY88Xu/scro…
支持点选
需要注意的一点就是点选事件(click)和 mousedown 会混在一起,mousedown -> mouseup -> click 所以需要在 mousedown 的时候判断下,如果移动距离小于 3(自己可以设置),才表示是拖放事件,不是 click 事件。
const mouseUpEnd = (e: MouseEvent) => {
transitionDuration.value = 300
const dis = Math.abs(transXBf.value - translateX.value)
// 判断是否是 click
if (dis > 3) {
isClick.value = false
} else {
isClick.value = true
}
document.removeEventListener('mousemove', mouseMove, false)
document.removeEventListener('mouseup', mouseUpEnd, false)
onEnd()
}
// PC 端点击具体标签
const moveTo = (index: number) => {
if (!isClick.value) {
return
}
translateX.value = -(index * itemWidth + itemWidth/2 - wrapWidth.value / 2)
modelVal.value = index
}