306 lines
7.8 KiB
Vue
306 lines
7.8 KiB
Vue
|
|
<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 下拉最大 px;circleOffsetStart / 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>
|