FrontTools/PullRefresh/PullRefresh.vue

306 lines
7.8 KiB
Vue
Raw Permalink Normal View History

<template>
<view class="pull-refresh" :style="wrapStyle">
<view
class="pull-refresh__zone"
:class="{ 'pull-refresh__zone--no-transition': pulling }"
:style="refreshZoneStyle"
>
<view
class="pull-refresh__circle-wrap"
:class="{ 'pull-refresh__circle-wrap--no-transition': pulling }"
:style="circleWrapStyle"
>
<view class="pull-refresh__circle" :style="circleInnerStyle" />
</view>
</view>
<scroll-view
:class="['pull-refresh__scroll', scrollClass]"
:scroll-y="scrollEnabled && !pulling"
:show-scrollbar="showScrollbar"
:scroll-with-animation="false"
:lower-threshold="lowerThreshold"
@scroll="onScroll"
@touchstart="onPullStart"
@touchmove="onPullMove"
@touchend="onPullEnd"
@touchcancel="onPullEnd"
@scrolltolower="onScrollToLower"
>
<slot />
<!-- 底部加载状态footerMode 时显示预留 footerPaddingBottom 避免被底栏/安全区遮挡 -->
<view
v-if="footerMode"
class="pull-refresh__footer"
:style="footerStyle"
>
<view v-if="loading && !isRefreshing && !(noEmptyFooter && listLength === 0)" class="pull-refresh__footer-text">加载中...</view>
<view v-else-if="noMore && listLength > 0" class="pull-refresh__footer-text">已经到底啦</view>
<view v-else-if="listLength === 0 && loaded && !noEmptyFooter" class="pull-refresh__footer-text">还没有动态</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
/**
* PullRefresh 下拉刷新
* 用法当成Scroll组件用然后包住内容监听 @refresh 拉取数据拉完后设 :is-refreshing="false"
* pullMax 下拉最大 pxcircleOffsetStart / circleOffsetEnd 圆圈起止位置
*/
import { ref, computed, watch, onUnmounted } from 'vue'
const props = withDefaults(
defineProps<{
isRefreshing?: boolean
scrollClass?: string
showScrollbar?: boolean
height?: string
/** 下拉最多允许超出多少 px超过后刷新圈不再继续往下拉默认 160 */
pullMax?: number
circleOffsetStart?: string
circleOffsetEnd?: string
/** 启用底部加载状态 UI加载中/已经到底啦/还没有动态) */
footerMode?: boolean
/** 触底加载中 */
loading?: boolean
/** 已加载完,无更多 */
noMore?: boolean
/** 列表长度,用于空态判断 */
listLength?: number
/** 首屏已加载完成,用于空态判断 */
loaded?: boolean
/** 触底检测距离px默认 100 */
lowerThreshold?: number
/** 底部预留(如 tab 栏+安全区),避免 footer 文字被遮挡,如 "calc(120rpx + env(safe-area-inset-bottom))" */
footerPaddingBottom?: string
/** 为 true 时不显示「还没有动态」空态(用于详情页等自有空态的场景) */
noEmptyFooter?: boolean
/** 是否允许纵向滚动 */
scrollEnabled?: boolean
}>(),
{
isRefreshing: false,
scrollClass: '',
showScrollbar: false,
height: '100%',
scrollEnabled: true,
pullMax: 60,
circleOffsetStart: '0rpx',
circleOffsetEnd: '36rpx',
footerMode: false,
loading: false,
noMore: false,
listLength: 0,
loaded: true,
lowerThreshold: 100,
footerPaddingBottom: '0rpx',
noEmptyFooter: false,
}
)
const emit = defineEmits<{
(e: 'refresh'): void
(e: 'scroll', ev: any): void
(e: 'scrolltolower', ev: any): void
(e: 'touchstart'): void
(e: 'touchend'): void
}>()
// --- 常量 ---
const CONFIG = {
PULL_TRIGGER: 56,
PULL_DAMPING: 0.82,
ZONE_HEIGHT_PX: 50,
LERP_FACTOR: 0.62,
SPIN_DURATION_MS: 700,
SPIN_INTERVAL_MS: 16,
PULL_CAP: 500,
/** 下拉时旋转阻尼0~1越小拉同样距离转得越少默认 0.35(约拉满 pullMax 转 1/3 圈) */
ROTATION_DAMPING: 0.35
} as const
const SPIN_STEP = (360 / CONFIG.SPIN_DURATION_MS) * CONFIG.SPIN_INTERVAL_MS
// --- 状态 ---
const scrollTop = ref(0)
const pullDistance = ref(0)
const pulling = ref(false)
const refreshStartRotation = ref(0)
const spinDeg = ref(0)
let touchStartY = 0
let touchStartScrollTop = 0
let spinTimerId: number | ReturnType<typeof setInterval> = 0
// --- 工具 ---
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
const getTouchY = (e: any): number | null => e.touches?.[0]?.clientY ?? null
function clearSpinTimer() {
if (spinTimerId) {
clearInterval(spinTimerId)
spinTimerId = 0
}
}
// --- 计算属性 ---
const wrapStyle = computed(() => ({ height: props.height }))
/** 展示进度:按实际下拉量 / pullMax可>1用于缩放/透明度/旋转(位移单独用 pullDistance capped */
const revealProgress = computed(() => {
if (props.isRefreshing) return 1
return pullDistance.value / Math.max(1, props.pullMax)
})
/** 位移在 pullMax 处封顶,再往下拉只动动画不动位置 */
const refreshZoneStyle = computed(() => {
const displacement = Math.min(pullDistance.value, props.pullMax)
const y = props.isRefreshing ? 0 : -CONFIG.ZONE_HEIGHT_PX + displacement
return { transform: `translateY(${y}px)` }
})
const circleWrapStyle = computed(() => {
const p = revealProgress.value
return {
marginTop: props.isRefreshing ? props.circleOffsetEnd : props.circleOffsetStart,
transform: `scale(${Math.min(1, 0.3 + p * 0.7)})`,
opacity: Math.min(1, p),
transformOrigin: 'center center'
}
})
const circleInnerStyle = computed(() => {
const deg = props.isRefreshing
? spinDeg.value
: revealProgress.value * 360 * CONFIG.ROTATION_DAMPING
return { transform: `rotate(${deg}deg)` }
})
const footerStyle = computed(() =>
props.footerPaddingBottom ? { paddingBottom: props.footerPaddingBottom } : {}
)
// --- 触摸与滚动 ---
function onPullStart(e: any) {
emit('touchstart')
const y = getTouchY(e)
if (y == null) return
touchStartY = y
touchStartScrollTop = scrollTop.value
pulling.value = false
}
function onPullMove(e: any) {
const y = getTouchY(e)
if (y == null) return
const dy = y - touchStartY
if (touchStartScrollTop > 0 || dy <= 0) return
pulling.value = true
e.preventDefault?.()
const target = Math.min(CONFIG.PULL_CAP, dy * CONFIG.PULL_DAMPING)
pullDistance.value = lerp(pullDistance.value, target, CONFIG.LERP_FACTOR)
}
function onPullEnd() {
emit('touchend')
pulling.value = false
if (pullDistance.value >= CONFIG.PULL_TRIGGER) {
refreshStartRotation.value =
(pullDistance.value / Math.max(1, props.pullMax)) * 360 * CONFIG.ROTATION_DAMPING
emit('refresh')
} else {
pullDistance.value = 0
}
}
function onScroll(e: any) {
scrollTop.value = e?.detail?.scrollTop ?? 0
emit('scroll', e)
}
function onScrollToLower(e: any) {
emit('scrolltolower', e)
}
// --- 刷新中旋转 ---
watch(
() => props.isRefreshing,
(cur, prev) => {
if (cur && !prev) {
spinDeg.value = refreshStartRotation.value
spinTimerId = setInterval(() => {
spinDeg.value += SPIN_STEP
}, CONFIG.SPIN_INTERVAL_MS)
} else if (!cur && prev) {
clearSpinTimer()
pullDistance.value = 0
}
}
)
onUnmounted(clearSpinTimer)
</script>
<style lang="scss" scoped>
.pull-refresh {
position: relative;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.pull-refresh__zone {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 100rpx;
display: flex;
align-items: flex-start;
justify-content: center;
z-index: 10;
pointer-events: none;
transition: transform 0.25s ease-out;
&--no-transition {
transition: none;
}
}
.pull-refresh__circle-wrap {
transition: opacity 0.2s ease, margin-top 0.25s ease-out, transform 0.2s ease-out;
&--no-transition {
transition: none;
}
}
.pull-refresh__circle {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
border: 6rpx solid var(--dv-border);
border-top-color: var(--dv-accent);
}
.pull-refresh__scroll {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
background-color: var(--dv-bg);
}
.pull-refresh__footer {
text-align: center;
position: relative;
z-index: 0;
}
.pull-refresh__footer-text {
font-size: var(--dv-font-muted);
color: var(--dv-text-muted);
}
</style>