feat: 实现了无限轮播的展示卡片组件
This commit is contained in:
420
src/pages/Discovery/components/CardSwiper/index.vue
Normal file
420
src/pages/Discovery/components/CardSwiper/index.vue
Normal file
@@ -0,0 +1,420 @@
|
||||
<template>
|
||||
<view
|
||||
class="card-swiper"
|
||||
:class="{ 'is-single': !canLoop }"
|
||||
@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.image" 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.desc" class="card-desc ellipsis-2">
|
||||
{{ slot.item.desc }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="canLoop"
|
||||
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", "card-click"]);
|
||||
|
||||
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 activeIndex = 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 canLoop = computed(() => props.list.length > 1);
|
||||
const progress = computed(() => {
|
||||
if (!canLoop.value) return 0;
|
||||
return clamp(deltaX.value / sideOffset, -1, 1);
|
||||
});
|
||||
|
||||
const normalizeIndex = (index, total) => {
|
||||
if (!total) return 0;
|
||||
return ((index % total) + total) % total;
|
||||
};
|
||||
|
||||
const syncActiveIndex = (incomingIndex = 0) => {
|
||||
activeIndex.value = normalizeIndex(incomingIndex, props.list.length);
|
||||
};
|
||||
|
||||
function clearSettleTimer() {
|
||||
if (settleTimer) {
|
||||
clearTimeout(settleTimer);
|
||||
settleTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
() => {
|
||||
clearSettleTimer();
|
||||
deltaX.value = 0;
|
||||
isDragging.value = false;
|
||||
isAnimating.value = false;
|
||||
const nextIndex = hasExternalModel
|
||||
? props.modelValue
|
||||
: activeIndex.value;
|
||||
syncActiveIndex(nextIndex);
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (!hasExternalModel) return;
|
||||
if (isAnimating.value || isDragging.value) return;
|
||||
syncActiveIndex(value);
|
||||
}
|
||||
);
|
||||
|
||||
const getItemKey = (index, role) => {
|
||||
const item = props.list[index] || {};
|
||||
const baseKey =
|
||||
item.id ??
|
||||
item.commodityId ??
|
||||
item.tabLabel ??
|
||||
item.title ??
|
||||
index;
|
||||
const prevIndex = normalizeIndex(activeIndex.value - 1, props.list.length);
|
||||
const nextIndex = normalizeIndex(activeIndex.value + 1, props.list.length);
|
||||
const hasDuplicateSide = prevIndex === nextIndex && role !== "current";
|
||||
|
||||
return hasDuplicateSide ? `${baseKey}-${role}` : `${baseKey}`;
|
||||
};
|
||||
|
||||
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 (!canLoop.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%, -50%, 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 (!canLoop.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 (!props.list.length) return [];
|
||||
|
||||
if (!canLoop.value) {
|
||||
return [
|
||||
{
|
||||
key: getItemKey(activeIndex.value, "current"),
|
||||
role: "current",
|
||||
index: activeIndex.value,
|
||||
item: props.list[activeIndex.value],
|
||||
style: buildCardStyle("current"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const prevIndex = normalizeIndex(activeIndex.value - 1, props.list.length);
|
||||
const nextIndex = normalizeIndex(activeIndex.value + 1, props.list.length);
|
||||
|
||||
return [
|
||||
{
|
||||
key: getItemKey(prevIndex, "prev"),
|
||||
role: "prev",
|
||||
index: prevIndex,
|
||||
item: props.list[prevIndex],
|
||||
style: buildCardStyle("prev"),
|
||||
},
|
||||
{
|
||||
key: getItemKey(activeIndex.value, "current"),
|
||||
role: "current",
|
||||
index: activeIndex.value,
|
||||
item: props.list[activeIndex.value],
|
||||
style: buildCardStyle("current"),
|
||||
},
|
||||
{
|
||||
key: getItemKey(nextIndex, "next"),
|
||||
role: "next",
|
||||
index: nextIndex,
|
||||
item: props.list[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 nextIndex = normalizeIndex(activeIndex.value + step, props.list.length);
|
||||
activeIndex.value = nextIndex;
|
||||
emit("update:modelValue", nextIndex);
|
||||
emit("change", nextIndex);
|
||||
resetGesture();
|
||||
}, DURATION);
|
||||
};
|
||||
|
||||
const handleTouchStart = (event) => {
|
||||
if (!canLoop.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 (!canLoop.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 (!canLoop.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 (!canLoop.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("card-click", slot.item, slot.index);
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearSettleTimer();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "./styles/index.scss";
|
||||
</style>
|
||||
97
src/pages/Discovery/components/CardSwiper/styles/index.scss
Normal file
97
src/pages/Discovery/components/CardSwiper/styles/index.scss
Normal file
@@ -0,0 +1,97 @@
|
||||
.card-swiper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.swiper-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 278px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.swiper-card {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 236px;
|
||||
height: 234px;
|
||||
max-width: calc(100% - 56px);
|
||||
transform-origin: center center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.card-shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 12px 20px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
|
||||
.card-media {
|
||||
width: 100%;
|
||||
height: 142px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.card-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 12px;
|
||||
min-width: 50px;
|
||||
max-width: 100%;
|
||||
height: 18px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
background: #fff4db;
|
||||
color: #d78621;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin-top: 6px;
|
||||
color: #172033;
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
margin-top: 2px;
|
||||
color: #7f8ea3;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.card-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.is-current .card-shell {
|
||||
box-shadow: 0 12px 20px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.is-single .swiper-stage {
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -1,22 +1,63 @@
|
||||
<template>
|
||||
<view>
|
||||
<FindTabs v-model="activeIndex" @change="handleTabChange" />
|
||||
<FindTabs
|
||||
v-model="activeIndex"
|
||||
:tabs="discoveryTabs"
|
||||
/>
|
||||
<CardSwiper
|
||||
:list="discoveryCards"
|
||||
@card-click="cardClick" />
|
||||
<QuickQuestions />
|
||||
|
||||
</view>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import FindTabs from "./components/FindTabs/index.vue";
|
||||
import CardSwiper from "./components/CardSwiper/index.vue";
|
||||
import QuickQuestions from "./components/QuickQuestions/index.vue";
|
||||
import discoveryCover from "@/components/ImageSwiper/images/2025-07-12_180248.jpg";
|
||||
|
||||
const activeIndex = ref(0);
|
||||
const discoveryCards = ref([
|
||||
{
|
||||
title: "卧龙潭漂流",
|
||||
tag: "夏日必玩",
|
||||
desc: "15米极速落差,极致清凉体验。",
|
||||
image: discoveryCover,
|
||||
tabLabel: "卧龙潭",
|
||||
},
|
||||
{
|
||||
title: "峡谷徒步",
|
||||
tag: "清凉徒步",
|
||||
desc: "穿行山谷栈道,沿路都是瀑布与云雾。",
|
||||
image: discoveryCover,
|
||||
tabLabel: "峡谷探秘",
|
||||
},
|
||||
{
|
||||
title: "竹筏观景",
|
||||
tag: "轻松观光",
|
||||
desc: "低头可见鱼群,抬眼就是山水画卷。",
|
||||
image: discoveryCover,
|
||||
tabLabel: "竹筏观景",
|
||||
},
|
||||
{
|
||||
title: "秘境潜游",
|
||||
tag: "人气推荐",
|
||||
desc: "近距离观察水下生态,沉浸感很强。",
|
||||
image: discoveryCover,
|
||||
tabLabel: "秘境潜游",
|
||||
},
|
||||
]);
|
||||
|
||||
const handleTabChange = (index) => {
|
||||
activeIndex.value = index;
|
||||
const discoveryTabs = computed(() =>
|
||||
discoveryCards.value.map((item) => ({
|
||||
label: item.tabLabel || item.title,
|
||||
}))
|
||||
);
|
||||
|
||||
const cardClick = (item) => {
|
||||
console.log(JSON.stringify(item));
|
||||
};
|
||||
|
||||
</script>
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user