vue 根据手势惯性滚动实现

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

 参考自:源生js惯性滚动与回弹效果

参考了上面的博客实现了惯性滚动和回弹效果,并且实现了点击列表内容时,当不完全在可视区域,上移或下移至可视区域。

在这里,需要注意的是一个this指向的问题(在mleave和ease方法中);

在匿名函数中,this指向的是windows,为了让this始终指向当前对象而不是全局对象,这里将this赋值给一个变量再在匿名函数中使用该变量;

而当在函数作为某个对象的方法调动时,此时的this指向的是调用该函数的对象。

<template>
  <div v-if="visiable">
    <div class="transparent" :class="{active:resultPanelStatus==='top'}"></div>
    <div class="mapbox-result"
         ref="resultPanel"
         style="z-index: 101;"
         :style="slideEffect"
    >
      <div class="mapbox-result-content">
        <a class="mapbox-result-close" v-if="closable" @click="close"></a>
        <div class="mapbox-result-header">
          <!--<div class="touch_line"></div>-->
          <slot name="header">
            <div class="mapbox-result-header-title">
              共找到【<div class="mapbox-result-header-poi">{{header}}</div>】相关{{total}}个结果
            </div>
          </slot>
        </div>
        <div
          class="mapbox-result-scroll-hidden"
        >
          <div
            class="mapbox-result-wrap"
            ref="resultWrap"
          >
            <div
              class="mapbox-result-body"
              id="resultBody"
              ref="resultBody"
              @touchstart="touchStart"
              @touchmove="touchMove"
              @touchend="touchEnd"
              :style="resultBodyStyle"
            >
              <result-option
                v-for="(item, index) in data"
                :index="index+1"
                :name="item.name"
                :meter="item.meter?item.meter:0"
                :floor-name="item.floorName"
                :key="index"
                v-show="visiable"
                @on-click-gohere="handleNavigate(index)"
                @on-click-item="focusResultOnMap(index)"
              ></result-option>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
