原生 JS 实现时间滑动 picker 选择器

7,186 阅读2分钟

最近看了一些 UI 库的源码,原生 js 实现一个时间选择器的干货,可在此基础上进行扩展到自己的项目中

实现效果如下:

  1. 可点击触发滑动,滑动结束将当前索引值返回
  2. 可滑动触发,滑动结束将当前索引值返回

先看效果,如下图:

实现逻辑:

  1. 通过滑动事件动态计算滑动距离
  2. 利用css的滑动效果,将滑动距离动态赋值给列表容器

html 及 css 代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0, user-scalable=no">
  <title>picker - 滑动选择组件</title>
  <style>
    html,body {
      margin: 0;
      padding: 0;
    }
    .picker-demo {
      width: 360px;
      height: 264px;
      margin: auto;
    }

    /* 遮罩层区域 */
    .picker {
      width: 100%;
      height: 100%;
      position: relative;
    }
    .mask {
      position: absolute;
      top: 0;
      left: 0;
      z-index: 2;
      width: 100%;
      height: 100%;
      background-image: linear-gradient(180deg, hsla(0, 0%, 100%, 0.9), hsla(0, 0%, 100%, 0.4)), linear-gradient(0deg, hsla(0, 0%, 100%, 0.9), hsla(0, 0%, 100%, 0.4));
      background-repeat: no-repeat;
      background-position: top, bottom;
      pointer-events: none;
    }
    .cover-border {
      position: absolute;
      z-index: 3;
      top: 50%;
      left: 16px;
      right: 16px;
      transform: translateY(-50%);
      pointer-events: none;
    }
    .cover-border::after{
      position: absolute;
      box-sizing: border-box;
      content: ' ';
      pointer-events: none;
      top: -50%;
      right: -50%;
      bottom: -50%;
      left: -50%;
      /* border: 0 solid #ebedf0; */
      border: 0 solid #aaa;
      -webkit-transform: scale(0.5);
      transform: scale(0.5);
      border-width: 1px 0;
    }

    /* 内容区域 */
    ul,li {
      list-style: none;
      margin: 0;
      padding: 0;
    }
    .picker-column {
      width: 100%;
      height: 100%;
      position: relative;
      overflow: hidden;
    }
    .column-item {
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 0 4px;
      color: #000;
    }
  </style>
</head>
<body>
  <!-- 外部容器 -->
  <div class="picker-demo">
    <!-- 组件 -->
    <div class="picker">
      <!-- 滚动内容 -->
      <div class="picker-column">
        <ul ref="wrapper" class="wrapper-container">
            <li class="column-item">1</li>
            <li class="column-item">2</li>
            <li class="column-item">3</li>
            <li class="column-item">4</li>
            <li class="column-item">5</li>
            <li class="column-item">6</li>
            <li class="column-item">7</li>
            <li class="column-item">8</li>
            <li class="column-item">9</li>
            <li class="column-item">10</li>
        </ul>
      </div>
      <!-- 遮照层 -->
      <div class="mask"></div>
      <div class="cover-border" :style="frameStyle"></div>
    </div>
  </div>
</body>
<script src="./index.js"></script>
</html>

逻辑代码如下:

// demo数据
// const DEMO_DATA = ['2011', '2012', '2013', '2014', '2015', '2016', '2017', '2018', '2019', '2020']

class Picker {
  DEFAULT_DURATION = 200;
  MIN_DISTANCE = 10;

  // 惯性滑动思路:
  // 在手指离开屏幕时,如果和上一次 move 时的间隔小于 `MOMENTUM_LIMIT_TIME` 且 move
  // 距离大于 `MOMENTUM_LIMIT_DISTANCE` 时,执行惯性滑动
  MOMENTUM_LIMIT_TIME = 30;
  MOMENTUM_LIMIT_DISTANCE = 15;
  supportsPassive = false;

  constructor(options = {}) {
      this.initValue(options)
      this._initValue(options)
      this.resetTouchStatus()
      this.initComputed(options)
      this.setEleStyle()

      this.onMounted()
  }

  // 私有变量
  _initValue(options) {
      this.offset = 0
      this.duration = 0
      this.options = this.initOptions
      this.direction = options.direction || 'vertical'
      this.deltaX = 0
      this.deltaY = 0
      this.offsetX = 0
      this.offsetY = 0

      this.startX = 0
      this.startY = 0

      this.moving = false
      this.startOffset = 0

      this.transitionEndTrigger = null // 滚动函数
      this.touchStartTime = 0 // 记录开始滑动时间
      this.momentumOffset = 0 // 记录开始滑动位置

      this.currentIndex = this.defaultIndex
  }

