mint-ui swipe组件源码解析

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/u010014658/article/details/73605167

前叙


mint-ui组件库中swipe组件,实现的是常见的轮播图效果。但是它的实现方式,和常见的实现有所不同。
常见的实现方式: 通过移动轮播图的wrapper来实现item的切换效果(也就是修改wrapper的translate3d属性来实现)。如果支持循环播放,需要在首部插入一个最后一个轮播图item的clone版,以及在尾部插入一个第一个轮播图item的clone版。
swipe组件实现的方式: 只显示当前显示的轮播图item,当切换的时候,显示出当前item的前后相邻的两个item;通过设置三个item的translate3d来实现切换的效果。
两个实现方式的对比:

  • 第一种方式,初始会渲染出所有的item,通过translate3d来实现切换和滑动,这种方式会启动硬件加速提升性能。但是毕竟是在所有轮播图的基础上的渲染。
  • 第二种方式,通过切换item的display属性来实现对应item的显示和隐藏,虽然会引起回流和重绘,但是每个item的position为absolute,脱离文档流,所以并不会引起其他dom的回流和重绘。每个item的translate3d引发的渲染只是在当前item的基础上。
  • 通过上面分析,可以得出: 如果轮播图的数量不多,第一种方式不会引起回流和重绘,并且translate引发渲染的item不多,性能相对好;但是轮播图的数量比较多的话,第二种性能相对比较好。

swipe接入示例


  • html代码

    <div id="app">
        <div class="swipe-wrapper">
            <mt-swipe :auto="0" ref="swipeWrapper">
                <mt-swipe-item class="swip-item-1 item">1</mt-swipe-item>
                <mt-swipe-item class="swip-item-2 item">2</mt-swipe-item>
                <mt-swipe-item class="swip-item-3 item">3</mt-swipe-item>
            </mt-swipe>
        </div>
    
        <div class="button-wrapper">
            <button class="prev-button flex-item" @click="prev">prev</button>
            <button class="next-button flex-item" @click="next">next</button>
        </div>
    </div>
  • css代码

    <!-- 引入组件库css -->
    <link rel="stylesheet" href="../css/mint-style.css">
    <style>
        html,body{
            width: 100%;
            height: 100%;
            margin: 0;
        }
        #app{
            width: 100%;
            height: 100%;
        }
        .swipe-wrapper{
            width: 100%;
            height: 300px;
        }
        .swip-item-1{
            background: red;
        }
        .swip-item-2{
            background: blue;
        }
        .swip-item-3{
            background: green;
        }
        .item{
            text-align: center;
            font-size: 40px;
            color: white;
        }
    
        .button-wrapper{
            display: flex;
            height: 100px;
        }
        .flex-item{
            flex: 1;
            display: inline-block;
            text-align: center;
            height: 100%;
            line-height: 100%;
            font-size: 40px;
        }
        .prev-button{
            background: darkorange;
        }
        .next-button{
            background: green;
        }
    
    </style>
  • js代码

    <!-- 先引入 Vue -->
    <script src="../js/vue.js"></script>
    <!-- 引入组件库 -->
    <script src="../js/index.js"></script>
    <script>
        new Vue({
            el: '#app',
            methods: {
                prev: function () {
                    this.$refs.swipeWrapper.prev();
                    console.log(this.$children);
                },
                next: function () {
                    this.$refs.swipeWrapper.next();
                }
            }
        });
    </script>

原理解析


  • 初始只显示选中index的item,将其他item都隐藏
  • 当拖动开始的时候,显示当前index的相邻两个item
  • 当拖动的时候,计算出手指滑动的距离,通过设置当前item和其相关两个item的translate3d来改变他们的位置的方式,来实现切换的效果
  • 自动播放:通过设置定时器,触发上面拖动相同的切换代码,来实现切换。

