前言
手把手教你封装一个移动端 自定义下拉刷新组件带更新时间和加载动画(PullRefresh),以uniapp vue3为代码示例。
一、实现原理
基于系统自带组件scroll-view封装,开启组件refresher-enabled属性支持自定义下拉刷新功能。下拉自定义UI容器默认状态下位于页面顶部外不可视区域,下拉时候自定义区域进入页面显示。并通过多个自定义下拉刷新回调函数refresherpulling、refresherrefresh、refresherrestore进行逻辑和UI控制。
二、组件样式和功能设计
1、如上述动图所示,下拉未达到阈值提示文字显示下拉可以刷新,到达或超过阈值显示释放立即刷新
2、释放后进入刷新状态,提示文字显示正在刷新,左边出现加载转圈动画,下拉区域卡住不动
3、数据刷新完成后设置更新时间,恢复到初始状态,再次下拉会显示上次更新时间
三、scroll-view 自定义下拉刷新使用回顾
相关属性:
属性名 | 类型 | 默认值 | 说明 |
---|---|---|---|
refresher-enabled | Boolean | false | 开启自定义下拉刷新 |
refresher-threshold | Number | 45(单位px) | 设置自定义下拉刷新阈值 |
refresher-default-style | String | “black” | 设置自定义下拉刷新默认样式,支持设置 black,white,none,none 表示不使用默认样式 |
refresher-background | String | “#FFF” | 设置自定义下拉刷新区域背景颜色 |
refresher-triggered | Boolean | false | 设置当前下拉刷新状态,true 表示下拉刷新已经被触发,false 表示下拉刷新未被触发 |
@refresherpulling | EventHandle | 自定义下拉刷新控件被下拉 | |
@refresherrefresh | EventHandle | 自定义下拉刷新被触发 | |
@refresherrestore | EventHandle | 自定义下拉刷新被复位 |
上面比较重要一个关系就是下拉松手那一刻下拉高度大于或等于refresher-threshold时(刷新阈值)会触发刷新事件@refresherrefresh
refresher-enabled用来控制下拉区域是否复位,当值从true转变为false才能复位。
分析下拉过程属性值变化和回调函数的触发时机:
1、初始状态refresher-triggered=false
2、 开始下拉@refresherpulling一直持续被触发,下拉松手那一刻当下拉距离大于或等于refresher-threshold(刷新阈值)时,@refresherrefresh 被触发一次,此时设置refresher-triggered=true,下拉区域卡住不复位进入加载中状态,数据加载完成设置refresher-triggered=false,下拉复位,触发@refresherrestore。
按上面描述我们很容易实现一个自定义下拉刷新简易版如下:
pullRefresh.vue
<template>
<view class="pull-refresh">
<scroll-view style="height: 100%;" scroll-y refresher-enabled refresher-default-style="none"
refresher-background="#ffffff" :refresher-threshold="threshold" :refresher-triggered="loading"
@refresherpulling="onRefresherpulling" @refresherrefresh="onRefresherrefresh"
@refresherrestore="onRefresherrestore" >
<view class="main">
<!-- 下拉提示内容 -->
<view class="content" :style="{top:`-${threshold}px`,height:`${threshold}px`}">
<view class="tip-view">
<view class="text">{{tipText}}</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import {ref} from 'vue'
//拉刷新阈值
const threshold=ref(80)
//是否正在刷新(加载数据)
const loading = ref(false)
//提示文字
const tipText = ref('下拉可以刷新')
//控件被下拉触发回调
const onRefresherpulling = (e) => {
console.log(e,'正在下拉')
}
//下拉刷新被触发回调
const onRefresherrefresh = (e) => {
loading.value=true;
tipText.value="正在刷新..."
//模拟接口请求数据
setTimeout(()=>{
loading.value=false
uni.showToast({
title:'刷新成功',
icon:'none'
})
},1000)
}
//下拉刷新被复位回调
const onRefresherrestore = () => {
tipText.value = "下拉可以刷新"
}
</script>
<style lang="scss" scoped>
.pull-refresh {
height: 100vh;
width: 100%;
position: relative;
}
.main {
position: relative;
}
.content {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: -80px;
height: 80px;
color: #000;
z-index: 999;
padding-bottom: 15px;
box-sizing: border-box;
.arrow {
width: 45rpx;
height: auto;
}
.tip-view {
width: 10em;
display: flex;
font-size: 22rpx;
flex-direction: column;
align-items: center;
margin-left: 20rpx;
.text {
font-size: 28rpx;
color: #666;
}
}
}
</style>
运行效果:
通过运行效果可以看出除了图标、加载动画、更新时间外,和我们设想的效果最关键的区别在于释放刷新这个状态实现,组件无提供此状态,此时需要我们自己判断定义出来。
我们打印下来下拉刷新控件被下拉回调函数的参数
//控件被下拉触发回调
const onRefresherpulling = (e) => {
console.log(e,'下拉触发')
}
小程序端:
H5或APP:
可以看到下拉过程中该回调一直被触发,小程序端内部有个dy字段而H5或APP端却变成了deltaY,dy或deltaY就是我们下拉区域的高度(单位px),当这个值大于等于下拉刷新阈值( refresher-threshold)将触发下拉刷新回调(@refresherrefresh)
所以是否达到释放立即刷新这个状态就很容易判断,也即dy或deltaY>=refresher-threshold进入释放立即刷新状态
//控件被下拉触发回调
const onRefresherpulling = (e) => {
//微信小程dy字段,H5和app deltaY字段
//#ifdef H5||APP
if (e.detail.deltaY >= threshold ) {
tipText.value = props.loosingText || "释放立即刷新"
}
// #endif
//#ifndef H5||APP
if (e.detail.dy >= threshold) {
tipText.value = props.loosingText || "释放立即刷新"
}
// #endif
else {
tipText.value = props.pullingText || "下拉可以刷新"
}
}
为了更好控制下拉区域图标和文字我们定义3中状态:
/**
* 状态:0:下拉状态 1:可释放刷新状态 2:正在刷新状态
*/
const status = ref(0)
通过不同状态来改变UI和执行逻辑。
完整代码如下:
pullRefresh.vue
<template>
<view class="pull-refresh">
<scroll-view style="height: 100%;" scroll-y refresher-enabled refresher-default-style="none"
refresher-background="#ffffff" :refresher-threshold="threshold" :refresher-triggered="loading"
@refresherpulling="onRefresherpulling" @refresherrefresh="onRefresherrefresh"
@refresherrestore="onRefresherrestore" @scrolltolower="onScrolltolower">
<view class="main">
<!-- 下拉提示内容 -->
<view class="content" :style="{top:`-${threshold}px`,height:`${threshold}px`}">
<!-- 下拉或可释放状态 -->
<image v-if="status<2" class="arrow"
:src="status===0 ? '/static/arrow_down.png':'/static/arrow_up.png'" mode="widthFix"></image>
<!-- 正在刷新中 -->
<image v-else-if="status==2" class="arrow loading" src="/static/loading.png" mode="widthFix">
</image>
<view class="tip-view">
<view :class="['text',{start:!updateTime}]">{{tipText}}</view>
<view v-if="updateTime" class="update-time">上次更新 {{updateTime}}</view>
</view>
</view>
<slot></slot>
</view>
</scroll-view>
</view>
</template>
<script setup>
import {
ref,
nextTick,
computed
} from 'vue'
const props = defineProps({
//下拉刷新阈值
threshold: {
type: Number,
default: 80
},
//下拉刷新接口方法,cb:接口请求完成回调
refreshMethod: {
type: Function,
default: cb => cb()
},
//下拉过程文案
pullingText: {
type: String,
default: '下拉可以刷新'
},
//释放过程文案
loosingText: {
type: String,
default: '释放立即刷新'
},
//刷新中文案
loadingText: {
type: String,
default: '正在刷新...'
},
})
const emits = defineEmits(['scrolltolower'])
//是否正在刷新(加载数据)
const loading = ref(false)
/**
* 状态:0:下拉状态 1:可释放刷新状态 2:正在刷新状态
*/
const status = ref(0)
//提示文字
const tipText = computed(()=>{
let tips={
0:props.pullingText,
1:props.loosingText,
2:props.loadingText
}
return tips[status.value]||props.pullingText
})
//上一次刷新时间
const updateTime = ref('')
//控件被下拉触发回调
const onRefresherpulling = (e) => {
//微信小程dy字段,H5和app deltaY字段
//#ifdef H5||APP
if (e.detail.deltaY >= props.threshold ) {
status.value = 1
}
// #endif
//#ifndef H5||APP
if (e.detail.dy >= props.threshold) {
status.value = 1
}
// #endif
else {
status.value = 0
}
}
//下拉刷新被触发回调
const onRefresherrefresh = (e) => {
//到达下拉阈值刷新数据
if (status.value === 1) {
loading.value = true
status.value = 2;
//接口获取数据
props.refreshMethod(() => {
nextTick(() => {
uni.showToast({
title: '刷新成功',
icon: 'none'
})
updateTime.value = formatDateTime()
loading.value = false
})
})
}
}
//下拉刷新被复位回调
const onRefresherrestore = () => {
status.value = 0
}
//获取当前日期时间
const formatDateTime = () => {
let date = new Date()
let month = (date.getMonth() + 1).toString().padStart(2, '0')
let day = date.getDate().toString().padStart(2, '0')
let hour = date.getHours().toString().padStart(2, '0')
let minus = date.getMinutes().toString().padStart(2, '0')
let second = date.getSeconds().toString().padStart(2, '0')
return `${month}-${day} ${hour}:${minus}`
}
//触底
const onScrolltolower = () => {
emits('scrolltolower')
}
</script>
<style lang="scss" scoped>
.pull-refresh {
height: 100%;
width: 100%;
position: relative;
}
.main {
position: relative;
}
.content {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: -80px;
height: 80px;
color: #000;
z-index: 999;
padding-bottom: 15px;
box-sizing: border-box;
.arrow {
width: 45rpx;
height: auto;
&.loading {
animation: loadingFrames 1s linear infinite;
}
}
.tip-view {
width: 10em;
display: flex;
font-size: 22rpx;
flex-direction: column;
align-items: center;
margin-left: 20rpx;
.text {
font-size: 28rpx;
color: #666;
&.start{
align-self: flex-start;
}
}
.update-time {
font-size: 22rpx;
margin-top: 10rpx;
color: #808080;
}
}
}
@keyframes loadingFrames {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
页面调用:
index.vue
<template>
<view class="container">
<PullRefresh :threshold="80" :refreshMethod="getData" @scrolltolower="handleScrolltolower">
<view class="item" v-for="(item,index) in 20">{{item}}</view>
</PullRefresh>
</view>
</template>
<script setup>
import PullRefresh from '@/components/pullRefresh.vue';
/**
* 获取数据
* cb:接口数据获取完成回调函数
*/
const getData=(cb)=>{
//模拟接口请求数据
setTimeout(()=>{
cb()
},2000)
}
//触底回调
const handleScrolltolower=()=>{
console.log('触底')
}
</script>
<style lang="scss" scoped>
.container {
height: 100vh;
background-color: #f2f2f2;
}
.item {
height: 90rpx;
line-height: 90rpx;
text-align: center;
margin-top: 20rpx;
}
</style>
说明:为了让用户自定义文字,我们提供了各种状态下的文案属性设置,下拉刷新阈值也暴露属性threshold给用户自定义,同时还定义了refreshMethod属性用来控制接口获取刷新数据是否完成,该属性是一个方法内部写入从接口获取刷新数据逻辑,入参cb是个回调函数,刷新数据获取完成调用回调函数组件即可复位。组件高度默认100%继承父容器,内部默认插槽可添加页面内容。
ps:scrollView组件外层必须设置高度或最大高度才能保证自定义下拉刷新或者滚动触底功能正常
在小程序端运行:
H5或APP运行:
从运行效果看出小程序运行正常,而H5或APP端存在bug,当下拉区域较长时正常,当下拉区域较短(刚处于立即释放刷新状态)就放开手指会出现异常,组件不进入刷新状态。
查阅uniapp官方文档,官方文档对下拉回调参数都是轻描淡写,没写详细,通过多次试验测试,发现当中的猫腻,原来是下拉触发回调@refresherpulling返回的当前下拉区域高度值deltaY在这2端是不准确的,经过测试发现跟实际误差10px左右(多了10px),因此判断这2端是否到达阈值需要减去10
//控件被下拉触发回调
const onRefresherpulling = (e) => {
console.log(e,'e')
//微信小程dy字段,H5和app deltaY字段
//#ifdef H5||APP
//H5或APP端值判断是否到达阈值需要额外加10
if (e.detail.deltaY-10 >= props.threshold ) {
status.value = 1
}
// #endif
//#ifndef H5||APP
if (e.detail.dy >= props.threshold) {
status.value = 1
}
// #endif
else {
status.value = 0
}
}
最终版完整代码:
pullRefresh.vue(自定义下拉刷新组件)
<template>
<view class="pull-refresh">
<scroll-view style="height: 100%;" scroll-y refresher-enabled refresher-default-style="none"
refresher-background="#ffffff" :refresher-threshold="threshold" :refresher-triggered="loading"
@refresherpulling="onRefresherpulling" @refresherrefresh="onRefresherrefresh"
@refresherrestore="onRefresherrestore" @scrolltolower="onScrolltolower">
<view class="main">
<!-- 下拉提示内容 -->
<view class="content" :style="{top:`-${threshold}px`,height:`${threshold}px`}">
<!-- 下拉或可释放状态 -->
<image v-if="status<2" class="arrow"
:src="status===0 ? '/static/arrow_down.png':'/static/arrow_up.png'" mode="widthFix"></image>
<!-- 正在刷新中 -->
<image v-else-if="status==2" class="arrow loading" src="/static/loading.png" mode="widthFix">
</image>
<view class="tip-view">
<view :class="['text',{start:!updateTime}]">{{tipText}}</view>
<view v-if="updateTime" class="update-time">上次更新 {{updateTime}}</view>
</view>
</view>
<slot></slot>
</view>
</scroll-view>
</view>
</template>
<script setup>
import {
ref,
nextTick,
computed
} from 'vue'
const props = defineProps({
//下拉刷新阈值
threshold: {
type: Number,
default: 80
},
//下拉刷新接口方法,cb:接口请求完成回调
refreshMethod: {
type: Function,
default: cb => cb()
},
//下拉过程文案
pullingText: {
type: String,
default: '下拉可以刷新'
},
//释放过程文案
loosingText: {
type: String,
default: '释放立即刷新'
},
//刷新中文案
loadingText: {
type: String,
default: '正在刷新...'
},
})
const emits = defineEmits(['scrolltolower'])
//是否正在刷新(加载数据)
const loading = ref(false)
/**
* 状态:0:下拉状态 1:可释放刷新状态 2:正在刷新状态
*/
const status = ref(0)
//提示文字
const tipText = computed(()=>{
let tips={
0:props.pullingText,
1:props.loosingText,
2:props.loadingText
}
return tips[status.value]||props.pullingText
})
//上一次刷新时间
const updateTime = ref('')
//控件被下拉触发回调
const onRefresherpulling = (e) => {
//微信小程dy字段,H5和app deltaY字段
//#ifdef H5||APP
//H5或APP端值判断是否到达阈值需要额外加10
if (e.detail.deltaY-10 >= props.threshold ) {
status.value = 1
}
// #endif
//#ifndef H5||APP
if (e.detail.dy >= props.threshold) {
status.value = 1
}
// #endif
else {
status.value = 0
}
}
//下拉刷新被触发回调
const onRefresherrefresh = (e) => {
//到达下拉阈值刷新数据
if (status.value === 1) {
loading.value = true
status.value = 2;
//接口获取数据
props.refreshMethod(() => {
nextTick(() => {
uni.showToast({
title: '刷新成功',
icon: 'none'
})
updateTime.value = formatDateTime()
loading.value = false
})
})
}
//发现误差在1左右未到达下拉刷新阈值H5或APP也会触发,兼容处理恢复初态
else {
loading.value = false
status.value = 0
}
}
//下拉刷新被复位回调
const onRefresherrestore = () => {
status.value = 0
}
//获取当前日期时间
const formatDateTime = () => {
let date = new Date()
let month = (date.getMonth() + 1).toString().padStart(2, '0')
let day = date.getDate().toString().padStart(2, '0')
let hour = date.getHours().toString().padStart(2, '0')
let minus = date.getMinutes().toString().padStart(2, '0')
let second = date.getSeconds().toString().padStart(2, '0')
return `${month}-${day} ${hour}:${minus}`
}
//触底
const onScrolltolower = () => {
emits('scrolltolower')
}
</script>
<style lang="scss" scoped>
.pull-refresh {
height: 100%;
width: 100%;
position: relative;
}
.main {
position: relative;
}
.content {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: -80px;
height: 80px;
color: #000;
z-index: 999;
padding-bottom: 15px;
box-sizing: border-box;
.arrow {
width: 45rpx;
height: auto;
&.loading {
animation: loadingFrames 1s linear infinite;
}
}
.tip-view {
width: 10em;
display: flex;
font-size: 22rpx;
flex-direction: column;
align-items: center;
margin-left: 20rpx;
.text {
font-size: 28rpx;
color: #666;
&.start{
align-self: flex-start;
}
}
.update-time {
font-size: 22rpx;
margin-top: 10rpx;
color: #808080;
}
}
}
@keyframes loadingFrames {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
页面调用:
<template>
<view class="container">
<PullRefresh :threshold="80" :refreshMethod="getData" @scrolltolower="handleScrolltolower">
<view class="item" v-for="(item,index) in 20">{{item}}</view>
</PullRefresh>
</view>
</template>
<script setup>
import PullRefresh from '@/components/pullRefresh.vue';
/**
* 获取数据
* cb:接口数据获取完成回调函数
*/
const getData=(cb)=>{
//模拟接口请求数据
setTimeout(()=>{
cb()
},2000)
}
//触底回调
const handleScrolltolower=()=>{
console.log('触底')
}
</script>
<style lang="scss" scoped>
.container {
height: 100vh;
background-color: #f2f2f2;
}
.item {
height: 90rpx;
line-height: 90rpx;
text-align: center;
margin-top: 20rpx;
}
</style>
H5或APP端运行效果: