FrontTools/PullRefresh/PullRefresh.vue
Rain 3ab1f2db24 [入库]初次入库各种工具
- DBManager DB创建管理器
- FormatTimeTool
- ImageViewer工具
- InputArear工具
- OverlayPage工具
- 下拉刷新容器工具
- 视频查看器工具
2026-04-02 16:09:00 +08:00

306 lines
7.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>