  // 初始化--用户变量
  initValue(options) {
  	  // 可是区域子元素个数
      this.visibleItemCount = Number(options.visibleItemCount || 6) || 6
      // 子元素高度
      this.itemPxHeight = Number(this.itemPxHeight) || 44
      // 初始化传入的数据列表(当前案例微用到,可结合框架使用)
      this.initOptions = options.initOptions || DEMO_DATA
      // 是否只读
      this.readonly = options.readonly || false
      // 初始显示元素(当前案例未使用,可结合框架扩展)
      this.defaultIndex = Number(options.defaultIndex) || 0
  }

  // 根据传入变量--获取计算属性
  initComputed(options) {
      // 外层容器高度
      this.wrapHeight = this.itemPxHeight * this.visibleItemCount
      this.maskStyle = { backgroundSize: `100% ${(this.wrapHeight - this.itemPxHeight) / 2}px` }
      this.frameStyle = { height: `${this.itemPxHeight}px` }

      // this.count = this.options.length
      this.count = document.querySelector('.wrapper-container').children.length
      this.baseOffset = (this.itemPxHeight * (this.visibleItemCount - 1)) / 2
      // 内层元素高度计算
      this.wrapperStyle = {
          transform: `translate3d(0, ${this.offset + this.baseOffset}px, 0)`,
          transitionDuration: `${this.duration}ms`,
          transitionProperty: this.duration ? 'all' : 'none',
      }
  }

  // 设置外部容器的样式及遮罩层
  setEleStyle() {
      let mask = document.querySelector('.mask')
      let coverBorder = document.querySelector('.cover-border')
      let columnItem = document.querySelectorAll('.column-item')
      mask.style.backgroundSize = this.maskStyle.backgroundSize
      coverBorder.style.height = this.frameStyle.height

      this.setUlStyle()

      this.setColumnHeight(columnItem)
  }
	
  // 滑动主要逻辑--动态设置容器的垂直方向偏移量
  setUlStyle() {
      let wrapperContainer = document.querySelector('.wrapper-container')
      wrapperContainer.style.transform = this.wrapperStyle.transform
      wrapperContainer.style.transitionDuration = this.wrapperStyle.transitionDuration
      wrapperContainer.style.transitionProperty = this.wrapperStyle.transitionProperty
  }

  setUlTransform() {
      this.initComputed()
      this.setUlStyle()
  }

  // 设置每个行元素的高度及点击事件
  setColumnHeight(columnItem) {
      columnItem.forEach((item, index) => {
          item.style.height = `${this.itemPxHeight}px`
          item.tabindex = index
          item.onclick = () => {
              this.onClickItem(index)
              this.setUlTransform()
          }
      })
  }

  // 点击单个行元素
  onClickItem(index) {
      if (this.moving || this.readonly) {
          return;
      }

      this.transitionEndTrigger = null;
      this.duration = this.DEFAULT_DURATION;
      this.setIndex(index, true);
  }

  // 初始化完成--执行事件绑定
  onMounted() {
      let el = document.querySelector('.picker-column')
      this.bindTouchEvent(el)
  }

  bindTouchEvent(el) {
      const { onTouchStart, onTouchMove, onTouchEnd, onTransitionEnd } = this
      let wrapper = document.querySelector('.wrapper-container')

      this.on(el, 'touchstart', onTouchStart);
      this.on(el, 'touchmove', onTouchMove);
      this.on(wrapper, 'transitionend', onTransitionEnd)

      if (onTouchEnd) {
          this.on(el, 'touchend', onTouchEnd);
          this.on(el, 'touchcancel', onTouchEnd);
      }
  }

  on(target, event, handler, passive = false) {
      target.addEventListener(
          event,
          handler,
          this.supportsPassive ? { capture: false, passive } : false
      );
  }

  // 动画结束事件
  onTransitionEnd = () => {
      this.stopMomentum();
  }

  // 滑动结束后数据获取及优化处理
  stopMomentum() {
      this.moving = false;
      this.duration = 0;

      if (this.transitionEndTrigger) {
          this.transitionEndTrigger();
          this.transitionEndTrigger = null;
      }
  }

  // 开始滑动
  onTouchStart = (event) => {
      // 控制只读
      if (this.readonly) return
      let wrapper = document.querySelector('.wrapper-container')
      this.touchStart(event)

      if (this.moving) {
          const translateY = this.getElementTranslateY(wrapper);
          this.offset = Math.min(0, translateY - this.baseOffset);
          this.startOffset = this.offset;
      } else {
          this.startOffset = this.offset;
      }

      this.duration = 0;
      this.transitionEndTrigger = null;
      this.touchStartTime = Date.now();
      this.momentumOffset = this.startOffset;

      // 设置滑动
      this.setUlTransform()
  }

  touchStart(event) {
      this.resetTouchStatus();
      this.startX = event.touches[0].clientX;
      this.startY = event.touches[0].clientY;
  }