<script>
  import resultOption from './resultOption';

  let offset = 50;  // 最大溢出值
  let cur = 0; // 列表滑动位置
  let isDown = false;
  let vy = 0;  // 滑动的力度
  let fl = 150;  // 弹力,值越大,到底或到顶后,可以继续拉的越远
  let isInTransition = false;  // 是否在滚动中

  export default {
    name: 'result-panel',
    components: {resultOption},
    props: {
      header: {
        type: String
      },
      // value: {
      //   type: Boolean,
      //   default: true
      // },
      closable: {
        type: Boolean,
        default: true
      },
      data: {
        type: Array,
        default: []
      }
    },
    data() {
      return {
        // visiable: true,
        resultPanelStatus: 'normal',  //'normal'、'top'
        cloneData: this.deepCopy(this.data),
        startY: 0,  // 开始触摸屏幕的点,会变动,用于滑动计算
        sY: 0,   // 开始触摸屏幕点,不会变动,用于判断是否是点击还是滑动
        endY: 0,   // 离开屏幕的点
        moveY: 0,  // 滑动时的距离
        disY: 0,  // 移动距离
        slideEffect: '',  // 滑动效果
        timer: null,
        resultBodyStyle: '',
        top: false,
        startTime: '',      // 初始点击时的时间戳
        oh: 0,   // 列表的高度
        ch: 0    // 容器的高度
      }
    },
    mounted() {
      // this.$refs.resultWrap.style.height = `${this.defaultHeight - 50}px`;
    },
    computed: {
      total() {
        return this.data.length;
      },
      defaultHeight() {
        return this.data.length > 3 ? 254 : this.data.length * 60 + 50
      },
      visiable() {
        this.resultPanelStatus = 'normal';
        this.slideEffect = `transform: translateY(-${this.defaultHeight}px); transition: all .5s`;
        return this.$store.state.resultPanel.show;
      }
    },
    methods: {

      /**
       * 根据手指来滚动,会触发click延时300ms的问题,导致关闭结果列表面板时,立即点击另一个poi结果列表,导致此时的scroll滑动到上一次的位置,且滑动时也会移到上一次滑动停止的位置
       */
      touchStart (ev) {
        ev = ev || event;
        if (isInTransition || ev.targetTouches.length > 1) return;
        // ev.preventDefault();
        this.startY = ev.targetTouches[0].clientY;       // 点击的初始位置
        this.sY = ev.targetTouches[0].clientY;       // 点击的初始位置, 点击时使用
        clearInterval(this.timer);       // 清除定时器
        vy = 0;
        this.disY = ev.targetTouches[0].clientY - cur;  // 计算点击位置与列表当前位置的差值,列表位置初始值为0
        this.startTime = ev.timeStamp;
        /**
         * overflow:hidden 导致scrollHeight和clientHeight 相等         解决:把容器高度写死, 结果列表大于3,则为204,否则内容高度即为容器高度
         */
        this.oh = this.$refs.resultWrap.scrollHeight;         // 内容的高度
        this.ch = this.total > 3 ? 204 : this.$refs.resultWrap.clientHeight;        // 容器的高度
        // console.log("this.$refs.resultWrap.style: ", this.$refs.resultWrap.style);
        isDown = true;
      },

      touchMove (ev) {
        ev = ev || event;
        if (ev.targetTouches.length > 1) return;
        if (Math.abs(ev.targetTouches[0].clientY - this.sY) < 5) return;
        if (isDown) {
          if (ev.timeStamp - this.startTime > 40) {     // 如果是慢速滑动,则不会产生力度,内容是根据手指一动的
            this.startTime = ev.timeStamp;         // 慢速滑动不会产生力度,所以需要实时更新时间戳
            cur = ev.targetTouches[0].clientY - this.disY;   // 内容位置应为手指当前位置减去手指点击时与内容位置的差值
            if (cur > 0) {  // 如果内容位置大于0, 即手指向下滑动并到顶时
              cur *= fl / (fl + cur);   // 弹力模拟公式: 位置 *= 弹力 / (弹力 + 位置)
            } else if (cur < this.ch - this.oh) {  // 如果内容位置小于容器高度减内容高度(因为需要负数,所以反过来减),即向上滑动到最底部时
              // 当列表滑动到最底部时,curPos的值其实是等于容器高度减列表高度的,假设窗口高度为10,列表为30,此时curPos为-20,但这里判断是小于,所以当curPos < -20时才会触发
              cur += this.oh - this.ch;  // 列表位置加等于列表高度减容器高度(这是与上面不同,这里是正减,得到一个正数),这里curPos为负数,加上一个正数,这里curPos为负数,加上一个正数,延用上面的假设,此时 cur = -21 + (30-10=20) = -1 ,所以这里算的是溢出数
              cur = cur * fl / (fl - cur) - this.oh + this.ch; // 然后给溢出数带入弹力,延用上面的假设,这里为   cur = -1 * 150 /(150 - -1 = 151)~= -0.99 再减去 30  等于 -30.99  再加上容器高度 -30.99+10=-20.99  ,这也是公式,要死记。。
            }
            this.setPos(cur);
          }
          vy = ev.targetTouches[0].clientY - this.startY;  // 记录本次移动后,与前一次手指位置的滑动的距离,快速滑动时才有效,慢速滑动时差值为 1 或 0,vy可以理解为滑动的力度
          this.startY = ev.targetTouches[0].clientY;  // 更新前一次位置为现在的位置,以备下一次比较
        }
        // let maxHeight = this.total < 3 ? 0 : (this.$refs.resultBody.offsetHeight - this.defaultHeight);
      },

      touchEnd (ev) {
        ev = ev || event;
        if (ev.changedTouches.length > 1) return;
        if (Math.abs(ev.changedTouches[0].clientY - this.sY) < 5) return;
        this.mleave(ev);
      },

      setPos(y) {    // 列表y轴位置,移动列表
        this.resultBodyStyle = `transform: translateY(${y}px) translateZ(0);`
      },

      ease (target) {
        isInTransition = true;
        let that = this;
        this.timer = setInterval(function () {              // 回弹算法为 当前位置 减 目标位置 取2个百分点 递减
          cur -= (cur - target) * 0.2;
          if (Math.abs(cur - target) < 1) {  // 减到当前位置与目标位置相差小于1之后直接归位
            cur = target;
            clearInterval(that.timer);
            isInTransition = false;
          }
          that.setPos(cur);
        }, 20);
      },

      mleave(ev) {
        if (isDown) {
          isDown = false;
          let friction = ((vy >> 31) * 2 + 1) * 0.5,     // 根据力度套用公式计算出惯性大小
            that = this,
            // _oh = this.$refs.resultWrap.scrollHeight - this.$refs.resultWrap.clientHeight;
            _oh = this.$refs.resultWrap.scrollHeight - (this.total > 3 ? 204 : this.$refs.resultWrap.clientHeight);
          this.timer = setInterval(function () {
            vy -= friction;  // 力度按惯性大小递减
            cur += vy;  // 转换为额外的滑动距离
            that.setPos(cur);     // 滑动列表

            if (-cur - _oh > offset) {    // 如果列表底部超出
              clearInterval(that.timer);
              that.ease(-_oh); // 回弹
              return;
            }
            if (cur > offset) {     // 如果列表顶部超出
              clearInterval(that.timer);
              that.ease(0);  // 回弹
              return;
            }
            if (Math.abs(vy) < 1) {  // 如果力度减小到小于1了,再做超出回弹
              clearInterval(that.timer);
              if (cur > 0) {
                that.ease(0);
                return;
              }
              if (-cur > _oh) {
                that.ease(-_oh);
                return;
              }
            }
          }, 20);
        }
      },

      normal() {
        this.slideEffect = `transform: translateY(${-this.defaultHeight}px); transition: all .5s;`;
        this.resultPanelStatus = 'normal';
      },

      /**
       * 点击地图上的poi点时,结果列表中相应的点置于可视区域
       */
      scrollToClickOne (_index) {
        let dis = (_index === 0 ? 0 : (_index) * 60);    // 选中的索引行乘以60,即为当索引行移动到第一行所需的上移高度
        let scrollHeight = this.$refs.resultWrap.scrollHeight;  // 列表内容高度
        let dVal = (scrollHeight - 204);      // 最多能够上移的高度
        if (0 < Math.abs(cur) && Math.abs(cur) < dVal) {
          if (dis < Math.abs(cur)) { // 下移
            cur += Math.abs(cur) - dis;
          } else if (Math.abs(cur) + 144 < dis && dis < Math.abs(cur) + 204) {  // 上移
            cur -= dis + 60 - 204 - Math.abs(cur);
          } else if (dis >= Math.abs(cur) + 204) {  // 上移 目标完全在可视范围下面
            cur -= dis + 60 - Math.abs(cur) - 204;
          }// cur = -dVal;
        } else if (Math.abs(cur) === dVal) {   // 当滑至底部时
          if (_index < (this.total - 3)) {
            cur = -_index * 60;
          }
        } else if (cur === 0) {   // 当滑至顶部时
          if (dis >= 144) {
            cur += -(dis + 60 - 204 - Math.abs(cur));
          }
        }
        this.setPos(cur);
      },

      clickItem (_index) {
        let len = this.$refs.resultBody.children.length;
        for (let i = 0; i < len; i++) {
          if (i === _index) {
            this.$refs.resultBody.children[i].style.background = "#F0F0F0";
          } else {
            this.$refs.resultBody.children[i].style.background = "white";
          }
        }
      },

      close(ev) {  // click事件会和touchestart事件冲突
        this.normal();
        this.resultBodyStyle = 'transform: translateY(0) translateZ(0);';
        cur = 0;
        this.$store.state.resultPanel.show = false;
        this.$emit('on-cancel');
      },

      handleNavigate(_index) {
        // this.$emit("on-item-click", JSON.parse(JSON.stringify(this.cloneData[_index])), _index);  //这个是获取行的元素,和索引
        this.$emit("on-click-gohere", _index);  // 这个是获取索引
      },

      /**
       * 点击结果列表,地图上的图标置于可视区域
       */
      focusResultOnMap(_index) {
        this.clickItem(_index);
        this.scrollToClickOne(_index);
        this.$emit("on-click-item", _index);  // 这个是获取索引
      },

      /**
       * 点击地图上的图标,结果列表中相应的结果置于可视区域
       */
      focusResultOnMapReverse(_index) {
        this.scrollToClickOne(_index);
        this.clickItem(_index);
      },
      // deepCopy
      deepCopy(data) {
        const t = this.typeOf(data);
        let o;

        if (t === 'array') {
          o = [];
        } else if (t === 'object') {
          o = {};
        } else {
          return data;
        }

        if (t === 'array') {
          for (let i = 0; i < data.length; i++) {
            o.push(this.deepCopy(data[i]));
          }
        } else if (t === 'object') {
          for (let i in data) {
            o[i] = this.deepCopy(data[i]);
          }
        }
        return o;
      },

      typeOf(obj) {
        const toString = Object.prototype.toString;
        const map = {
          '[object Boolean]': 'boolean',
          '[object Number]': 'number',
          '[object String]': 'string',
          '[object Function]': 'function',
          '[object Array]': 'array',
          '[object Date]': 'date',
          '[object RegExp]': 'regExp',
          '[object Undefined]': 'undefined',
          '[object Null]': 'null',
          '[object Object]': 'object'
        };
        return map[toString.call(obj)];
      }
    }
  }
