效果图呈现如下

代码如下:
js
<template>
<view class="swipe-media stack" :style="{ height: `${containerHeightRpx}rpx` }"
@touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd">
<view class="stack-stage">
<view
v-for="(item, index) in items"
:key="item._key_ || index"
class="stack-item"
:style="itemStyle(index)"
@click.stop="handleTap(item, index)"
>
<slot name="item" :item="item" :index="index" :active="index === currentIndex">
<image v-if="item.type === 'image'" class="media" :src="item.src" mode="aspectFill" />
<video v-else-if="item.type === 'video'" class="media" :src="item.src" :controls="true"></video>
<view v-else class="media media-placeholder">Unsupported</view>
</slot>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch, getCurrentInstance, nextTick, onMounted } from 'vue'
type MediaItem = {
type: 'image' | 'video' | string
src: string
_key_?: string
[id: string]: any
}
const props = withDefaults(defineProps<{
items: MediaItem[]
itemWidth?: number // rpx
itemHeight?: number // rpx
gap?: number // rpx (视觉间隔)
threshold?: number // px
loop?: boolean
visibleCount?: number // 可见层数(奇数,建议5)
depthScale?: number // 每一层递减缩放系数 0~1
enable3d?: boolean
followSlotSize?: boolean // 跟随插槽内容尺寸
}>(), {
items: () => [],
itemWidth: 300,
itemHeight: 200,
gap: 20,
threshold: 30,
loop: true,
visibleCount: 5,
depthScale: 0.85,
enable3d: false,
followSlotSize: false
})
const emit = defineEmits<{
(e: 'change', payload: { index: number }): void
(e: 'itemTap', payload: { item: MediaItem, index: number }): void
}>()
const app = getCurrentInstance()
const currentIndex = ref(0)
const startX = ref(0)
const deltaX = ref(0)
const isTouching = ref(false)
const measuredWidthPx = ref(0)
const measuredHeightPx = ref(0)
const measuredReady = computed(() => measuredWidthPx.value > 0 && measuredHeightPx.value > 0)
const halfVisible = computed(() => Math.floor(Math.max(1, props.visibleCount) / 2))
const itemWidthPxProp = computed(() => uni.upx2px(props.itemWidth))
const itemHeightPxProp = computed(() => uni.upx2px(props.itemHeight))
const gapPx = computed(() => uni.upx2px(props.gap))
const effItemWidthPx = computed(() => props.followSlotSize ? measuredWidthPx.value : itemWidthPxProp.value)
const effItemHeightPx = computed(() => props.followSlotSize ? measuredHeightPx.value : itemHeightPxProp.value)
const containerHeightRpx = computed(() => props.followSlotSize ? Math.max(props.itemHeight, Math.round(effItemHeightPx.value * 750 / uni.getSystemInfoSync().windowWidth)) : props.itemHeight)
function measureOnce() {
nextTick(() => {
uni.createSelectorQuery().in(app?.proxy as any).select('.stack-item').boundingClientRect((rect: any) => {
if (rect && rect.width && rect.height) {
measuredWidthPx.value = rect.width
measuredHeightPx.value = rect.height
}
}).exec()
})
}
onMounted(() => {
if (props.followSlotSize) measureOnce()
})
function clampIndex(idx: number) {
if (shouldLoop.value) return (idx + props.items.length) % props.items.length
return Math.max(0, Math.min(props.items.length - 1, idx))
}
function relativePos(index: number) {
let rel = index - currentIndex.value
if (shouldLoop.value) {
const L = props.items.length
rel = ((rel % L) + L) % L
if (rel > L / 2) rel = rel - L
}
return rel
}
function isVisible(index: number) {
const r = relativePos(index)
return Math.abs(r) <= halfVisible.value
}
function itemStyle(index: number) {
if (!isVisible(index)) {
return {
opacity: 0,
transform: 'translate3d(0,0,-1000px)'
}
}
if (props.followSlotSize && !measuredReady.value) {
return { opacity: 0 }
}
const r = relativePos(index)
const step = effItemWidthPx.value + gapPx.value // 不重叠,留空
const translateX = r * step + (isTouching.value ? deltaX.value : 0)
const scale = Math.pow(props.depthScale, Math.abs(r))
const z = 100 - Math.abs(r)
const rotateY = props.enable3d ? (r * -8) : 0
const style: any = {
left: '50%',
top: '50%',
zIndex: z as unknown as string,
opacity: 1,
transform: `translate3d(${translateX}px, 0, 0) scale(${scale}) rotateY(${rotateY}deg)`,
transition: isTouching.value ? 'none' : 'transform 300ms ease'
}
// 仅在不跟随插槽时强制尺寸与居中偏移
if (!props.followSlotSize) {
style.width = `${effItemWidthPx.value}px`
style.height = `${effItemHeightPx.value}px`
style.marginLeft = `-${effItemWidthPx.value / 2}px`
style.marginTop = `-${effItemHeightPx.value / 2}px`
} else {
// 跟随插槽:基于测量的尺寸做居中偏移
style.marginLeft = `-${effItemWidthPx.value / 2}px`
style.marginTop = `-${effItemHeightPx.value / 2}px`
}
return style
}
function snapToIndex(idx: number) {
const next = clampIndex(idx)
currentIndex.value = next
deltaX.value = 0
emit('change', { index: next })
}
function onTouchStart(e: any) {
isTouching.value = true
startX.value = e.touches?.[0]?.clientX || 0
deltaX.value = 0
}
function onTouchMove(e: any) {
if (!isTouching.value) return
const x = e.touches?.[0]?.clientX || 0
deltaX.value = x - startX.value
}
function onTouchEnd() {
isTouching.value = false
const absDelta = Math.abs(deltaX.value)
if (absDelta > props.threshold) {
if (deltaX.value < 0) {
snapToIndex(currentIndex.value + 1)
} else {
snapToIndex(currentIndex.value - 1)
}
} else {
deltaX.value = 0
}
}
function handleTap(item: MediaItem, index: number) {
emit('itemTap', { item, index })
}
watch(() => props.items, (val) => {
if (!val?.length) return
const init = val.length >= 2 ? 1 : 0
currentIndex.value = init
deltaX.value = 0
if (props.followSlotSize) measureOnce()
}, { immediate: true })
// 计算是否应该启用循环
const shouldLoop = computed(() => props.loop && props.items.length >= 2)
</script>
<style scoped lang="scss">
.swipe-media.stack {
position: relative;
width: 100%;
overflow: visible;
perspective: 1000px;
display: flex;
align-items: center;
justify-content: center;
}
.stack-stage {
position: relative;
width: 100%;
height: 100%;
}
.stack-item {
position: absolute;
border-radius: 12rpx;
// background: #fff;
overflow: hidden;
// box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.08);
}
.media {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.media-placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
color: #888;
background: #fafafa;
}
</style>
使用方式:
js
<view
v-if="housingInfo.news_list && housingInfo.news_list.length > 0"
class="swiper-container"
>
<view class="swiper-title">视频解读</view>
<view class="swiper">
<SwipeMediaCarousel
:items="housingInfo.news_list"
:itemWidth="368"
:itemHeight="492"
:gap="20"
:visibleCount="5"
:depthScale="0.9"
:enable3d="false"
@itemTap="onNewsTap"
@change="onNewsChange"
>
<template #item="{ item, index, active }">
<view class="swiper-item">
<image
class="swiper-image"
@click="handlePreviewCover(item)"
:src="item.cover_image"
mode="aspectFill"
/>
</view>
</template>
</SwipeMediaCarousel>
</view>
</view>