滑动阻尼,惯性滚动列表,边界回弹,惯性回弹

14 阅读2分钟
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Inertia Scrolling with Bounce</title>
  <style>
    #container {
      width: 300px;
      height: 500px;
      overflow: hidden;
      border: 1px solid black;
      position: relative;
      box-sizing: border-box;
    }
    .inertia-content {
      min-height: 1000px;
      background: linear-gradient(to bottom, #ffcc00, #ff6600);
      display: grid;
      grid-gap: 10px;
      grid-template-columns: repeat(2, 1fr);
      justify-items: center;
      /* overflow-x: hidden; */
    }
    .item {
      height: 100px;
      width: 100px;
      background: white;
    }
  </style>
</head>
<body>
  <div id="container"></div>
  <script>
    class Inertia {
      options = {
        max: 20
      };
      data = {
        container: null,
        content: null,
        containerHeight: 0,
        contentHeight: 0,
        scrollableHeight: 0,
        maxDistance: () => this.data.containerHeight * 0.9,
        isDragging: false,
        startY: 0,
        scrollY: 0,
        lastMoveY: 0,
        originY: 0,
        velocity: 0,
        lastTimestamp: 0,
        animationFrame: null,
        direction: 0,
      };
      static utils = {
        getTranslateY(element) {
          const style = window.getComputedStyle(element);
          const transform = style.transform || style.webkitTransform || style.mozTransform;

          if (transform && transform !== 'none') {
            // transform值通常是 "matrix(a, b, c, d, e, f)",其中e和f是translateX和translateY的值
            const match = transform.match(/matrix\(([^,]+),[^,]+,[^,]+,([^,]+),([^,]+),([^,]+)\)/);
            if (match) {
              return parseFloat(match[4]);
            }
          }
          return 0; // 如果没有translateY值,默认返回0
        },
        // 缓动函数时,在[0,1]区间
        easeOutQuad(x) {
          return 1 - (1 - x) * (1 - x);
        },
        easeInOutCubic(x) {
          return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
        },
        easeOutQuint(x) {
          return 1 - Math.pow(1 - x, 5);
        },
        iosEase(x) {
          return Math.sqrt(1 - Math.pow(1 - 2*x, 2));
        },
        deci(number) {
          return Math.round(number * 10) / 10
        }
      };
      constructor(container, options) {
        this.data.container = document.querySelector(container);
        Object.assign(this.options, options);
        this.init();
      }
      async init() {
        this.createdInertiaContentEl();
        await this.renderContent();
        this.bindEvents();
      }
      createdInertiaContentEl() {
        this.data.container.insertAdjacentHTML('afterbegin', `<div class="inertia-content"></div>`);
        this.data.content = container.querySelector('.inertia-content');
      }
      async renderContent() {
        const { container, content } = this.data;
        const data = await this.options.rendered();
        container.querySelector('.inertia-content').innerHTML = data;
        this.data.containerHeight = container.offsetHeight;
        this.data.contentHeight = content.offsetHeight;
        this.data.scrollableHeight = this.data.contentHeight - this.data.containerHeight;
      }
      bindEvents() {
        container.addEventListener('touchstart', (e) => {
          e.preventDefault();
          const { content, animationFrame } = this.data;
          const touch = e.touches[0];
          Object.assign(this.data, {
            isDragging: true,
            startY: touch.clientY,
            scrollY: Inertia.utils.getTranslateY(content),
            originY: 0,
            velocity: 0,
            lastMoveY: touch.clientY,
            lastTimestamp: e.timeStamp,
          });
          cancelAnimationFrame(animationFrame);
        }, { passive: false });

        document.addEventListener('touchmove', (e) => {
          e.preventDefault();
          let { isDragging, content, startY, maxDistance, direction, lastTimestamp, lastMoveY, scrollY, originY, scrollableHeight } = this.data;
          const { deci, easeOutQuad } = Inertia.utils;
          const touch = e.touches[0];
          // 没有被拖拽,或者持续横向移动时(会导致速度为0)
          if (!isDragging || lastMoveY - touch.clientY == 0) return;
          // 偏移量
          const dy = touch.clientY - startY;
          // 实时滚动距离
          const y = scrollY + dy;
          // 1 向下滚动, -1 向上滚动
          direction = this.data.direction = Math.abs(dy)/dy;
          // 从哪里开始会有阻尼感的距离滑动
          originY = this.data.originY = direction == 1 ? 0 : scrollableHeight;
          // 当接触到边界时,增加阻尼感 
          if(y > 0 || (y < -scrollableHeight && direction == -1)) {
            //下拉置顶或者上拉置底时,滑动距离
            const disY = direction == 1 ? dy : Math.abs(y) - scrollableHeight;
            // 0.2是阻尼系数
            const realDisY = easeOutQuad(Math.abs(disY * 0.1) / maxDistance()) * maxDistance();
            content.style.transform = `translateY(${ direction * (originY + realDisY) }px)`;
          }else {
            content.style.transform = `translateY(${ y }px)`;
          }
          const now = e.timeStamp;
          const dt = now - lastTimestamp;
          this.data.velocity = (touch.clientY - lastMoveY) / dt * 1000;
          this.data.lastMoveY = touch.clientY;
          this.data.lastTimestamp = now;
        }, { passive: false });

        document.addEventListener('touchend', (e) => {
          e.preventDefault();
          const { lastTimestamp, content, isDragging, scrollableHeight, direction, originY } = this.data;
          const { max } = this.options;
          const { getTranslateY, easeOutQuad } = Inertia.utils;
          if (!isDragging) {return}
          this.data.isDragging = false;
          let y = this.data.scrollY = getTranslateY(content);
          // 下拉距离小于max 或者 停止时间超过300ms,则自动回弹到顶部
          if(e.timeStamp - lastTimestamp >= 300) {
            if(y > 0 || y < -scrollableHeight) {
              y < 0 && (y = Math.abs(y) - scrollableHeight);
              this.transition(400, easeOutQuad, (value) => {
                // direction * Y + y( 1 - value)
                content.style.transform = `translateY(${direction * (originY + (y - y * value)) }px)`;
              })
            }
            return;
          }
          console.log('开启惯性滚动')
          this.animateInertia();
        }, { passive: false })


      }
      animateInertia() {
        const { getTranslateY, deci, easeInOutCubic } = Inertia.utils;
        const self = this;
        const { content, scrollableHeight, direction, originY } = this.data;
        const { max } = this.options;
        function step() {
          //速度小于60就停止动画
          if(Math.abs(self.data.velocity) < 1) { 
            self.data.scrollY = getTranslateY(content)
            return
          }
          // 每一帧移动的像素数
          const deltaY = deci(self.data.velocity / 60);
          let y = getTranslateY(content);
          // 顶部回弹
          if(y > max) {
            self.transition(400, easeInOutCubic, (value) => {
              content.style.transform = `translateY(${ y - y * value }px)`;
            })
            return;
          }
          // 底部回弹
          if(y < 0 && Math.abs(y) - scrollableHeight > max) {
            y = Math.abs(y) - scrollableHeight;
            self.transition(400, easeInOutCubic, (value) => {
              console.log('originY', originY)
              content.style.transform = `translateY(${direction * (originY + (y - y * value)) }px)`;
            })
            return;
          }
          content.style.transform = `translateY(${ y + deltaY }px)`;
          self.data.velocity *= (1- 0.03);
          self.data.animationFrame = requestAnimationFrame(step);
        }
        requestAnimationFrame(step);
      }
      transition(duration = 500, easeFunction, framer) {
        let startTime = null;
        function step() {
          let time = Date.now();
          if (!startTime) startTime = time;
          let progress = (time - startTime) / duration;
          if (progress > 1) progress = 1;
          let value = easeFunction(progress);

          framer(value);

          if (progress < 1) {
            requestAnimationFrame(step);
          }
        }
        requestAnimationFrame(step);
      }

    }


    new Inertia('#container', {
      rendered() {
        return new Promise((resolve) => {
          let c = ``;
          for(let i = 0; i < 100; i++) {
            c += `<div class="item" style="${ i < 2 && 'margin-top:10px' }">${i+1}</div>`;
          }
          resolve(c);
        })
      }
    });

  </script>
</body>
</html>

滑动阻尼案例