</script>

<style type="text/less" scoped>
  .transparent {
    bottom: 0;
    left: 0;
    position: absolute;
    right: 0;
    top: 0;
    background-color: rgba(0, 0, 0, 0.3);
    opacity: 0;
    transition: opacity .3s;
    z-index: -1000000000;
  }

  .transparent.active {
    opacity: 1;
    z-index: 0;
  }

  .mapbox-result {
    height: calc(100% - 2.8vw);
    background: #fff;
    position: absolute;
    font-size: 12px;
    color: #4A4A4A;
    bottom: 0;
    width: 94.4vw;
    margin: 0 2.8vw;
    outline: 0;
    box-sizing: border-box;
    top: 100%;
    overflow: hidden;
    border-radius: 5px 5px 0 0;
    box-shadow: 0 0 12px 0px rgba(153, 153, 153, 0.25);
  }

  .mapbox-result-content {
    position: relative;
    background-color: #fff;
    border: 0;
  }

  .mapbox-result-header {
    /*padding: 24px 10vw;*/
    text-align: center;
    width: calc(100% - 5.6vw - 5.6vw - 32px);
    margin: 0 auto;
    overflow: hidden;
  }

  .mapbox-result-header-title {
    white-space: nowrap;
    /*line-height: 48px;*/
    line-height: 50px;
    display: inline-flex;
    margin: 0;
  }

  .mapbox-result-header-poi {
    text-overflow: ellipsis;
    overflow: hidden;
    white-space: nowrap;
    display: inline-block;
    max-width: 150px;
  }

  .mapbox-result-close {
    position: absolute;
    width: 16px;
    height: 16px;
    background-image: url('../../assets/[email protected]');
    background-size: 100% 100%;
    background-repeat: no-repeat;
    right: 5.6vw;
    top: 17px;
  }

  .mapbox-result-scroll-hidden {
    overflow: hidden;
    width: 100%;
  }

  .mapbox-result-wrap {
    /*-webkit-overflow-scrolling: touch;*/
    /*overflow-scrolling: touch;*/
    position: relative;
    overflow-y: hidden;  /* 设置overflow-y为hidden,以避免原生的scroll影响根据手势滑动计算滚动距离 */
    height: 204px;
    background: transparent;
    width: calc(100%  + 17px);
    /*解决安卓滑动页面时出现空白*/
    -webkit-backface-visibility: hidden;
    -webkit-transform: translate3d(0,0,0);
  }

  .mapbox-result-wrap::-webkit-scrollbar {
    display: none;
  }

  .mapbox-result-body {
    /*position: absolute;*/
    /*transform: translateY(0);*/
    /*transition: transform .5s;*/
    width: 100%;
  }
</style>

猜你喜欢

转载自blog.csdn.net/shelbyandfxj/article/details/83026088