一个支持手机端和PC端的 Picker

1,502 阅读5分钟

在做项目的时候,发现现有移动端组件 Picker,在 PC 端使用的时候,用户会反馈说不能满足鼠标的滚轮事件,用起来有些许的不方便。毕竟有的时候移动端 H5 页面也会被使用 PC 的浏览器打开。所以决定自己实现一个同时支持手机端和 PC 的 Picker。

一个支持手机端和 PC 端的 Picker。功能具体包括:
✅ 在移动端支持 手指滚动 选择。
✅ 在 PC 端支持 拖放 滚动选择;
✅ 在 PC 端支持 鼠标滚轮 滚动选择;
✅ 支持触摸板的横向滚动
✅ 支持点选

ezgif-7-2b361af8c0de.gif

准备

组件滚动原理是 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

scroll-picker1.jpeg

scroll-picker2.png

  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-containermousedown 事件,也就是鼠标在 scroll-picker-container 上触发 mousedown 事件的时候,给 document 添加 mousemovemouseup,这样鼠标的移动和放开的时候都会被监控到。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.xdeltaCache.y 为 0.

 // MouseWheel.ts
  const wheelStartHandler = (event: ICompatibleWheelEvent) => {
    deltaCache = {
      x: 0,
      y: 0,
    }
    wheelCount = 0
    lastWheelTimestamp = 0
    // 执行回调
    handleStart(deltaCache)
  }

由于各种浏览器关于鼠标滚轮定义的不一致,需要对滚动距离做一个统一的处理。也就是对鼠标滚动距离进行一个统一处理,然后将这个距离累加到 deltaCache.xdeltaCache.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.xdeltaCache.y 超过 maxDis 的时候,不会触发接下来的 wheel 事件,同时将 deltaCache.xdeltaCache.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
  }

源码

源码:github.com/YY88Xu/scro…