最简单的picker选择器实现

552 阅读3分钟

先看效果图

picker-v2.0.png

1.x版本在这篇文章有介绍

2.0版本 picker 选择器代码如下:

大致思路:

  1. touchmove计算出手指滑动时的瞬时速度, 因为这块move更新频率很快,所以,位移足够小时间足够短,可以大致理解这里的速度就是瞬时速度velocity = deltaY / (now - lastTime)
  2. picker选择器,滚动停止时,必须与滚动项对齐,不能有偏差。 为了达到这个目的。 我需要提前算出总的位移量和时间。
  3. 根据这个速度velocity,计算出总的位移和总时间,具体实现看calcTotalDisAndTimecalcTotalDisAndTime在每次循环时,速度都会在原来的基础上衰减deceleration = 0.03, 即每次循环都在上一次速度上衰减3%
  4. 我们知道requestAnimationFrame每次执行时间大约是16.67ms。 有这样一个公式:1000ms/fps = 一帧多用时间; 大多数浏览器的帧数fps = 60帧; 即一秒60帧,一秒中页面60个帧数刷新页面。
  5. 这样我就知道每次循环(每帧)位移量:velocity * 16.67ms = 每帧的位移量; 这样就大概统计出了所有时间和位移。
  6. stepSize步长就是滚动项的高度。
  7. 写一个惯性滚动函数:startInertiaScroll; 这里需要将最终滚动停止时,位移量正好对齐滚动项,因此需要4舍5入,取步长stepSize整数倍:具体代码 moveY = Math.round((oldCurrentY + moveY) / stepSize) * stepSize;
  8. 最后编写一个过渡函数initTranstion并利用easing缓动函数, 来过渡每一帧的位移。具体代码initTranstion
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>最简单的 picker 选择器</title>
  <style>
    html, body, ul, li {
      margin: 0;
      padding: 0;
    }
    ul {
      list-style: none; /* 移除默认的列表样式 */
      padding: 10px;
    }
    ul li {
      height: 40px;
      margin-bottom: 10px;
      background-color: #ffffff;
      border-radius: 6px;
      box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.1);
      text-align: center;
      font-size: 18px;
      line-height: 40px;
    }
    .scrollable-content{
      position: relative;
      user-select: none;
      height: 600px;
      box-sizing: border-box;
      overflow: hidden;
    }
    .scrollable-content:before {
      content: '';
      display: block;
      width: 100%;
      height: 250px;
      background-image: linear-gradient(to top,
          rgba(255, 255, 255, 0.8) 0%,
            rgba(255, 255, 255, 0.6) 33%,
            rgba(255, 255, 255, 0.9) 66%,
            rgba(255, 255, 255, 1) 100%);
      border-bottom: 0.5px solid #b7b7b7;
      position: absolute;
      top:0;
      z-index: 99;
    }
    .scrollable-content::after{
      content: '';
      display: block;
      width: 100%;
      height: 290px;
      background-image: linear-gradient(to bottom,
            rgba(255, 255, 255, 0.8) 0%,
            rgba(255, 255, 255, 0.6) 33%,
            rgba(255, 255, 255, 0.9) 66%,
            rgba(255, 255, 255, 1) 100%);
      border-top: 0.5px solid #b7b7b7;
      position: absolute;
      bottom: 0;
      z-index: 99;
    }
  </style>
</head>
<body>
  <div class="scrollable-content">
    <ul class="viewport"></ul>
  </div>
  <script>
    let startY = 0;  // 滑动起始位置
    let disY = 0;  // 当前滑动位置
    let velocity = 0;  // 滑动速度
    let isMoving = false;  // 是否在滑动
    let lastTime = 0;  // 上一次移动的时间
    let deceleration = 0.03;  // 减速度,每帧速度减少为上次速度的10%
    let currentY = disY;  // 当前滚动位置
    let direction = 0;  // 滑动方向,1表示向下,-1表示向上
    let transition = null;  // 动画过渡
    const stepSize = 50; // 步长大小
    const content = document.querySelector('.scrollable-content'); // 可滚动内容
    const viewport = document.querySelector('.viewport'); // 可视区域
    const contentHeight = content.offsetHeight; // 获取内容的总高度
    const viewportHeight = viewport.offsetHeight;; // 获取可视区域的高度
    const maxScrollY = viewportHeight - contentHeight; // 计算最大滚动距离

    let lis = '';
    for(let i = 0; i < 100; i++) {
      lis += `<li>${i+1}</li>`
    }
    viewport.innerHTML = lis;

    document.addEventListener('touchstart', (event) => {
      event.preventDefault(); // 阻止默认的触摸滚动
      transition && transition.stop(); // 停止之前的动画
      startY = event.touches[0].clientY;
      disY = 0;
      velocity = 0;
      isMoving = true;
      lastTime = Date.now();
    }, { passive: false });

    document.addEventListener('touchmove', (event) => {
      event.preventDefault(); // 阻止默认的触摸滚动
      if (!isMoving) return;
      const now = Date.now();
      const deltaY = event.touches[0].clientY - startY;
      disY += deltaY;
      // 1 向下滚动, -1 向上滚动
      direction = Math.abs(disY)/disY;
      velocity = deltaY / (now - lastTime);  // 计算当前速度
      // 限制最大速度
      if(Math.abs(velocity) > 2.5) {
        velocity = 2.5 * direction;
      }
      // 实时更新页面位置
      updatePagePosition(currentY + disY);
      startY = event.touches[0].clientY;
      lastTime = now;
    }, { passive: false });

    document.addEventListener('touchend', (event) => {
      isMoving = false;
      currentY = currentY + disY;
      startInertiaScroll();
    });

    // 根据初速度,计算总位移和所有时间
    function calcTotalDisAndTime(velocity) {
      let duration = 0;
      let moveY = 0;
      const frameTime = 16.67;
      const lastTime = Date.now();
      console.time('calcTotalDisAndTime');
      while(Math.abs(velocity) >= 0.01) {
        velocity *= (1 - deceleration);
        moveY += velocity * frameTime;
        duration += frameTime;
      }
      console.timeEnd('calcTotalDisAndTime');
      return { duration, moveY };
    }

    function startInertiaScroll() {
      let { duration, moveY } = calcTotalDisAndTime(velocity);
      const oldCurrentY = currentY;
      moveY = Math.round((oldCurrentY + moveY) / stepSize) * stepSize;
      transition = initTranstion(Math.max(duration, 300), easeOutQuad);
      transition.framer((percent) => {
        // 实时y轴位置
        currentY = oldCurrentY + (moveY - oldCurrentY) * percent;
        updatePagePosition(currentY)
      })
      // 过渡结束的回调
      // .end(() => {})
      .start();
    }

    function updatePagePosition(y, duration = 0) {
      const element = document.querySelector('.viewport');
      element.style.transform = `translate3d(0, ${y}px, 0)`;
    }

    function initTranstion(duration = 300, easeFunction) {
      let startTime = null;
      let timer = null;
      let framer = null;
      let transitionEnd = null;
      // 开启动画
      function startAnimated() {
        function step() {
          let time = Date.now();
          if (!startTime) startTime = time;
          let progress = (time - startTime) / duration;
          if (progress > 1) progress = 1;
          const percent = easeFunction(progress);

          // 每帧回调
          framer && framer(percent);
          
          if (progress < 1) {
            timer = requestAnimationFrame(step);
          }else {
            // 结束回调
            transitionEnd && transitionEnd();
          }
        }
        requestAnimationFrame(step);
      }
      
      // initiator
      return {
        framer(callback) {
          typeof callback == 'function' && (framer = callback);
          return this;
        },
        // 过渡j结束回调
        end(callback) {
          typeof callback == 'function' && (transitionEnd = callback);
          return this;
        },
        start() {
          startAnimated();
          return this;
        },
        stop() {
          cancelAnimationFrame(timer);
          // 停止也可以认为是过渡结束
          transitionEnd && transitionEnd();
          return this;
        }
      };
    }
    // easing 动画函数,参数x的取值范围[0~1]
    function easeOutQuad(x) {
      return 1 - (1 - x) * (1 - x);
    }
  </script>
</body>
</html>

演示效果

源码链接