使用原生js 写的picker 效果

发布时间 2023-10-31 23:37:58作者: 踏浪小鲨鱼
class Picker {
  DEFAULT_DURATION = 200;
  MIN_DISTANCE = 10;
  DEMO_DATA = [];
  // demo数据
  // 惯性滑动思路:
  // 在手指离开屏幕时,如果和上一次 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 || 3) || 3;
    // 子元素高度
    this.itemPxHeight = Number(this.itemPxHeight) || 105;
    // 初始化传入的数据列表(当前案例微用到,可结合框架使用)
    this.initOptions = options.initOptions || this.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(
      ".bo-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(".bo-mask");
    let coverBorder = document.querySelector(".bo-cover-border");
    let columnItem = document.querySelectorAll(".bo-column-item");
    mask.style.backgroundSize = this.maskStyle.backgroundSize;
    coverBorder.style.height = this.frameStyle.height;
    this.setUlStyle();
    this.setColumnHeight(columnItem);
  }

  // 滑动主要逻辑--动态设置容器的垂直方向偏移量
  setUlStyle() {
    let wrapperContainer = document.querySelector(".bo-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(".bo-picker-column");
    this.bindTouchEvent(el);
    this.bindMouseScrollEvent(el); // 添加鼠标滚轮事件
  }

  bindTouchEvent(el) {
    const { onTouchStart, onTouchMove, onTouchEnd, onTransitionEnd } = this;
    let wrapper = document.querySelector(".bo-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(".bo-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);
  }

  bindMouseScrollEvent(el) {
    let isFirefox = typeof InstallTrigger !== "undefined"; // Firefox 1.0+
    let mouseScrollEvent = isFirefox ? "DOMMouseScroll" : "mousewheel"; // 检测是否是火狐浏览器,如果是则使用"DOMMouseScroll"事件
    const handler = (event) => {
      event.preventDefault(); // 防止默认滚动行为
      let delta = isFirefox ? -event.detail : event.wheelDelta; // Firefox 和 其他浏览器滚动值是相反的,修改以保持一致性。

      // 使用滚轮的滚动值更新 currentIndex 的值,并在改变选择项后重新渲染视图
      this.currentIndex += delta > 0 ? -1 : 1;
      this.onClickItem(this.currentIndex);
      this.setUlTransform();
    };

    this.on(el, mouseScrollEvent, handler);
  }
}
new Picker()