diff --git a/src/pages/Discovery/components/CardSwiper/index.vue b/src/pages/Discovery/components/CardSwiper/index.vue index 76b49e0..0c3823f 100644 --- a/src/pages/Discovery/components/CardSwiper/index.vue +++ b/src/pages/Discovery/components/CardSwiper/index.vue @@ -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(); });