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

View File

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