feat:修复滑动卡片 闪烁的问题

This commit is contained in:
2026-05-14 15:01:48 +08:00
parent 83d4066f72
commit aec288409f

View File

@@ -61,6 +61,7 @@ const props = defineProps({
const emit = defineEmits(["update:modelValue", "change", "didSelectItem"]); const emit = defineEmits(["update:modelValue", "change", "didSelectItem"]);
const DURATION = 280; const DURATION = 280;
const RECYCLE_FRAME_DELAY = 32;
const CLICK_THRESHOLD = 8; const CLICK_THRESHOLD = 8;
const SWIPE_THRESHOLD = 60; const SWIPE_THRESHOLD = 60;
@@ -75,12 +76,14 @@ const activeCursor = ref(0);
const deltaX = ref(0); const deltaX = ref(0);
const isDragging = ref(false); const isDragging = ref(false);
const isAnimating = ref(false); const isAnimating = ref(false);
const isRecycling = ref(false);
const isTapCandidate = ref(false); const isTapCandidate = ref(false);
const swipeStep = ref(0); const swipeStep = ref(0);
let startX = 0; let startX = 0;
let startY = 0; let startY = 0;
let settleTimer = null; let settleTimer = null;
let recycleTimer = 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 || {},
@@ -89,10 +92,11 @@ const hasExternalModel = Object.prototype.hasOwnProperty.call(
const actualCount = computed(() => props.list.length); const actualCount = computed(() => props.list.length);
const virtualCount = computed(() => { const virtualCount = computed(() => {
if (actualCount.value === 1) return 3; if (actualCount.value <= 1) return actualCount.value;
if (actualCount.value === 2) return 4;
return actualCount.value; return actualCount.value;
}); });
const canSwipe = computed(() => virtualCount.value > 1); const canSwipe = computed(() => actualCount.value > 1);
const progress = computed(() => { const progress = computed(() => {
if (!canSwipe.value) return 0; if (!canSwipe.value) return 0;
return clamp(deltaX.value / sideOffset, -1, 1); return clamp(deltaX.value / sideOffset, -1, 1);
@@ -113,10 +117,6 @@ const getItemByVirtualIndex = (virtualIndex) => {
return props.list[getActualIndex(virtualIndex)] || null; return props.list[getActualIndex(virtualIndex)] || null;
}; };
const syncActiveCursor = (incomingIndex = 0) => {
activeCursor.value = normalizeIndex(incomingIndex, virtualCount.value);
};
function clearSettleTimer() { function clearSettleTimer() {
if (settleTimer) { if (settleTimer) {
clearTimeout(settleTimer); clearTimeout(settleTimer);
@@ -124,17 +124,61 @@ function clearSettleTimer() {
} }
} }
function clearRecycleTimer() {
if (recycleTimer) {
clearTimeout(recycleTimer);
recycleTimer = null;
}
}
const getCircularDistance = (from, to, total) => {
const forward = normalizeIndex(to - from, total);
const backward = normalizeIndex(from - to, total);
return Math.min(forward, backward);
};
const getNearestVirtualIndexByActualIndex = (actualIndex, anchor = activeCursor.value) => {
if (!actualCount.value || !virtualCount.value) return 0;
const targetActualIndex = normalizeIndex(actualIndex, actualCount.value);
let matchedIndex = targetActualIndex;
let minDistance = Infinity;
for (let index = 0; index < virtualCount.value; index += 1) {
if (getActualIndex(index) !== targetActualIndex) continue;
const distance = getCircularDistance(anchor, index, virtualCount.value);
if (distance < minDistance) {
matchedIndex = index;
minDistance = distance;
}
}
return matchedIndex;
};
const syncActiveCursorByVirtualIndex = (incomingIndex = 0) => {
activeCursor.value = normalizeIndex(incomingIndex, virtualCount.value);
};
const syncActiveCursorByActualIndex = (incomingIndex = 0) => {
activeCursor.value = getNearestVirtualIndexByActualIndex(incomingIndex);
};
watch( watch(
() => props.list, () => props.list,
() => { () => {
clearSettleTimer(); clearSettleTimer();
clearRecycleTimer();
deltaX.value = 0; deltaX.value = 0;
isDragging.value = false; isDragging.value = false;
isAnimating.value = false; isAnimating.value = false;
const nextCursor = hasExternalModel isRecycling.value = false;
? props.modelValue if (hasExternalModel) {
: activeCursor.value; syncActiveCursorByActualIndex(props.modelValue);
syncActiveCursor(nextCursor); } else {
syncActiveCursorByVirtualIndex(activeCursor.value);
}
}, },
{ deep: true, immediate: true } { deep: true, immediate: true }
); );
@@ -144,11 +188,11 @@ watch(
(value) => { (value) => {
if (!hasExternalModel) return; if (!hasExternalModel) return;
if (isAnimating.value || isDragging.value) return; if (isAnimating.value || isDragging.value) return;
syncActiveCursor(value); syncActiveCursorByActualIndex(value);
} }
); );
const getItemKey = (virtualIndex, role) => { const getItemKey = (virtualIndex) => {
const item = getItemByVirtualIndex(virtualIndex) || {}; const item = getItemByVirtualIndex(virtualIndex) || {};
const baseKey = const baseKey =
item.id ?? item.id ??
@@ -157,7 +201,7 @@ const getItemKey = (virtualIndex, role) => {
item.title ?? item.title ??
virtualIndex; virtualIndex;
return `${baseKey}-${virtualIndex}-${role}`; return `${baseKey}-${virtualIndex}`;
}; };
const states = { const states = {
@@ -256,7 +300,7 @@ const buildCardStyle = (role) => {
transform: `translate3d(-50%, 0, 0) translateX(${state.x}px) scale(${state.scale})`, transform: `translate3d(-50%, 0, 0) translateX(${state.x}px) scale(${state.scale})`,
opacity: state.opacity, opacity: state.opacity,
zIndex: getCardZIndex(role), zIndex: getCardZIndex(role),
transition: isDragging.value transition: isDragging.value || isRecycling.value
? "none" ? "none"
: `transform ${DURATION}ms ease, opacity ${DURATION}ms ease`, : `transform ${DURATION}ms ease, opacity ${DURATION}ms ease`,
}; };
@@ -287,32 +331,44 @@ const getMaskOpacity = (role) => {
const getMaskStyle = (role) => ({ const getMaskStyle = (role) => ({
opacity: getMaskOpacity(role), opacity: getMaskOpacity(role),
transition: isDragging.value ? "none" : "opacity 120ms ease-out", transition: isDragging.value || isRecycling.value ? "none" : "opacity 120ms ease-out",
}); });
const renderSlots = computed(() => { const renderSlots = computed(() => {
if (!actualCount.value) return []; if (!actualCount.value) return [];
if (!canSwipe.value) {
return [
{
key: getItemKey(activeCursor.value),
role: "current",
index: getActualIndex(activeCursor.value),
item: getItemByVirtualIndex(activeCursor.value),
style: buildCardStyle("current"),
},
];
}
const prevIndex = normalizeIndex(activeCursor.value - 1, virtualCount.value); const prevIndex = normalizeIndex(activeCursor.value - 1, virtualCount.value);
const nextIndex = normalizeIndex(activeCursor.value + 1, virtualCount.value); const nextIndex = normalizeIndex(activeCursor.value + 1, virtualCount.value);
return [ return [
{ {
key: getItemKey(prevIndex, "prev"), key: getItemKey(prevIndex),
role: "prev", role: "prev",
index: getActualIndex(prevIndex), index: getActualIndex(prevIndex),
item: getItemByVirtualIndex(prevIndex), item: getItemByVirtualIndex(prevIndex),
style: buildCardStyle("prev"), style: buildCardStyle("prev"),
}, },
{ {
key: getItemKey(activeCursor.value, "current"), key: getItemKey(activeCursor.value),
role: "current", role: "current",
index: getActualIndex(activeCursor.value), index: getActualIndex(activeCursor.value),
item: getItemByVirtualIndex(activeCursor.value), item: getItemByVirtualIndex(activeCursor.value),
style: buildCardStyle("current"), style: buildCardStyle("current"),
}, },
{ {
key: getItemKey(nextIndex, "next"), key: getItemKey(nextIndex),
role: "next", role: "next",
index: getActualIndex(nextIndex), index: getActualIndex(nextIndex),
item: getItemByVirtualIndex(nextIndex), item: getItemByVirtualIndex(nextIndex),
@@ -336,16 +392,22 @@ 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);
isRecycling.value = true;
activeCursor.value = nextCursor; activeCursor.value = nextCursor;
const actualIndex = getActualIndex(nextCursor); const actualIndex = getActualIndex(nextCursor);
emit("update:modelValue", actualIndex); emit("update:modelValue", actualIndex);
emit("change", actualIndex); emit("change", actualIndex);
resetGesture(); resetGesture();
clearRecycleTimer();
recycleTimer = setTimeout(() => {
isRecycling.value = false;
recycleTimer = null;
}, RECYCLE_FRAME_DELAY);
}, DURATION); }, DURATION);
}; };
const handleTouchStart = (event) => { const handleTouchStart = (event) => {
if (!canSwipe.value || isAnimating.value) return; if (!canSwipe.value || isAnimating.value || isRecycling.value) return;
const touch = event.touches?.[0] || event.changedTouches?.[0]; const touch = event.touches?.[0] || event.changedTouches?.[0];
if (!touch) return; if (!touch) return;
@@ -399,20 +461,23 @@ const handleTouchEnd = () => {
const handleTouchCancel = () => { const handleTouchCancel = () => {
if (!canSwipe.value) return; if (!canSwipe.value) return;
clearSettleTimer(); clearSettleTimer();
clearRecycleTimer();
deltaX.value = 0; deltaX.value = 0;
isDragging.value = false; isDragging.value = false;
isAnimating.value = false; isAnimating.value = false;
isRecycling.value = false;
isTapCandidate.value = false; isTapCandidate.value = false;
swipeStep.value = 0; swipeStep.value = 0;
}; };
const handleCardTap = (slot) => { const handleCardTap = (slot) => {
if (slot.role !== "current" || isDragging.value || isAnimating.value) return; if (slot.role !== "current" || isDragging.value || isAnimating.value || isRecycling.value) return;
emit("didSelectItem", slot.item, slot.index); emit("didSelectItem", slot.item, slot.index);
}; };
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearSettleTimer(); clearSettleTimer();
clearRecycleTimer();
}); });
</script> </script>