  // 重置滑动数据变量
  resetTouchStatus() {
      this.direction = '';
      this.deltaX = 0;
      this.deltaY = 0;
      this.offsetX = 0;
      this.offsetY = 0;
  }

  // 动态获取元素滑动距离--关键
  getElementTranslateY(element) {
      const style = window.getComputedStyle(element);
      const transform = style.transform || style.webkitTransform;
      const translateY = transform.slice(7, transform.length - 1).split(', ')[5];
      return Number(translateY);
  }

  onTouchMove = (event) => {
      if (this.readonly) return

      this.touchMove(event)

      if (this.direction === 'vertical') {
          this.moving = true;
          this.preventDefault(event, true);
      }

      this.offset = this.range(this.startOffset + this.deltaY, -(this.count * this.itemPxHeight), this.itemPxHeight);

      const now = Date.now()
      if (now - this.touchStartTime > this.MOMENTUM_LIMIT_TIME) {
          this.touchStartTime = now;
          this.momentumOffset = this.offset;
      }

      // 滑动中
      this.setUlTransform()
  }

  onTouchEnd = (event) => {
      if (this.readonly) return

      const distance = this.offset - this.momentumOffset;
      const duration = Date.now() - this.touchStartTime;
      const allowMomentum = duration < this.MOMENTUM_LIMIT_TIME && Math.abs(distance) > this.MOMENTUM_LIMIT_DISTANCE

      if (allowMomentum) {
          this.momentum(distance, duration);
          return;
      }

      const index = this.getIndexByOffset(this.offset);
      this.duration = this.DEFAULT_DURATION;
      this.setIndex(index, true)

      // 滑动结束
      this.setUlTransform()

      // compatible with desktop scenario
      // use setTimeout to skip the click event triggered after touchstart
      setTimeout(() => {
          this.moving = false

      }, 0);

  }

  // 滑动动画函数--关键
  momentum(distance, duration) {
      const speed = Math.abs(distance / duration);

      distance = this.offset + (speed / 0.003) * (distance < 0 ? -1 : 1);

      const index = this.getIndexByOffset(distance);

      this.duration = +this.swipeDuration;
      this.setIndex(index, true);
  }

  // 获取当前展示的元素数据信息--关键
  setIndex(index, emitChange) {
      index = this.adjustIndex(index) || 0;

      const offset = -index * this.itemPxHeight;

      const trigger = () => {
          if (index !== this.currentIndex) {
              this.currentIndex = index;

              if (emitChange) {
                  // this.$emit('change', index);
                  console.log(index)
              }
          }
      };

      // trigger the change event after transitionend when moving
      if (this.moving && offset !== this.offset) {
          this.transitionEndTrigger = trigger;
      } else {
          trigger();
      }

      this.offset = offset;
  }

  getValue() {
      return this.options[this.currentIndex];
  }

  adjustIndex(index) {
      index = this.range(index, 0, this.count);

      for (let i = index; i < this.count; i++) {
          if (!this.isOptionDisabled(this.options[i])) return i;
      }

      for (let i = index - 1; i >= 0; i--) {
          if (!this.isOptionDisabled(this.options[i])) return i;
      }
  }

  isOptionDisabled(option) {
      return this.isObject(option) && option.disabled;
  }

  isObject(val) {
      return val !== null && typeof val === 'object';
  }

  // 滑动偏移量
  getIndexByOffset(offset) {
      return this.range(Math.round(-offset / this.itemPxHeight), 0, this.count - 1);
  }

  // 阻止默认行为
  preventDefault(event, isStopPropagation) {
      /* istanbul ignore else */
      if (typeof event.cancelable !== 'boolean' || event.cancelable) {
          event.preventDefault();
      }

      if (isStopPropagation) {
          this.stopPropagation(event);
      }
  }

  stopPropagation(event) {
      event.stopPropagation();
  }

  touchMove(event) {
      const touch = event.touches[0];
      this.deltaX = touch.clientX - this.startX;
      this.deltaY = touch.clientY - this.startY;
      this.offsetX = Math.abs(this.deltaX);
      this.offsetY = Math.abs(this.deltaY);
      this.direction = this.direction || this.getDirection(this.offsetX, this.offsetY);
  }

  // 确定滑动方向
  getDirection(x, y) {
      if (x > y && x > this.MIN_DISTANCE) {
          return 'horizontal';
      }

      if (y > x && y > this.MIN_DISTANCE) {
          return 'vertical';
      }

      return '';
  }

  // 滑动范围限制--关键代码
  range(num, min, max) {
      return Math.min(Math.max(num, min), max);
  }
}

new Picker()

以上代码是滑动picker的基础版,可在此基础之上结合框架,根据需求进行扩展