【前端】云工厂的纸掌柜app
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

668 lines
22 KiB

<template>
<view class="scroll-list-wrap" :style="[scrollListWrapStyle]">
<scroll-view
class="scroll-view"
:class="[elClass]"
:style="[listWrapStyle]"
scroll-y
scroll-anchoring
enable-back-to-top
:scroll-top="scrollTop"
:lower-threshold="defaultOption.lowerThreshold"
@scroll="handleScroll"
@touchend="handleTouchEnd"
@touchmove.prevent.stop="handleTouchMove"
@touchstart="handleTouchStart"
@scrolltolower="handleScrolltolower"
>
<view class="scroll-content" :style="[scrollContentStyle]">
<view class="pull-down-wrap">
<slot name="pulldown" v-if="$slots.pulldown"></slot>
<view class="refresh-view" :style="[refreshViewStyle]" v-else>
<view class="pull-down-animation" :class="{ refreshing: refreshing }" :style="[pullDownAnimationStyle]"></view>
<text class="pull-down-text" :style="[pullDownTextStyle]">{{ refreshStateText }}</text>
</view>
</view>
<view class="empty-wrap" v-if="showEmpty">
<slot name="empty" v-if="$slots.empty"></slot>
<view class="empty-view" v-else>
<image class="empty-image" :src="defaultOption.emptyImage || images.empty" mode="aspectFit"></image>
<text class="empty-text" :style="[emptyTextStyle]">{{ emptyText }}</text>
</view>
</view>
<view class="list-content"><slot></slot></view>
<view class="pull-up-wrap" v-if="showPullUp">
<slot name="pullup" v-if="$slots.pullup"></slot>
<view class="load-view" v-else>
<view class="pull-up-animation" v-if="loading" :style="[pullUpAnimationStyle]"></view>
<text class="pull-up-text" :style="[pullUpTextStyle]">{{ loadStateText }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import images from './images.js'
export default {
name: 'scroll-list',
props: {
// 配置信息
option: {
type: Object,
default: () => ({})
}
},
data() {
return {
defaultOption: {
page: 1, // 分页
size: 15, // 分页大小
auto: true, // 自动加载
height: null, // 组件高度
disabled: false, // 禁用
background: '', // 背景颜色属性
emptyImage: '', // 空数据提示图片
offsetBottom: 0, // 底部高度补偿
pullDownSpeed: 0.5, // 下拉速率
lowerThreshold: 40, // 距离底部上拉加载距离
refresherThreshold: 80, // 距离顶部下拉刷新距离
refreshDelayed: 800, // 刷新延迟
refreshFinishDelayed: 800, // 刷新完成后的延迟
safeArea: false, // 是否开启安全区域适配
emptyTextColor: '#82848a', // 空提示文字颜色
loadTextColor: '#82848a', // 上拉加载文字颜色
loadIconColor: '#82848a', // 上拉加载图标颜色
refresherTextColor: '#82848a', // 下拉刷新文字颜色
refresherIconColor: '#82848a', // 下拉刷新图标颜色
emptyText: '暂无列表~', // 空数据提示文字
loadingText: '正在加载中~', // 加载中文字
loadFailText: '加载失败啦~', // 加载失败文字
noMoreText: '没有更多啦~', // 没有更多文字
refreshingText: '正在刷新~', // 正在刷新文字
refreshFailText: '刷新失败~', // 刷新失败文字
refreshSuccessText: '刷新成功~', // 刷新成功文字
pulldownText: '下拉刷新~', // 下拉中的文字
pulldownFinishText: '松开刷新~' // 下拉完成的文字
},
images, // 内置图片
elClass: '', // 组件动态class
windowInfo: {}, // 窗口信息
scrollTop: 0, // 距离顶部滚动高度
scrollViewTop: -1, // 滚动视图顶部位置
scrollViewHeight: 0, // 滚动视图高度
currentPage: 1, // 当前分页页码
currentSize: 15, // 当前分页大小
currentScrollTop: 0, // 当前滚动高度
emptyText: '暂无列表~',
loadStateText: '正在加载中~', // 加载状态文字
refreshStateText: '下拉刷新~', // 刷新状态文字
loadDisabled: false, // 是否禁用上拉加载
loading: false, // 是否加载中
refreshing: false, // 是否刷新中
refreshFinish: false, // 是否刷新完成
pulldowning: false, // 是否正在下拉
pullDownHeight: 0, // 下拉高度
showEmpty: false, // 是否显示空数据提示
showPullUp: false, // 是否显示上拉加载
showPullDown: false // 是否显示下拉刷新
}
},
methods: {
// 组件初始化
handleInit() {
// 合并配置
this.defaultOption = Object.assign(this.defaultOption, this.option)
this.showEmpty = !this.defaultOption.auto
this.currentPage = this.defaultOption.page
this.currentSize = this.defaultOption.size
this.emptyText = this.defaultOption.emptyText
this.loadStateText = this.defaultOption.loadingText
this.refreshStateText = this.defaultOption.pulldownText
// 计算高度
this.queryRect('.' + this.elClass).then((rect) => {
// 设置组件顶部位置
this.scrollViewTop = rect.top
// 判断是否自动加载
if (this.defaultOption.auto) this.load()
})
},
// 加载数据
load() {
if (this.defaultOption.disabled || this.loading || this.loadDisabled) return
// 开启正在加载
this.loading = true
// 设置正在加载状态文字
this.loadStateText = this.defaultOption.loadingText
// 显示上拉加载
this.showPullUp = true
// 分页参数
let paging = { page: this.currentPage, size: this.currentSize }
// 触发load事件
this.$emit('load', paging)
},
// 加载成功
loadSuccess(data = {}) {
// 解构数据
const { list, total } = data
// 判断列表是否是数组
if (Array.isArray(list)) {
// 判断列表长度
if (list.length) {
// 判断列表长度和列表总数是否相同
if (list.length >= total) {
// 设置禁用上拉加载
this.loadDisabled = true
// 加载状态文字
this.loadStateText = this.defaultOption.noMoreText
} else {
// 关闭禁用上拉加载
this.loadDisabled = false
// 设置分页参数
this.currentPage++
// 加载状态为加载中
this.loadStateText = this.defaultOption.loadingText
// 加载计算
this.loadCompute()
}
// 显示上拉加载
this.showPullUp = true
// 隐藏空数据提示
this.showEmpty = false
} else {
// 设置禁用上拉加载
this.loadDisabled = true
// 隐藏上拉加载
this.showPullUp = false
// 隐藏上拉加载
this.showPullUp = false
// 显示空数据提示
this.showEmpty = true
}
// 关闭正在加载
this.loading = false
// 触发加载成功事件
this.$emit('loadSuccess', list)
} else {
// 不是数组类型当作加载失败处理
this.loadFail()
console.error('the list must be a array')
}
},
// 加载失败
loadFail() {
// 关闭正在加载
this.loading = false
// 关闭空数据提示
this.showEmpty = false
// 显示上拉加载
this.showPullUp = true
// 加载状态为加载失败
this.loadStateText = this.defaultOption.loadFailText
// 触发加载失败事件
this.$emit('loadFail')
},
// 刷新数据
refresh() {
// 如果是下拉刷新
if (this.pullDownHeight == this.defaultOption.refresherThreshold) {
// 关闭正在加载
this.loading = false
// 隐藏上拉加载
this.showPullUp = false
} else {
// 开启正在加载
this.loading = true
// 隐藏空数据提示
this.showEmpty = false
// 显示上拉加载
this.showPullUp = true
// 设置正在刷新状态文字
this.loadStateText = this.defaultOption.refreshingText
}
// 设置刷新未完成
this.refreshFinish = false
// 开启正在刷新
this.refreshing = true
// 设置正在刷新状态文字
this.refreshStateText = this.defaultOption.refreshingText
// 设置分页参数
this.currentPage = 1
this.currentSize = this.defaultOption.size
let paging = { page: this.currentPage, size: this.currentSize }
// 触发refresh事件
setTimeout(() => {
this.$emit('refresh', paging)
}, this.defaultOption.refreshDelayed)
},
// 刷新成功
refreshSuccess(data) {
// 解构数据
const { list, total } = data
// 判断列表是否是数组
if (Array.isArray(list)) {
// 判断列表长度
if (list.length) {
// 判断列表长度和列表总数是否相同
if (list.length >= total) {
// 设置禁用上拉加载
this.loadDisabled = true
// 设置没有更多状态文字
this.loadStateText = this.defaultOption.noMoreText
} else {
// 设置分页参数
this.currentPage++
// 关闭禁用上拉加载
this.loadDisabled = false
// 设置加载中状态文字
this.loadStateText = this.defaultOption.loadingText
// 开启自动加载
this.defaultOption.auto = true
// 加载计算
this.loadCompute()
}
// 关闭空数据提示
this.showEmpty = false
// 显示上拉加载
this.showPullUp = true
} else {
// 设置禁用上拉加载
this.loadDisabled = true
// 隐藏上拉加载
this.showPullUp = false
// 显示空数据提示
this.showEmpty = true
// 设置没有更多状态文字
this.loadStateText = this.defaultOption.noMoreText
}
// 关闭正在加载
this.loading = false
// 设置刷新成功状态文字
this.refreshStateText = this.defaultOption.refreshSuccessText
// 关闭正在刷新
this.refreshing = false
// 关闭正在下拉
this.pulldowning = false
// 触发刷新成功事件
this.$emit('refreshSuccess', list)
setTimeout(() => {
// 设置刷新完成
this.refreshFinish = true
// 重置下拉高度
this.pullDownHeight = 0
// 隐藏下拉刷新
this.showPullDown = false
this.$emit('refreshSuccess')
}, this.defaultOption.refreshFinishDelayed)
} else {
// 不是数组类型当作刷新失败处理
this.refreshFail()
console.error('the list must be a array')
}
},
// 刷新失败
refreshFail() {
// 设置加载失败状态文字
this.loadStateText = this.defaultOption.refreshFailText
// 设置刷新失败状态文字
this.refreshStateText = this.defaultOption.refreshFailText
// 关闭正在加载
this.loading = false
// 显示下拉加载
this.showPullUp = true
// 关闭正在刷新
this.refreshing = false
// 关闭正在下拉
this.pulldowning = false
// 延迟执行刷新完成后状态
setTimeout(() => {
// 设置刷新完成
this.refreshFinish = true
// 重置下拉高度
this.pullDownHeight = 0
// 隐藏下拉刷新
this.showPullDown = false
// 触发刷新失败事件
this.$emit('refreshError')
}, this.defaultOption.refreshFinishDelayed)
},
// 加载计算
loadCompute() {
// 判断是否自动加载
if (this.defaultOption.auto) {
// 延迟执行下否者可能会高度计算错误
setTimeout(() => {
this.$nextTick(() => {
this.queryRect('.list-content').then((rect) => {
if (rect.height <= this.scrollViewHeight) {
this.load()
}
})
})
}, 100)
}
},
// 上拉触底事件
handleScrolltolower(e) {
if (this.loadDisabled) return
this.$emit('scrolltolower', e)
this.load()
},
// 滚动事件
handleScroll(event) {
this.currentScrollTop = event.detail.scrollTop
this.$emit('scroll', event.detail)
},
// 触摸按下处理
handleTouchStart(event) {
if (this.defaultOption.disabled) return
this.currentTouchStartY = event.touches[0].clientY
this.$emit('touchStart', event)
},
// 触摸按下滑动处理
handleTouchMove(event) {
if (this.defaultOption.disabled || this.currentScrollTop) return
if (event.touches[0].clientY >= this.currentTouchStartY) {
this.pulldowning = true
this.showPullDown = true
let pullDownDistance = (event.touches[0].clientY - this.currentTouchStartY) * this.defaultOption.pullDownSpeed
this.pullDownHeight = pullDownDistance > this.defaultOption.refresherThreshold ? this.defaultOption.refresherThreshold : pullDownDistance
this.refreshStateText =
this.pullDownHeight >= this.defaultOption.refresherThreshold ? this.defaultOption.pulldownFinishText : this.defaultOption.pulldownText
this.$emit('touchMove', event)
}
},
// 触摸松开处理
handleTouchEnd(event) {
if (this.defaultOption.disabled) return
// 当下拉高度小于下拉阈值
if (this.pullDownHeight < this.defaultOption.refresherThreshold) {
// 关闭正在下拉
this.pulldowning = false
// 重置下拉高度
this.pullDownHeight = 0
// 隐藏下拉刷新
this.showPullDown = false
// 触发下拉中断事件
this.$emit('refreshStop')
} else {
this.refresh()
}
// 触发下拉触摸松开事件
this.$emit('touchEnd', event)
},
// 更新组件
updateScrollView() {
if (this.defaultOption.height) {
this.scrollViewHeight = uni.upx2px(this.defaultOption.height)
} else {
this.scrollViewHeight = this.windowInfo.windowHeight - this.scrollViewTop
}
this.scrollViewObserve()
},
// 监听列表高度变化
listContentObserve() {
this.disconnectObserve('_listContentObserve')
const listContentObserve = this.createIntersectionObserver({
thresholds: [0, 0.5, 1]
})
listContentObserve.relativeToViewport({
// #ifdef H5
top: -(this.windowInfo.windowTop + rect.top),
// #endif
// #ifndef H5
top: -rect.top
// #endif
})
},
// 监听组件位置变化
scrollViewObserve() {
this.disconnectObserve('_scrollViewObserve')
this.$nextTick(() => {
this.queryRect('.' + this.elClass).then((rect) => {
const scrollViewObserve = this.createIntersectionObserver({
thresholds: [0, 0.5, 1]
})
scrollViewObserve.relativeToViewport({
// #ifdef H5
top: -(this.windowInfo.windowTop + rect.top),
// #endif
// #ifndef H5
top: -rect.top
// #endif
})
scrollViewObserve.observe('.' + this.elClass, (position) => {
// #ifdef H5
this.scrollViewTop = position.boundingClientRect.top - this.windowInfo.windowTop
// #endif
// #ifndef H5
this.scrollViewTop = position.boundingClientRect.top
// #endif
})
this._scrollViewObserve = scrollViewObserve
})
})
},
// 断开监听组件
disconnectObserve(observerName) {
const observer = this[observerName]
observer && observer.disconnect()
},
// 查询dom节点信息
queryRect(selector, all) {
return new Promise((resolve) => {
uni
.createSelectorQuery()
.in(this)
[all ? 'selectAll' : 'select'](selector)
.boundingClientRect((rect) => {
if (all && Array.isArray(rect) && rect.length) {
resolve(rect)
}
if (!all && rect) {
resolve(rect)
}
})
.exec()
})
},
// 16进制转RGB
hexToRgb(hex) {
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i
hex = hex.replace(shorthandRegex, (m, r, g, b) => {
return r + r + g + g + b + b
})
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
}
: null
}
},
computed: {
scrollListWrapStyle() {
let style = {}
style.background = this.defaultOption.background
return style
},
// 组件容器样式
listWrapStyle() {
let style = {}
const { offsetBottom } = this.defaultOption
style.height = this.scrollViewHeight - uni.upx2px(offsetBottom) + 'px'
if (this.defaultOption.safeArea) style.paddingBottom = 'env(safe-area-inset-bottom) !important'
return style
},
// 滚动内容样式
scrollContentStyle() {
const style = {}
const { pullDownHeight, pulldowning, showPullDown } = this
style.transform = showPullDown ? `translateY(${pullDownHeight}px)` : `translateY(0px)`
style.transition = pulldowning ? `transform 100ms ease-out` : `transform 200ms cubic-bezier(0.19,1.64,0.42,0.72)`
return style
},
// 下拉刷新样式
refreshViewStyle() {
const style = {}
const { showPullDown } = this
style.opacity = showPullDown ? 1 : 0
return style
},
// 下拉中动画样式
pullDownAnimationStyle() {
const style = {}
const { refresherIconColor, refresherThreshold } = this.defaultOption
const { refreshing, pullDownHeight } = this
const { r, g, b } = this.hexToRgb(refresherIconColor)
const rate = pullDownHeight / refresherThreshold
style.borderColor = `rgba(${r},${g},${b},0.2)`
style.borderTopColor = refresherIconColor
if (!refreshing) {
style.transform = `rotate(${360 * rate}deg)`
style.transition = 'transform 100ms linear'
}
return style
},
pullDownTextStyle() {
const style = {}
const { refresherTextColor } = this.defaultOption
style.color = refresherTextColor
return style
},
// 上拉中动画样式
pullUpAnimationStyle() {
const style = {}
const { loadIconColor } = this.defaultOption
const { r, g, b } = this.hexToRgb(loadIconColor)
style.borderColor = `rgba(${r},${g},${b},0.2)`
style.borderTopColor = loadIconColor
return style
},
// 上拉中文字样式
pullUpTextStyle() {
const style = {}
const { loadTextColor } = this.defaultOption
style.color = loadTextColor
return style
},
// 空数据提示文字样式
emptyTextStyle() {
const style = {}
const { emptyTextColor } = this.defaultOption
style.color = emptyTextColor
return style
}
},
watch: {
scrollViewTop(val) {
this.updateScrollView()
}
},
created() {
this.elClass = 'scroll-view-' + this._uid
this.windowInfo = uni.getSystemInfoSync()
},
mounted() {
this.handleInit()
}
}
</script>
<style scoped lang="scss">
.scroll-list-wrap {
box-sizing: border-box;
.scroll-view {
position: relative;
.scroll-content {
height: 100%;
display: flex;
will-change: transform;
flex-direction: column;
.pull-down-wrap {
left: 0;
width: 100%;
display: flex;
padding: 30rpx 0;
position: absolute;
align-items: flex-end;
justify-content: center;
transform: translateY(-100%);
.refresh-view {
display: flex;
justify-content: center;
.pull-down-animation {
width: 32rpx;
height: 32rpx;
border-width: 4rpx;
border-style: solid;
border-radius: 50%;
&.refreshing {
animation: spin 0.5s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
}
.pull-down-text {
margin-left: 10rpx;
}
}
}
.empty-wrap {
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
position: absolute;
align-items: center;
flex-direction: column;
.empty-view {
margin: auto;
display: flex;
align-items: center;
flex-direction: column;
.empty-image {
width: 200rpx;
height: 200rpx;
}
.empty-text {
color: #606266;
margin-top: 20rpx;
}
}
}
.list-content {
}
.pull-up-wrap {
display: flex;
align-items: center;
justify-content: center;
.load-view {
padding: 20rpx 0;
display: flex;
align-items: center;
justify-content: center;
.pull-up-animation {
width: 32rpx;
height: 32rpx;
border-width: 4rpx;
border-style: solid;
border-radius: 50%;
animation: spin 0.5s linear infinite;
}
.pull-up-text {
margin-left: 10rpx;
}
}
}
}
}
}
</style>