手把手教学:Vue下拉刷新、上拉加载组件插件(超详细)

10,969 阅读2分钟

前言

拖了很久的下拉刷新Vue组件——终于来了,其实写了很久了,一直拖着没写文章...

上效果图

技术点

其实技术点也没什么难的,主要使用H5的touch事件:

  • touchstart: 手指触屏触发的事件,主要工作是在触发时获取鼠标点击的Y坐标,event.touches[0].pageY。
  • touchmove: 手指滑动触发的事件, 主要工作是在触发时获取移动的Y坐标减去开始时的Y坐标,得出移动的距离,然后利用transform改变容器的位置。
  • touchend: 手指松开触发的事件,主要工作是释放鼠标让div恢复原来位置。

理想使用方法

既然是一个组件,那么就要便于开发人员使用,要简单、容易。 假设我们的下拉刷新、上拉加载组件是upLoadDownRefresh:

    <upLoadDownRefresh>
      <div v-for="(item, index) in list" :key="index">
        {{ item.name }}
      </div>
    </upLoadDownRefresh>

只需要将组件往容易里面一丢即可出现效果。

实现:版本一 实现一个可拖动、且松开手指恢复原位的容器

<template>
  <div>
    <div id="scroll-container" 
      @touchstart.stop="handlerTouchStart"
      @touchmove.stop="handlerTouchMove"
      @touchend.stop="handlerTouchEnd"
      ref="scrollContainer"
    >
      <slot></slot>
    </div>
  </div>
</template>
  
<script>
export default {
  data () {
    return {
      startLocation: '', // 记录鼠标点击的位置
      moveDistance: 0,  // 记录移动的位置
      distance: '' // 记录移动的距离
    }
  },
  methods: {
    // 获取手指触屏时的屏幕Y轴位置
    handlerTouchStart (e) {
      this.startLocation = e.touches[0].pageY
    },
    // 获取手指移动的距离
    handlerTouchMove (e) {
      this.moveDistance = Math.floor(e.touches[0].pageY - this.startLocation)
      this.$refs.scrollContainer.style.transform = `translateY(${this.moveDistance}px)`
    },
    // 获取手指松开的Y轴位置
    handlerTouchEnd (e) {
      // 清除已移动的距离
      this.moveDistance = 0
      // 恢复原位
      this.$refs.scrollContainer.style.transform = 'translateY(0px)'
    }
  }
}
</script>
<style scoped>
  #scroll-container {
    background-color: yellow;
  }
</style>

主要通过以利用touchstart事件得出容器移动的Y轴距离,然后在touchmove事件中算出手指移动的Y轴距离并且通过设置transform: translateY()进行移动,最后在touchen事件中恢复原始位置transform: translateY(0px)。

延迟恢复 效果图

通过上面的效果图可以看出在松开手指恢复位置的过程是一瞬间恢复的,给用户效果并不友好,接下来进行改进。通过添加transition实现延迟恢复。

来,上代码:

<template>
  <div>
    <div id="scroll-container" 
	  ...
      :class="{'transition': isTransition}"
    >
      <slot></slot>
    </div>
  </div>
</template>

<script>
export default {
  data () {
    return {
	  ...
      isTransition: false // 是否启动transition
      ...
    }
  },
  methods: {
    // 获取手指触屏时的屏幕Y轴位置
    handlerTouchStart (e) {
      ...
      this.isTransition = false
      ...
    },
    // 获取手指松开的Y轴位置
    handlerTouchEnd (e) {
      ...
      this.isTransition = true // 开启transition
      ...
    }
  }
}
</script>
<style scoped>
  ...
  .transition {
    transition: all .7s; 
  }
  ...
</style>

通过添加上面的代码即可实现延迟恢复,主要是利用transition这个css样式实现,并且通过this.isTransition来判断是否需要启动该样式,因为该样式只是在松开手指时,即touchend事件的时候添加,在touchstart事件关闭。

版本二 效果图:

拖拽——恢复的效果出来了,看着还不错,可是还是缺了不少东西,例如在下拉刷新以及上拉加载的时候并没有一个加载动画的动画,这样对用户体验效果是极差的,现在我们来添加这两个动画。

版本二: 添加加载、刷新动画

修改部分代码:

<template>
  <div>
    <div id="scroll-container" 
      @touchstart.stop="handlerTouchStart"
      @touchmove.stop="handlerTouchMove"
      @touchend.stop="handlerTouchEnd"
      ref="scrollContainer"
      :class="{'transition': isTransition}"
    >
      <!-- 添加刷新图片 -->
      <div class="refresh">
        <img 
          src="https://www.easyicon.net/api/resizeApi.php?id=1190769&size=48" 
        >
      </div>
      <slot></slot>
      <!-- 添加加载图片 -->
      <div class="load">
        <img src="https://img.lanrentuku.com/img/allimg/1212/5-121204193R5-50.gif">
      </div>
    </div>
  </div>
</template>
  
<script>
export default {
  data () {
    return {
      startLocation: '', // 记录鼠标点击的位置
      moveDistance: 0,  // 记录移动的位置
      distance: '', // 记录移动的距离
      isTransition: false // 是否启动transition
    }
  },
  methods: {
    // 获取手指触屏时的屏幕Y轴位置
    handlerTouchStart (e) {
      this.isTransition = false
      this.startLocation = e.touches[0].pageY
    },
    // 获取手指移动的距离
    handlerTouchMove (e) {
      this.moveDistance = Math.floor(e.touches[0].pageY - this.startLocation)
      this.$refs.scrollContainer.style.transform = `translateY(${this.moveDistance}px)`
    },
    // 获取手指松开的Y轴位置
    handlerTouchEnd (e) {
      this.moveDistance = 0 // 清除已移动的距离
      this.isTransition = true // 开启transition
      this.$refs.scrollContainer.style.transform = 'translateY(0px)'
    }
  }
}
</script>
<style scoped>
  #scroll-container {
    background-color: yellow;
  }
  .transition {
    transition: all .7s; 
  }
  /* -----添加新样式------ */
  .load, .refresh {
    text-align: center;
  }
  .load img, .refresh img {
    width: 20px;
  }
  /* -----添加新样式------ */
</style>

效果图:

目前箭头加载都是静态的,如今有这样的需求,让上方的箭头的箭头在我下拉的时候指向向下,在我松开手指的时候箭头指向向上,在恢复到原位的时候,箭头变为加载。且下拉刷新上拉加载是在拖动一定距离的时候触发。

HTML代码修改:

<template>
  <div>
    <div id="scroll-container" 
      @touchstart.stop="handlerTouchStart"
      @touchmove.stop="handlerTouchMove"
      @touchend.stop="handlerTouchEnd"
      ref="scrollContainer"
      :class="{'transition': isTransition}"
    >
      <!-- 根据isDisplay.refresh 动态隐藏显示 -->
      <div :class="['refresh', {'display': isDisplay.refresh}]">
        <!-- 添加isShrnked 加载 和 箭头相互转换 -->
        <!-- 添加rotate类 反转箭头 下箭头和上箭头互相转换 -->
        <img
          :src="isShrinked? loadImg : refreshImg" 
          :class="{'rotate': isRotate}"
        >
      </div>
      <slot></slot>
      <!-- 根据isDisplay.load 动态隐藏显示 -->
      <div :class="['load', {display: isDisplay.load}]">
        <img :src="loadImg">
      </div>
    </div>
  </div>
</template>

↑箭头和↓箭头是通过transform: rotate(180deg)进行转换的,此处把加载图片和刷新图片地址利用变量存了起来,方便动态切换。

JS代码修改:

<script>
// 拖拽状态 true:下拉  false:上拉
let SCROLLSTATUS
export default {
  props: {
    // 能够拖拽的最大距离
    maxDistance: {
      style: Number,
      default: 300
    },
    // 定义触发加载刷新事件的拉伸长度
    triggerDistance: {
      style: Number,
      default: 100
    }
  },
  data () {
    return {
      startLocation: '', // 记录鼠标点击的位置
      moveDistance: 0,  // 记录移动的位置
      distance: '', // 记录移动的距离
      isTransition: false, // 是否启动transition
      isDisplay: {
        refresh: true,
        load: true
      },
      // 把图片地址抽离出来 方便动态切换
      loadImg: 'https://img.lanrentuku.com/img/allimg/1212/5-121204193R5-50.gif',
      refreshImg: 'https://www.easyicon.net/api/resizeApi.php?id=1190769&size=48',
      isRotate: false, // 是否选择箭头
      isShrinked: false // 是否收缩完成
    }
  },
  methods: {
    // 获取手指触屏时的屏幕Y轴位置
    handlerTouchStart (e) {
      this.isTransition = false
      this.startLocation = e.touches[0].pageY
      // 重置箭头反转
      this.isRotate = false
      // 重置箭头
      this.isShrinked = false
    },
    // 获取手指移动的距离
    handlerTouchMove (e) {
      if (this.moveDistance > this.maxDistance + 1) {
        this.isRotate = true
        return
      }
      this.moveDistance = Math.floor(e.touches[0].pageY - this.startLocation)
      this.$refs.scrollContainer.style.transform = `translateY(${this.moveDistance}px)`
      // 显示加载 刷新图片
      if (this.moveDistance > this.triggerDistance && this.isDisplay.refresh) {
        this.isDisplay.refresh = false
      } else if (this.moveDistance < -this.triggerDistance && this.isDisplay.load) {
        this.isDisplay.load = false
      }
    },
    // 获取手指松开的Y轴位置
    handlerTouchEnd (e) {
      // 记录拖拽状态是为上拉还是下拉
      SCROLLSTATUS = this.moveDistance > 0
      this.isTransition = true // 开启transition
      this.$refs.scrollContainer.style.transform = 'translateY(0px)'
      if (Math.abs(this.moveDistance) < this.triggerDistance) return (this.moveDistance = 0)
      this.moveDistance = 0 // 清除已移动的距离
      // 拖拽距离是否大于指定的触发长度
      // 容器位置恢复后触发
      setTimeout(() => {
        this.shrinked()
      }, 700)
    },
    // 容器恢复后的操作
    shrinked () {
      if (SCROLLSTATUS) {
        // 下拉刷新业务逻辑
        // 已经恢复完,箭头转为加载
        this.isShrinked = true
        // 隐藏刷新、加载
        this.isDisplay.refresh = true
        this.isDisplay.load = true
        alert('这是下拉操作')
      } else {
        // 上拉加载业务逻辑
        alert('这是上拉操作')
      }
    }
  }
}
</script>

这次代码修改的信息量较大,需要仔细阅读...增加了两个props属性:maxDistancetriggerDistance

  • maxDistance:该变量是能拖拽的最长距离
  • triggerDistance:该变量是触发加载、刷新的距离。

效果图

到这这里,就只剩下最后一步了,那就是抛出刷新、加载的事件给外部组件即可,那何时才需要抛事件呢?

我定义了一个props.triggerDistance属性,只有拖动的距离大于该值时才会触发刷新、加载,所以我们在@touchmove事件中抛出事件给外部组件使用,但是只要鼠标移动就会触发@touchmove事件,总不能不断的触发Vue.$emit()的,这样太影响性能了。

解决方法是用一个数组存刷新和加载的emit()方法,最后在@touchend中拿出来执行,这样就只会执行了一次emit()方法,最后在`@touchend`中拿出来执行,这样就只会执行了一次`emit()`方法了。

版本三 添加刷新、加载事件

修改JS:

<script>
// 拖拽状态 true:下拉  false:上拉
let SCROLLSTATUS
export default {
  props: {
	...
  },
  data () {
    return {
      ...
      // 添加emit缓存数组,并以undefined填充
      emitEvents: new Array(2).fill(undefined)
      ...
    }
  },
  methods: {
    // 获取手指移动的距离
    handlerTouchMove (e) {
      if (this.moveDistance > this.maxDistance + 1) {
        this.isRotate = true
        return
      }
      this.moveDistance = Math.floor(e.touches[0].pageY - this.startLocation)
      this.$refs.scrollContainer.style.transform = `translateY(${this.moveDistance}px)`
      // 显示加载 刷新图片
      if (this.moveDistance > this.triggerDistance && this.isDisplay.refresh) {
        this.isDisplay.refresh = false
      } else if (this.moveDistance < -this.triggerDistance && this.isDisplay.load) {
        this.isDisplay.load = false
      }
      // 缓存刷新的emit
      if (this.moveDistance > this.triggerDistance && !this.emitEvents[0]) {
      	this.emitEvents[0] = function () { this.$emit('refresh', this.displayDiv) }
      }
      // 缓存加载的emit
      if (this.moveDistance < -this.triggerDistance && !this.emitEvents[1]) {
      	this.emitEvents[1] = function () { this.$emit('load', this.displayDiv) }
      }
    },
    // 获取手指松开的Y轴位置
    handlerTouchEnd (e) {
      // 记录拖拽状态是为上拉还是下拉
      SCROLLSTATUS = this.moveDistance > 0
      this.isTransition = true // 开启transition
      this.$refs.scrollContainer.style.transform = 'translateY(0px)'
      if (Math.abs(this.moveDistance) < this.triggerDistance) return (this.moveDistance = 0)
      this.moveDistance = 0 // 清除已移动的距离
      // 拖拽距离是否大于指定的触发长度
      // 容器位置恢复后触发
      setTimeout(() => {
        this.shrinked()
      }, 700)
      // 遍历emit并执行
      this.emitEvents.forEach((fn, index) => {
        if (!fn) return
        this.emitEvents[index] = undefined
        fn.apply(this)
      })
    },
    // 容器恢复后的操作
    shrinked () {
      if (SCROLLSTATUS) {
        // 下拉恢复完,箭头转为加载
        this.isShrinked = true
      } else {
        // 上拉回复完
      }
    },
    // 该方法通过$emit()传给外部组件调用 然后隐藏刷新、加载的gif图片
    displayDiv () {
      this.isDisplay.refresh = true
      this.isDisplay.load = true
    }
  }
}
</script>

这里代码主要是对拖拽的长度进行校验,让长度大于this.triggerDistance则进行刷新、加载操作,并且每次校验的时候都会去判断this.emitEvents数组中是否已存在emit()的方法,若存在则跳过,不存在则保存,最后在@touchend中把遍历this.emitEvents。其中带调用$emit方法时,把隐藏gif的方法传递了过去供外部组件调用。

组件使用:

<template>
  <div>
    <refreshLoad 
      @refresh="refresh" 
      @load="load" 
      :maxDistance="300"
      :triggerDistance="100"
     >
      <div v-for="(item, index) in list" :key="index">
        {{ item.name }}
      </div>
    </refreshLoad>
  </div>
</template>
  
<script>
import refreshLoad from './refreshLoading'
export default {
  data () {
    return {
      timer: '',
      list: [
        {
          name: '张三'
        },
        {
          name: '李四'
        },
        {
          name: '王五'
        },
        {
          name: '赵六'
        }
      ],
      date: '',
      show: false
    }
  },
  components: {
    refreshLoad
  },
  methods: {
    refresh (done) {
      console.log(done)
      if (this.timer) clearTimeout(this.timer)
      this.timer = setTimeout(() => {
        console.log('refresh')
        done()
      }, 2000)
    },
    load (done) {
      if (this.timer) clearTimeout(this.timer)
      this.timer = setTimeout(() => {
        done()
        this.list = this.list.concat([{
          name: '新增的' + Math.ceil(Math.random() * 10)
        }, {
          name: '新增的' + Math.ceil(Math.random() * 10)
        }, {
          name: '新增的' + Math.ceil(Math.random() * 10)
        }])
      }, 1000)
    }
  }
}
</script>

这里比较留意的是需要在@refresh@load方法中接受参数done,这里的done就是组件里的displayDiv方法,用户隐藏加载、刷新的gif图片。

最终效果图

待改进

没有进行移动端适配,得根据个人开发的项目自行跳转组件中的像素大小。

没有解耦下拉和上拉功能,两者混在一起了,感兴趣的小伙伴可以自行改造哈。

已上传npm

改组件我已经上传至npm,可以直接安装使用

  • 安装:

npm i -s refresh-load-plugin

  • 使用:
import upLoadDownRefresh from 'refresh-load-plugin'
import 'refresh-load-plugin/lib/refresh-load-plugin.css'
Vue.use(upLoadDownRefresh)
<template>
  <upLoadDownRefresh 
    @refresh="refresh" 
    @load="load" 
    :maxDistance="300"
    :triggerDistance="100"
   >
    <div v-for="(item, index) in list" :key="index">
      {{ item.name }}
    </div>
  </upLoadDownRefresh>
</template>