422 lines
10 KiB
Vue
422 lines
10 KiB
Vue
<template>
|
|
<view
|
|
class="card-swiper"
|
|
:class="{ 'is-single': props.list.length <= 1 }"
|
|
@touchstart="handleTouchStart"
|
|
@touchmove.stop.prevent="handleTouchMove"
|
|
@touchend="handleTouchEnd"
|
|
@touchcancel="handleTouchCancel"
|
|
>
|
|
<view class="swiper-stage">
|
|
<view
|
|
v-for="slot in renderSlots"
|
|
:key="slot.key"
|
|
class="swiper-card"
|
|
:class="[`is-${slot.role}`, { 'is-current': slot.role === 'current' }]"
|
|
:style="slot.style"
|
|
@tap="handleCardTap(slot)"
|
|
>
|
|
<view class="card-shell">
|
|
<view class="card-media">
|
|
<image class="card-image" :src="slot.item.coverImage" mode="aspectFill" />
|
|
</view>
|
|
|
|
<view class="card-body">
|
|
<view v-if="slot.item.tag" class="card-tag">
|
|
{{ slot.item.tag }}
|
|
</view>
|
|
<view class="card-title ellipsis-1">
|
|
{{ slot.item.title }}
|
|
</view>
|
|
<view v-if="slot.item.subTitle" class="card-desc ellipsis-1">
|
|
{{ slot.item.subTitle }}
|
|
</view>
|
|
</view>
|
|
|
|
<view
|
|
v-if="canSwipe"
|
|
class="card-mask"
|
|
:style="getMaskStyle(slot.role)"
|
|
/>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, getCurrentInstance, onBeforeUnmount, ref, watch } from "vue";
|
|
|
|
const props = defineProps({
|
|
modelValue: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
list: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
});
|
|
|
|
const emit = defineEmits(["update:modelValue", "change", "didSelectItem"]);
|
|
|
|
const DURATION = 280;
|
|
const CLICK_THRESHOLD = 8;
|
|
const SWIPE_THRESHOLD = 60;
|
|
|
|
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
const lerp = (from, to, progress) => from + (to - from) * progress;
|
|
|
|
const { windowWidth = 375 } = uni.getWindowInfo();
|
|
const sideOffset = Math.max(108, Math.min(windowWidth * 0.26, 148));
|
|
const hiddenOffset = sideOffset + 92;
|
|
|
|
const activeCursor = ref(0);
|
|
const deltaX = ref(0);
|
|
const isDragging = ref(false);
|
|
const isAnimating = ref(false);
|
|
const isTapCandidate = ref(false);
|
|
const swipeStep = ref(0);
|
|
|
|
let startX = 0;
|
|
let startY = 0;
|
|
let settleTimer = null;
|
|
const instance = getCurrentInstance();
|
|
const hasExternalModel = Object.prototype.hasOwnProperty.call(
|
|
instance?.vnode.props || {},
|
|
"modelValue"
|
|
);
|
|
|
|
const actualCount = computed(() => props.list.length);
|
|
const virtualCount = computed(() => {
|
|
if (actualCount.value === 1) return 3;
|
|
return actualCount.value;
|
|
});
|
|
const canSwipe = computed(() => virtualCount.value > 1);
|
|
const progress = computed(() => {
|
|
if (!canSwipe.value) return 0;
|
|
return clamp(deltaX.value / sideOffset, -1, 1);
|
|
});
|
|
|
|
const normalizeIndex = (index, total) => {
|
|
if (!total) return 0;
|
|
return ((index % total) + total) % total;
|
|
};
|
|
|
|
const getActualIndex = (virtualIndex) => {
|
|
if (!actualCount.value) return 0;
|
|
return normalizeIndex(virtualIndex, actualCount.value);
|
|
};
|
|
|
|
const getItemByVirtualIndex = (virtualIndex) => {
|
|
if (!actualCount.value) return null;
|
|
return props.list[getActualIndex(virtualIndex)] || null;
|
|
};
|
|
|
|
const syncActiveCursor = (incomingIndex = 0) => {
|
|
activeCursor.value = normalizeIndex(incomingIndex, virtualCount.value);
|
|
};
|
|
|
|
function clearSettleTimer() {
|
|
if (settleTimer) {
|
|
clearTimeout(settleTimer);
|
|
settleTimer = null;
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => props.list,
|
|
() => {
|
|
clearSettleTimer();
|
|
deltaX.value = 0;
|
|
isDragging.value = false;
|
|
isAnimating.value = false;
|
|
const nextCursor = hasExternalModel
|
|
? props.modelValue
|
|
: activeCursor.value;
|
|
syncActiveCursor(nextCursor);
|
|
},
|
|
{ deep: true, immediate: true }
|
|
);
|
|
|
|
watch(
|
|
() => props.modelValue,
|
|
(value) => {
|
|
if (!hasExternalModel) return;
|
|
if (isAnimating.value || isDragging.value) return;
|
|
syncActiveCursor(value);
|
|
}
|
|
);
|
|
|
|
const getItemKey = (virtualIndex, role) => {
|
|
const item = getItemByVirtualIndex(virtualIndex) || {};
|
|
const baseKey =
|
|
item.id ??
|
|
item.commodityId ??
|
|
item.tabLabel ??
|
|
item.title ??
|
|
virtualIndex;
|
|
|
|
return `${baseKey}-${virtualIndex}-${role}`;
|
|
};
|
|
|
|
const states = {
|
|
hiddenLeft: {
|
|
x: -hiddenOffset,
|
|
scale: 0.68,
|
|
opacity: 0,
|
|
},
|
|
left: {
|
|
x: -sideOffset,
|
|
scale: 1 / 1.2,
|
|
opacity: 0.36,
|
|
},
|
|
center: {
|
|
x: 0,
|
|
scale: 1,
|
|
opacity: 1,
|
|
},
|
|
right: {
|
|
x: sideOffset,
|
|
scale: 1 / 1.2,
|
|
opacity: 0.36,
|
|
},
|
|
hiddenRight: {
|
|
x: hiddenOffset,
|
|
scale: 0.68,
|
|
opacity: 0,
|
|
},
|
|
};
|
|
|
|
const interpolateState = (fromKey, toKey, rate) => {
|
|
const from = states[fromKey];
|
|
const to = states[toKey];
|
|
return {
|
|
x: lerp(from.x, to.x, rate),
|
|
scale: lerp(from.scale, to.scale, rate),
|
|
opacity: lerp(from.opacity, to.opacity, rate),
|
|
};
|
|
};
|
|
|
|
const getLayerDirection = () => {
|
|
if (isDragging.value && deltaX.value !== 0) {
|
|
return deltaX.value < 0 ? 1 : -1;
|
|
}
|
|
if (isAnimating.value) {
|
|
return swipeStep.value;
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
const getCardZIndex = (role) => {
|
|
const direction = getLayerDirection();
|
|
|
|
if (direction === 1) {
|
|
if (role === "next") return 4;
|
|
if (role === "current") return 3;
|
|
return 1;
|
|
}
|
|
|
|
if (direction === -1) {
|
|
if (role === "prev") return 4;
|
|
if (role === "current") return 3;
|
|
return 1;
|
|
}
|
|
|
|
if (role === "current") return 3;
|
|
return 1;
|
|
};
|
|
|
|
const getSlotState = (role) => {
|
|
if (!canSwipe.value) return states.center;
|
|
|
|
const currentProgress = progress.value;
|
|
if (currentProgress < 0) {
|
|
const rate = Math.abs(currentProgress);
|
|
if (role === "prev") return interpolateState("left", "hiddenLeft", rate);
|
|
if (role === "current") return interpolateState("center", "left", rate);
|
|
return interpolateState("right", "center", rate);
|
|
}
|
|
|
|
if (currentProgress > 0) {
|
|
const rate = currentProgress;
|
|
if (role === "prev") return interpolateState("left", "center", rate);
|
|
if (role === "current") return interpolateState("center", "right", rate);
|
|
return interpolateState("right", "hiddenRight", rate);
|
|
}
|
|
|
|
if (role === "prev") return states.left;
|
|
if (role === "next") return states.right;
|
|
return states.center;
|
|
};
|
|
|
|
const buildCardStyle = (role) => {
|
|
const state = getSlotState(role);
|
|
return {
|
|
transform: `translate3d(-50%, 0, 0) translateX(${state.x}px) scale(${state.scale})`,
|
|
opacity: state.opacity,
|
|
zIndex: getCardZIndex(role),
|
|
transition: isDragging.value
|
|
? "none"
|
|
: `transform ${DURATION}ms ease, opacity ${DURATION}ms ease`,
|
|
};
|
|
};
|
|
|
|
const getMaskOpacity = (role) => {
|
|
if (!canSwipe.value) return 0;
|
|
|
|
const currentProgress = progress.value;
|
|
const baseOpacity = 0.42;
|
|
|
|
if (currentProgress < 0) {
|
|
const rate = Math.abs(currentProgress);
|
|
if (role === "next") return baseOpacity * (1 - rate);
|
|
if (role === "current") return baseOpacity * rate;
|
|
return baseOpacity;
|
|
}
|
|
|
|
if (currentProgress > 0) {
|
|
const rate = currentProgress;
|
|
if (role === "prev") return baseOpacity * (1 - rate);
|
|
if (role === "current") return baseOpacity * rate;
|
|
return baseOpacity;
|
|
}
|
|
|
|
return role === "current" ? 0 : baseOpacity;
|
|
};
|
|
|
|
const getMaskStyle = (role) => ({
|
|
opacity: getMaskOpacity(role),
|
|
transition: isDragging.value ? "none" : "opacity 120ms ease-out",
|
|
});
|
|
|
|
const renderSlots = computed(() => {
|
|
if (!actualCount.value) return [];
|
|
|
|
const prevIndex = normalizeIndex(activeCursor.value - 1, virtualCount.value);
|
|
const nextIndex = normalizeIndex(activeCursor.value + 1, virtualCount.value);
|
|
|
|
return [
|
|
{
|
|
key: getItemKey(prevIndex, "prev"),
|
|
role: "prev",
|
|
index: getActualIndex(prevIndex),
|
|
item: getItemByVirtualIndex(prevIndex),
|
|
style: buildCardStyle("prev"),
|
|
},
|
|
{
|
|
key: getItemKey(activeCursor.value, "current"),
|
|
role: "current",
|
|
index: getActualIndex(activeCursor.value),
|
|
item: getItemByVirtualIndex(activeCursor.value),
|
|
style: buildCardStyle("current"),
|
|
},
|
|
{
|
|
key: getItemKey(nextIndex, "next"),
|
|
role: "next",
|
|
index: getActualIndex(nextIndex),
|
|
item: getItemByVirtualIndex(nextIndex),
|
|
style: buildCardStyle("next"),
|
|
},
|
|
];
|
|
});
|
|
|
|
const resetGesture = () => {
|
|
deltaX.value = 0;
|
|
isDragging.value = false;
|
|
isAnimating.value = false;
|
|
isTapCandidate.value = false;
|
|
swipeStep.value = 0;
|
|
};
|
|
|
|
const finishSwipe = (step) => {
|
|
isAnimating.value = true;
|
|
swipeStep.value = step;
|
|
deltaX.value = step > 0 ? -sideOffset : sideOffset;
|
|
clearSettleTimer();
|
|
settleTimer = setTimeout(() => {
|
|
const nextCursor = normalizeIndex(activeCursor.value + step, virtualCount.value);
|
|
activeCursor.value = nextCursor;
|
|
const actualIndex = getActualIndex(nextCursor);
|
|
emit("update:modelValue", actualIndex);
|
|
emit("change", actualIndex);
|
|
resetGesture();
|
|
}, DURATION);
|
|
};
|
|
|
|
const handleTouchStart = (event) => {
|
|
if (!canSwipe.value || isAnimating.value) return;
|
|
|
|
const touch = event.touches?.[0] || event.changedTouches?.[0];
|
|
if (!touch) return;
|
|
|
|
startX = touch.clientX;
|
|
startY = touch.clientY;
|
|
deltaX.value = 0;
|
|
isDragging.value = true;
|
|
isTapCandidate.value = true;
|
|
};
|
|
|
|
const handleTouchMove = (event) => {
|
|
if (!canSwipe.value || !isDragging.value || isAnimating.value) return;
|
|
|
|
const touch = event.touches?.[0] || event.changedTouches?.[0];
|
|
if (!touch) return;
|
|
|
|
const moveX = touch.clientX - startX;
|
|
const moveY = touch.clientY - startY;
|
|
|
|
if (Math.abs(moveX) > CLICK_THRESHOLD || Math.abs(moveY) > CLICK_THRESHOLD) {
|
|
isTapCandidate.value = false;
|
|
}
|
|
|
|
if (Math.abs(moveY) > Math.abs(moveX) && Math.abs(moveY) > 12) {
|
|
deltaX.value = 0;
|
|
return;
|
|
}
|
|
|
|
deltaX.value = clamp(moveX, -hiddenOffset, hiddenOffset);
|
|
};
|
|
|
|
const handleTouchEnd = () => {
|
|
if (!canSwipe.value || !isDragging.value) return;
|
|
|
|
isDragging.value = false;
|
|
|
|
if (isTapCandidate.value) {
|
|
deltaX.value = 0;
|
|
return;
|
|
}
|
|
|
|
if (Math.abs(deltaX.value) >= SWIPE_THRESHOLD) {
|
|
finishSwipe(deltaX.value > 0 ? -1 : 1);
|
|
return;
|
|
}
|
|
|
|
deltaX.value = 0;
|
|
};
|
|
|
|
const handleTouchCancel = () => {
|
|
if (!canSwipe.value) return;
|
|
clearSettleTimer();
|
|
deltaX.value = 0;
|
|
isDragging.value = false;
|
|
isAnimating.value = false;
|
|
isTapCandidate.value = false;
|
|
swipeStep.value = 0;
|
|
};
|
|
|
|
const handleCardTap = (slot) => {
|
|
if (slot.role !== "current" || isDragging.value || isAnimating.value) return;
|
|
emit("didSelectItem", slot.item, slot.index);
|
|
};
|
|
|
|
onBeforeUnmount(() => {
|
|
clearSettleTimer();
|
|
});
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
@import "./styles/index.scss";
|
|
</style>
|