feat: 卡片滑动时的动画效果

This commit is contained in:
2026-05-26 22:25:42 +08:00
parent a734d98fa4
commit 98d380edd8
2 changed files with 43 additions and 1 deletions

View File

@@ -12,7 +12,13 @@
v-for="slot in renderSlots" v-for="slot in renderSlots"
:key="slot.key" :key="slot.key"
class="swiper-card" class="swiper-card"
:class="[`is-${slot.role}`, { 'is-current': slot.role === 'current' }]" :class="[
`is-${slot.role}`,
{
'is-current': slot.role === 'current',
'is-fill-fade': slot.key === fillFadeKey,
},
]"
:style="slot.style" :style="slot.style"
@tap="handleCardTap(slot)" @tap="handleCardTap(slot)"
> >
@@ -62,6 +68,7 @@ const emit = defineEmits(["update:modelValue", "change", "didSelectItem"]);
const DURATION = 280; const DURATION = 280;
const RECYCLE_FRAME_DELAY = 32; const RECYCLE_FRAME_DELAY = 32;
const FILL_FADE_DURATION = 240;
const CLICK_THRESHOLD = 8; const CLICK_THRESHOLD = 8;
const SWIPE_THRESHOLD = 60; const SWIPE_THRESHOLD = 60;
@@ -79,11 +86,13 @@ const isAnimating = ref(false);
const isRecycling = ref(false); const isRecycling = ref(false);
const isTapCandidate = ref(false); const isTapCandidate = ref(false);
const swipeStep = ref(0); const swipeStep = ref(0);
const fillFadeKey = ref("");
let startX = 0; let startX = 0;
let startY = 0; let startY = 0;
let settleTimer = null; let settleTimer = null;
let recycleTimer = null; let recycleTimer = null;
let fillFadeTimer = null;
const instance = getCurrentInstance(); const instance = getCurrentInstance();
const hasExternalModel = Object.prototype.hasOwnProperty.call( const hasExternalModel = Object.prototype.hasOwnProperty.call(
instance?.vnode.props || {}, instance?.vnode.props || {},
@@ -131,6 +140,13 @@ function clearRecycleTimer() {
} }
} }
function clearFillFadeTimer() {
if (fillFadeTimer) {
clearTimeout(fillFadeTimer);
fillFadeTimer = null;
}
}
const getCircularDistance = (from, to, total) => { const getCircularDistance = (from, to, total) => {
const forward = normalizeIndex(to - from, total); const forward = normalizeIndex(to - from, total);
const backward = normalizeIndex(from - to, total); const backward = normalizeIndex(from - to, total);
@@ -174,6 +190,8 @@ watch(
isDragging.value = false; isDragging.value = false;
isAnimating.value = false; isAnimating.value = false;
isRecycling.value = false; isRecycling.value = false;
fillFadeKey.value = "";
clearFillFadeTimer();
if (hasExternalModel) { if (hasExternalModel) {
syncActiveCursorByActualIndex(props.modelValue); syncActiveCursorByActualIndex(props.modelValue);
} else { } else {
@@ -392,7 +410,9 @@ const finishSwipe = (step) => {
clearSettleTimer(); clearSettleTimer();
settleTimer = setTimeout(() => { settleTimer = setTimeout(() => {
const nextCursor = normalizeIndex(activeCursor.value + step, virtualCount.value); const nextCursor = normalizeIndex(activeCursor.value + step, virtualCount.value);
const fillIndex = normalizeIndex(nextCursor + step, virtualCount.value);
isRecycling.value = true; isRecycling.value = true;
fillFadeKey.value = getItemKey(fillIndex);
activeCursor.value = nextCursor; activeCursor.value = nextCursor;
const actualIndex = getActualIndex(nextCursor); const actualIndex = getActualIndex(nextCursor);
emit("update:modelValue", actualIndex); emit("update:modelValue", actualIndex);
@@ -403,6 +423,11 @@ const finishSwipe = (step) => {
isRecycling.value = false; isRecycling.value = false;
recycleTimer = null; recycleTimer = null;
}, RECYCLE_FRAME_DELAY); }, RECYCLE_FRAME_DELAY);
clearFillFadeTimer();
fillFadeTimer = setTimeout(() => {
fillFadeKey.value = "";
fillFadeTimer = null;
}, FILL_FADE_DURATION);
}, DURATION); }, DURATION);
}; };
@@ -462,12 +487,14 @@ const handleTouchCancel = () => {
if (!canSwipe.value) return; if (!canSwipe.value) return;
clearSettleTimer(); clearSettleTimer();
clearRecycleTimer(); clearRecycleTimer();
clearFillFadeTimer();
deltaX.value = 0; deltaX.value = 0;
isDragging.value = false; isDragging.value = false;
isAnimating.value = false; isAnimating.value = false;
isRecycling.value = false; isRecycling.value = false;
isTapCandidate.value = false; isTapCandidate.value = false;
swipeStep.value = 0; swipeStep.value = 0;
fillFadeKey.value = "";
}; };
const handleCardTap = (slot) => { const handleCardTap = (slot) => {
@@ -478,6 +505,7 @@ const handleCardTap = (slot) => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearSettleTimer(); clearSettleTimer();
clearRecycleTimer(); clearRecycleTimer();
clearFillFadeTimer();
}); });
</script> </script>

View File

@@ -20,6 +20,10 @@
will-change: transform, opacity; will-change: transform, opacity;
} }
.swiper-card.is-fill-fade .card-shell {
animation: card-fill-fade-in 240ms ease-out both;
}
.card-shell { .card-shell {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -95,3 +99,13 @@
.is-single .swiper-stage { .is-single .swiper-stage {
overflow: visible; overflow: visible;
} }
@keyframes card-fill-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}