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 DURATION = 280;
const RECYCLE_FRAME_DELAY = 32;
const CLICK_THRESHOLD = 8;
const SWIPE_THRESHOLD = 60;
@@ -75,12 +76,14 @@ const activeCursor = ref(0);
const deltaX = ref(0);
const isDragging = ref(false);
const isAnimating = ref(false);
const isRecycling = ref(false);
const isTapCandidate = ref(false);
const swipeStep = ref(0);
let startX = 0;
let startY = 0;
let settleTimer = null;
let recycleTimer = null;
const instance = getCurrentInstance();
const hasExternalModel = Object.prototype.hasOwnProperty.call(
instance?.vnode.props || {},
@@ -89,10 +92,11 @@ const hasExternalModel = Object.prototype.hasOwnProperty.call(
const actualCount = computed(() => props.list.length);
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;
});
const canSwipe = computed(() => virtualCount.value > 1);
const canSwipe = computed(() => actualCount.value > 1);
const progress = computed(() => {
if (!canSwipe.value) return 0;
return clamp(deltaX.value / sideOffset, -1, 1);
@@ -113,10 +117,6 @@ const getItemByVirtualIndex = (virtualIndex) => {
return props.list[getActualIndex(virtualIndex)] || null;
};
const syncActiveCursor = (incomingIndex = 0) => {
activeCursor.value = normalizeIndex(incomingIndex, virtualCount.value);
};
function clearSettleTimer() {
if (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(
() => props.list,
() => {
clearSettleTimer();
clearRecycleTimer();
deltaX.value = 0;
isDragging.value = false;
isAnimating.value = false;
const nextCursor = hasExternalModel
? props.modelValue
: activeCursor.value;
syncActiveCursor(nextCursor);
isRecycling.value = false;
if (hasExternalModel) {
syncActiveCursorByActualIndex(props.modelValue);
} else {
syncActiveCursorByVirtualIndex(activeCursor.value);
}
},
{ deep: true, immediate: true }
);
@@ -144,11 +188,11 @@ watch(
(value) => {
if (!hasExternalModel) return;
if (isAnimating.value || isDragging.value) return;
syncActiveCursor(value);
syncActiveCursorByActualIndex(value);
}
);
const getItemKey = (virtualIndex, role) => {
const getItemKey = (virtualIndex) => {
const item = getItemByVirtualIndex(virtualIndex) || {};
const baseKey =
item.id ??
@@ -157,7 +201,7 @@ const getItemKey = (virtualIndex, role) => {
item.title ??
virtualIndex;
return `${baseKey}-${virtualIndex}-${role}`;
return `${baseKey}-${virtualIndex}`;
};
const states = {
@@ -256,7 +300,7 @@ const buildCardStyle = (role) => {
transform: `translate3d(-50%, 0, 0) translateX(${state.x}px) scale(${state.scale})`,
opacity: state.opacity,
zIndex: getCardZIndex(role),
transition: isDragging.value
transition: isDragging.value || isRecycling.value
? "none"
: `transform ${DURATION}ms ease, opacity ${DURATION}ms ease`,
};
@@ -287,32 +331,44 @@ const getMaskOpacity = (role) => {
const getMaskStyle = (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(() => {
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 nextIndex = normalizeIndex(activeCursor.value + 1, virtualCount.value);
return [
{
key: getItemKey(prevIndex, "prev"),
key: getItemKey(prevIndex),
role: "prev",
index: getActualIndex(prevIndex),
item: getItemByVirtualIndex(prevIndex),
style: buildCardStyle("prev"),
},
{
key: getItemKey(activeCursor.value, "current"),
key: getItemKey(activeCursor.value),
role: "current",
index: getActualIndex(activeCursor.value),
item: getItemByVirtualIndex(activeCursor.value),
style: buildCardStyle("current"),
},
{
key: getItemKey(nextIndex, "next"),
key: getItemKey(nextIndex),
role: "next",
index: getActualIndex(nextIndex),
item: getItemByVirtualIndex(nextIndex),
@@ -336,16 +392,22 @@ const finishSwipe = (step) => {
clearSettleTimer();
settleTimer = setTimeout(() => {
const nextCursor = normalizeIndex(activeCursor.value + step, virtualCount.value);
isRecycling.value = true;
activeCursor.value = nextCursor;
const actualIndex = getActualIndex(nextCursor);
emit("update:modelValue", actualIndex);
emit("change", actualIndex);
resetGesture();
clearRecycleTimer();
recycleTimer = setTimeout(() => {
isRecycling.value = false;
recycleTimer = null;
}, RECYCLE_FRAME_DELAY);
}, DURATION);
};
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];
if (!touch) return;
@@ -399,20 +461,23 @@ const handleTouchEnd = () => {
const handleTouchCancel = () => {
if (!canSwipe.value) return;
clearSettleTimer();
clearRecycleTimer();
deltaX.value = 0;
isDragging.value = false;
isAnimating.value = false;
isRecycling.value = false;
isTapCandidate.value = false;
swipeStep.value = 0;
};
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);
};
onBeforeUnmount(() => {
clearSettleTimer();
clearRecycleTimer();
});
</script>