feat: 有弹性的下拉刷新

master
LCJ-MinYa 5 months ago
parent 082b46db3f
commit 68beb08f90

@ -102,6 +102,10 @@ const titleArr = [
key: 'templateReuse', key: 'templateReuse',
title: '在单一vue组件内实现模板复用非jsx版本', title: '在单一vue组件内实现模板复用非jsx版本',
}, },
{
key: 'pullRefresh',
title: '有弹性的下拉刷新',
},
]; ];
// @/views/demo/**/*.vue // @/views/demo/**/*.vue

@ -0,0 +1,228 @@
<template>
<div
ref="pullContainer"
class="pull-refresh-container"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<!-- 下拉刷新区域 -->
<div
ref="pullHeader"
class="pull-header"
:style="{ transform: `translateY(${pullDistance}px)` }"
>
<div class="pull-text">
<template v-if="refreshStatus === 'pull'">
{{ pullText }}
</template>
<template v-else-if="refreshStatus === 'release'">
{{ releaseText }}
</template>
<template v-else-if="refreshStatus === 'refreshing'"> <span class="spinner"></span> {{ refreshingText }} </template>
</div>
</div>
<!-- 内容区域 -->
<div class="content">
<slot />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useBodyClass } from '@/hooks/useBodyClass';
useBodyClass('body-overflow-hidden');
// ==================== Props ====================
const props = defineProps({
pullThreshold: {
type: Number,
default: 80,
},
pullText: {
type: String,
default: '下拉刷新',
},
releaseText: {
type: String,
default: '释放刷新',
},
refreshingText: {
type: String,
default: '刷新中...',
},
onRefresh: {
type: Function,
required: true,
},
});
// ==================== Refs ====================
const pullContainer = ref(null);
const pullHeader = ref(null);
// ==================== State ====================
const pullDistance = ref(0);
const startY = ref(0);
const isDragging = ref(false);
const refreshStatus = ref('idle');
// ==================== Computed ====================
const isPulling = computed(() => pullDistance.value > 0);
// ==================== Methods ====================
const handleTouchStart = (e) => {
startY.value = e.touches[0].clientY;
isDragging.value = true;
pullDistance.value = 0;
};
const handleTouchMove = (e) => {
if (!isDragging.value) return;
const currentY = e.touches[0].clientY;
const deltaY = currentY - startY.value;
//
if (deltaY <= 0) {
pullDistance.value = 0;
return;
}
//
const maxPull = props.pullThreshold * 1.5;
pullDistance.value = Math.min(deltaY, maxPull);
//
if (pullDistance.value >= props.pullThreshold) {
refreshStatus.value = 'release';
} else {
refreshStatus.value = 'pull';
}
// transform
const content = pullContainer.value.querySelector('.content');
if (content) {
content.style.transform = `translateY(${pullDistance.value}px)`;
content.style.transition = 'none';
}
};
const handleTouchEnd = async () => {
// debugger
if (!isDragging.value) return;
isDragging.value = false;
if (pullDistance.value >= props.pullThreshold) {
//
refreshStatus.value = 'refreshing';
//
const targetDistance = props.pullThreshold;
// pullDistance.value = targetDistance
pullDistance.value = 60;
const content = pullContainer.value.querySelector('.content');
if (content) {
content.style.transition = 'transform 0s ease-out';
content.style.transform = 'translateY(60px)';
}
try {
await props.onRefresh();
} catch (error) {
console.error('刷新失败:', error);
} finally {
//
pullDistance.value = 0;
refreshStatus.value = 'idle';
const content = pullContainer.value.querySelector('.content');
if (content) {
content.style.transition = 'transform 0.3s ease-out';
content.style.transform = 'translateY(0px)';
}
}
} else {
//
pullDistance.value = 0;
refreshStatus.value = 'idle';
const content = pullContainer.value.querySelector('.content');
if (content) {
content.style.transition = 'transform 0.3s ease-out';
content.style.transform = 'translateY(0px)';
}
}
};
// ==================== Lifecycle ====================
onMounted(() => {
const header = pullHeader.value;
if (header) {
header.style.height = `${header.clientHeight}px`;
}
});
onUnmounted(() => {
// Vue 3
});
</script>
<style scoped>
.pull-refresh-container {
position: relative;
height: 100vh;
overflow: hidden;
touch-action: none;
margin-top: -60px !important;
}
.pull-header {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
z-index: 10;
will-change: transform;
}
.pull-text {
font-size: 14px;
color: #999;
text-align: center;
}
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #ccc;
border-top-color: #007aff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 6px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.content {
padding-top: 60px;
height: 100vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
will-change: transform;
transition: transform 0.3s ease-out;
}
</style>

@ -0,0 +1,43 @@
<!-- ExamplePage.vue -->
<template>
<PullRefresh
:pullThreshold="80"
:onRefresh="handleRefresh"
pullText="下拉刷新"
releaseText="释放刷新"
refreshingText="刷新中..."
>
<!-- 你的列表内容 -->
<div
v-for="item in list"
:key="item.id"
class="item"
>
{{ item.title }}
</div>
</PullRefresh>
</template>
<script setup>
import { ref } from 'vue';
import PullRefresh from './components/pullRefresh.vue';
const list = ref([
{ id: 1, title: 'Item 1' },
{ id: 2, title: 'Item 2' },
{ id: 3, title: 'Item 3' },
]);
const handleRefresh = async () => {
await new Promise((resolve) => setTimeout(resolve, 1500)); //
list.value = [{ id: 4, title: 'New Item' }, ...list.value];
};
</script>
<style scoped>
.item {
padding: 16px;
border-bottom: 1px solid #eee;
font-size: 16px;
}
</style>
Loading…
Cancel
Save