源码解析


  • 首先看子组件swipe-item组件,代码很简单,如下:

    <template>
      <div class="mint-swipe-item">
        <slot></slot>
      </div>
    </template>
    
    <script>
      export default {
        name: 'mt-swipe-item',
    
        mounted() { // 页面显示的时候触发
          this.$parent && this.$parent.swipeItemCreated(this); // 内部实现:调用父组件的reinit()业务
        },
    
        destroyed() { // item隐藏的时候触发
          this.$parent && this.$parent.swipeItemDestroyed(this); // 内部实现同上
        }
      };
    </script>

    上面的代码很简单,mounted和destoryed都是调用的父组件的实现。实现如下:

      swipeItemCreated() {
        if (!this.ready) return;
    
        clearTimeout(this.reInitTimer);
        this.reInitTimer = setTimeout(() => {
          this.reInitPages();
        }, 100);
      },
    
      swipeItemDestroyed() {
        if (!this.ready) return;
    
        clearTimeout(this.reInitTimer);
        this.reInitTimer = setTimeout(() => {
          this.reInitPages();
        }, 100);
      },
  • 父组件swipe的props
    这些数据都是允许外部传入的,这是数据的含义说明可以参考mint-ui的官网的说明mint-ui swipe组件

  • swipe组件的data说明

    data() {
      return {
        ready: false, // 当前组件是否 mounted
        dragging: false, // 当前是否正在拖动
        userScrolling: false, // 判定当前用户在上下滚动,就不执行drag动作
        animating: false, // 当前是否在执行动画(也就是自动切换页面)
        index: 0, // 当前所在的item的index
        pages: [], // 存储当前child 的dom
        timer: null, // 自动播放的定时器 timerid
        reInitTimer: null, // item组件触发reInit触发 定时器id
        noDrag: false, // 存储是否运行拖动的标识
        isDone: false // 当前动画是否执行完成
      };
    }

    上面的注释都是我通过分析源码得出的,有了说明,下面看代码就更容易了。

  • swipe组件的入口函数mounted回调的实现

    mounted() {
      this.ready = true;
    
      this.initTimer();// 初始化自动播放的timer
    
      this.reInitPages(); // 初始化drag状态, 以及dom节点的样式信息
    
      var element = this.$el;
      // 为当前组件的dom节点 注册touch时间
      element.addEventListener('touchstart', (event) => {
        if (this.prevent) event.preventDefault();
        if (this.stopPropagation) event.stopPropagation();
        if (this.animating) return; // 如果当前在执行移动动画, 直接返回
        this.dragging = true; // 设置dragging状态标识
        this.userScrolling = false; // 重置
        this.doOnTouchStart(event);
      });
    
      element.addEventListener('touchmove', (event) => {
        if (!this.dragging) return;
        if (this.timer) this.clearTimer(); // 将当前自动播放停止
        this.doOnTouchMove(event);
      });
    
      element.addEventListener('touchend', (event) => {
        if (this.userScrolling) { // 纵向滚动,重置状态并返回
          this.dragging = false;
          this.dragState = {};
          return;
        }
        if (!this.dragging) return;
        this.initTimer(); // 启动自动播放定时器
        this.doOnTouchEnd(event);
        this.dragging = false; // 重置拖动状态
      });
    }

    关于初始化自动播放的定时器的代码,最后在分析。现在来看初始化dom样式的reInitPages函数实现如下:

      reInitPages() {
        var children = this.$children;
        // 设置拖动状态
        this.noDrag = children.length === 1 && this.noDragWhenSingle; // 当前只有一个item,并且设置了只有一个不支持拖动
    
        var pages = [];
        var intDefaultIndex = Math.floor(this.defaultIndex);
        var defaultIndex = (intDefaultIndex >= 0 && intDefaultIndex < children.length) ? intDefaultIndex : 0;
        this.index = defaultIndex; // 设置当前显示的索引值
        //初始化显示样式, 将当前index的item显示出来,其他的都隐藏
        children.forEach(function(child, index) {
          pages.push(child.$el);
    
          removeClass(child.$el, 'is-active');
    
          if (index === defaultIndex) {
            addClass(child.$el, 'is-active');
          }
        });
        // 设置所有轮播图的item的dom
        this.pages = pages;
      },
  • swipe的touchstart事件回调的处理
    上面已经有了回调的代码,主要看处理的核心函数doOnTouchStart的实现如下:

    doOnTouchStart(event) { // 创建dragState, 包括touch事件的信息,当前drag item以及它前后两个item,并将其显示出来
        if (this.noDrag) return; // 不支持拖动
    
        var element = this.$el;
        var dragState = this.dragState;
        var touch = event.touches[0];
        // 设置dragstate的信息(也就是当前滑动的信息数据)
        dragState.startTime = new Date();
        dragState.startLeft = touch.pageX;
        dragState.startTop = touch.pageY;
        dragState.startTopAbsolute = touch.clientY;
    
        dragState.pageWidth = element.offsetWidth;
        dragState.pageHeight = element.offsetHeight;
    
        var prevPage = this.$children[this.index - 1];
        var dragPage = this.$children[this.index];
        var nextPage = this.$children[this.index + 1];
    
        if (this.continuous && this.pages.length > 1) { // 当前支持循环播放, 并且pages的长度大于1
          if (!prevPage) {
            prevPage = this.$children[this.$children.length - 1];
          }
          if (!nextPage) {
            nextPage = this.$children[0];
          }
        }
    
        dragState.prevPage = prevPage ? prevPage.$el : null;
        dragState.dragPage = dragPage ? dragPage.$el : null;
        dragState.nextPage = nextPage ? nextPage.$el : null;
        // 将当前index下的前后两个item显示出来
        if (dragState.prevPage) {
          dragState.prevPage.style.display = 'block';
        }
    
        if (dragState.nextPage) {
          dragState.nextPage.style.display = 'block';
        }
      }

    获取当前touchstart状态下面的拖动的状态信息(包括touch的信息,页面宽高,prev、current、next三个item的dom)。同时将prev、next显示出来。

  • touchmove事件回调的处理

    doOnTouchMove(event) {
        if (this.noDrag) return;
    
        var dragState = this.dragState;
        var touch = event.touches[0];
    
        dragState.currentLeft = touch.pageX;
        dragState.currentTop = touch.pageY;
        dragState.currentTopAbsolute = touch.clientY;
        //计算滑动的距离
        var offsetLeft = dragState.currentLeft - dragState.startLeft;
        var offsetTop = dragState.currentTopAbsolute - dragState.startTopAbsolute;
    
        var distanceX = Math.abs(offsetLeft);
        var distanceY = Math.abs(offsetTop);
        // 判断是 竖向滚动,还是横向滚动
        if (distanceX < 5 || (distanceX >= 5 && distanceY >= 1.73 * distanceX)) {
          this.userScrolling = true; // 判定当前用户在上下滚动,就不执行drag动作
          return;
        } else {
          this.userScrolling = false;
          event.preventDefault(); // 阻止默认事件的触发,也就是点击事件的触发
        }
        // 设置最大的拖拽距离在当前dom里面
        offsetLeft = Math.min(Math.max(-dragState.pageWidth + 1, offsetLeft), dragState.pageWidth - 1);
    
        var towards = offsetLeft < 0 ? 'next' : 'prev'; // 拖动的方向的确定
        //prev方向: prev dom移动到指定的位置
        if (dragState.prevPage && towards === 'prev') {
          this.translate(dragState.prevPage, offsetLeft - dragState.pageWidth);
        }
        // current dom移动到指定的位置
        this.translate(dragState.dragPage, offsetLeft);
        // next方向: next dom 移动到指定的位置
        if (dragState.nextPage && towards === 'next') {
          this.translate(dragState.nextPage, offsetLeft + dragState.pageWidth);
        }
      }

    主要确定当前滚动不是竖向滚动,并确定滚动的方向以确定移动prev还是next。
    下面看translate移动dom的核心函数实现:

    /**
       * @param element 要移动的dom节点
       * @param offset // dom移动的距离
       * @param speed 如果传递, 执行动画的移动; 没有,则直接translate执行的距离
       * @param callback 处理完成的回调函数
       */
      translate(element, offset, speed, callback) {
        if (speed) {
          this.animating = true; // 当前正在执行动画,此时不能拖拽
          element.style.webkitTransition = '-webkit-transform ' + speed + 'ms ease-in-out'; // transition过渡状态
          setTimeout(() => {
            element.style.webkitTransform = `translate3d(${offset}px, 0, 0)`;
          }, 50);
    
          var called = false;
    
          var transitionEndCallback = () => {
            if (called) return;
            called = true;
            this.animating = false; // 停止动画
            element.style.webkitTransition = '';
            element.style.webkitTransform = '';
            if (callback) {
              callback.apply(this, arguments); // 调用回调
            }
          };
    
          once(element, 'webkitTransitionEnd', transitionEndCallback); // 此事件只执行一次
          // 防止低版本android, 无法触发此事件
          setTimeout(transitionEndCallback, speed + 100); // webkitTransitionEnd maybe not fire on lower version android.
        } else {
          element.style.webkitTransition = '';
          element.style.webkitTransform = `translate3d(${offset}px, 0, 0)`;
        }
      }

    如果设置了speed,就会执行平滑的动画切换(speed是动画执行的时间);如果没有设置,直接移动到指定的位置,没有过渡效果。

  • touchend事件回调的实现
    分析其中核心代码doTouchEnd函数:

    doOnTouchEnd() {
        if (this.noDrag) return;
    
        var dragState = this.dragState;
    
        var dragDuration = new Date() - dragState.startTime;
        var towards = null; // 决定下面进入哪个页面, null: 当前页面, prev: 前一个页面, next: 下一个页面
    
        var offsetLeft = dragState.currentLeft - dragState.startLeft;
        var offsetTop = dragState.currentTop - dragState.startTop;
        var pageWidth = dragState.pageWidth;
        var index = this.index;
        var pageCount = this.pages.length;
    
        // 判断当前是否是 tap事件(轻触事件)
        if (dragDuration < 300) {
          let fireTap = Math.abs(offsetLeft) < 5 && Math.abs(offsetTop) < 5;
          if (isNaN(offsetLeft) || isNaN(offsetTop)) {
            fireTap = true;
          }
          if (fireTap) {
            this.$children[this.index].$emit('tap'); // 当前轮播图item发送给外部的tab事件
          }
        }
        // 触发时长小于300ms,并且没有执行touchmove事件, 不处理
        if (dragDuration < 300 && dragState.currentLeft === undefined) return;
    
        if (dragDuration < 300 || Math.abs(offsetLeft) > pageWidth / 2) {
          towards = offsetLeft < 0 ? 'next' : 'prev';
        }
    
        if (!this.continuous) { // 当前不支持循环, 向前或向后 都回到当前页面
          if ((index === 0 && towards === 'prev') || (index === pageCount - 1 && towards === 'next')) {
            towards = null;
          }
        }
    
        if (this.$children.length < 2) {
          towards = null;
        }
        // 动画的方式切换到指定的item
        this.doAnimate(towards, {
          offsetLeft: offsetLeft,
          pageWidth: dragState.pageWidth,
          prevPage: dragState.prevPage,
          currentPage: dragState.dragPage,
          nextPage: dragState.nextPage
        });
    
        this.dragState = {};// 清空dragState
      }

    判断当前是否是tap事件,并且确定下面要切换到哪个item。
    下面来看doAnimate动画的方式切换的实现:

    doAnimate(towards, options) {
        if (this.$children.length === 0) return;
        if (!options && this.$children.length < 2) return;
    
        var prevPage, nextPage, currentPage, pageWidth, offsetLeft;
        var speed = this.speed || 300;
        var index = this.index;
        var pages = this.pages;
        var pageCount = pages.length;
    
        if (!options) { // 没有options,是 自动播放或手动触发切换页面的处理
          pageWidth = this.$el.clientWidth;
          currentPage = pages[index];
          prevPage = pages[index - 1];
          nextPage = pages[index + 1];
          if (this.continuous && pages.length > 1) {
            if (!prevPage) {
              prevPage = pages[pages.length - 1];
            }
            if (!nextPage) {
              nextPage = pages[0];
            }
          }
          // 将 prevPage 和 nextPage 定位到应该的位置(也就是开始执行切换页面的位置)
          if (prevPage) {
            prevPage.style.display = 'block'; // 显示出来
            this.translate(prevPage, -pageWidth); // 移到当前index的前面
          }
          if (nextPage) {
            nextPage.style.display = 'block';
            this.translate(nextPage, pageWidth); // 移到当前index的后面
          }
        } else {
          prevPage = options.prevPage;
          currentPage = options.currentPage;
          nextPage = options.nextPage;
          pageWidth = options.pageWidth;
          offsetLeft = options.offsetLeft;
        }
        // 确定 要切换的item的索引
        var newIndex;
    
        var oldPage = this.$children[index].$el;
    
        if (towards === 'prev') {
          if (index > 0) {
            newIndex = index - 1;
          }
          if (this.continuous && index === 0) {
            newIndex = pageCount - 1;
          }
        } else if (towards === 'next') {
          if (index < pageCount - 1) {
            newIndex = index + 1;
          }
          if (this.continuous && index === pageCount - 1) {
            newIndex = 0;
          }
        }
    
        var callback = () => { // 动画完成的回调: 重置dom的样式信息
          if (newIndex !== undefined) {
            // 重置dom的样式信息
            var newPage = this.$children[newIndex].$el;
            removeClass(oldPage, 'is-active'); // is-active 设置当前item的display:block
            addClass(newPage, 'is-active');
    
            this.index = newIndex;
          }
          if (this.isDone) { // 切换了页面,向外部发送切换页面完成的事件
            this.end();
          }
    
          // 在touchStart 时设置的style中的display清空, 也就是使用class里面的display:none隐藏属性
          if (prevPage) {
            prevPage.style.display = '';
          }
    
          if (nextPage) {
            nextPage.style.display = '';
          }
        };
    
        setTimeout(() => {
          if (towards === 'next') { // 切换到下一页
            this.isDone = true;
            this.before(currentPage); // 执行切换页面之前,向外部发送事件
            this.translate(currentPage, -pageWidth, speed, callback);
            if (nextPage) {
              this.translate(nextPage, 0, speed);
            }
          } else if (towards === 'prev') { // 切换到上一页
            this.isDone = true;
            this.before(currentPage);
            this.translate(currentPage, pageWidth, speed, callback);
            if (prevPage) {
              this.translate(prevPage, 0, speed);
            }
          } else { // 回到当前页面,不切换页面
            this.isDone = false; // 当前没有进入到前一个页面和后一个页面, 还是回到当前页面
            this.translate(currentPage, 0, speed, callback);
            if (typeof offsetLeft !== 'undefined') {
              if (prevPage && offsetLeft > 0) {
                this.translate(prevPage, pageWidth * -1, speed);
              }
              if (nextPage && offsetLeft < 0) {
                this.translate(nextPage, pageWidth, speed);
              }
            } else {
              if (prevPage) {
                this.translate(prevPage, pageWidth * -1, speed);
              }
              if (nextPage) {
                this.translate(nextPage, pageWidth, speed);
              }
            }
          }
        }, 10);
      }

    此函数代码比较,但是不难,结合上面的注释,应该很容易读懂。实现:如果没有options(手动触发切换页面),会生成options中的信息(也就是下面处理需要用到的数据),并把prev和next两个dom定位到指定的位置;然后执行切换页面的操作,并且在结束的回调中重置相应dom的样式以及当前选中的index。

  • 下面来看 手动触发的切换页面的代码

      next() { // 切换到下一个页面
        this.doAnimate('next');
      },
    
      prev() { // 切换到上一个页面
        this.doAnimate('prev');
      }
  • 最后,来看自动播放的代码:

      initTimer() {
        if (this.auto > 0) {
          this.timer = setInterval(() => {
            // 如果不支持循环播放,并且当前播放到了 末尾位置,  停止定时器
            if (!this.continuous && (this.index >= this.pages.length - 1)) {
              return this.clearTimer();
            }
            if (!this.dragging && !this.animating) { // 没有在拖动, 也没有执行动画
              this.next(); // 播放下一个item
            }
          }, this.auto);
        }
      }

    读懂了doAnimate函数了,就很简单了。

总结


  1. 了解了一种新的轮播图的实现方式
  2. 两个轮播图的实现方式的差别以及性能的比较
  3. touch事件边界条件的处理,比如tap事件的判断,横纵向滚动的判断

猜你喜欢

转载自blog.csdn.net/u010014658/article/details/73605167