Uniapp 3D 轮播图 轮播视频 可循环组件

效果图呈现如下

代码如下:

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>
相关推荐
前端付豪2 小时前
12、为什么在 <script> 里写 export 会报错?
前端·javascript
Junsen2 小时前
electron窗口层级与dock窗口列表
前端·electron
一个小潘桃鸭2 小时前
需求:el-upload加上文件上传进度
前端
梦醒繁华尽2 小时前
使用vue-element-plus-x完成AI问答对话,markdown展示Echarts展示
前端·javascript·vue.js
鹏多多3 小时前
关于React父组件调用子组件方法forwardRef的详解和案例
前端·javascript·react.js
吃饺子不吃馅3 小时前
AntV X6 核心插件帮你飞速创建画布
前端·css·svg
葡萄城技术团队4 小时前
SpreadJS 纯前端表格控件:破解中国式复杂报表技术文档
前端
Humbunklung4 小时前
C# 压缩解压文件的常用方法
前端·c#·压缩解压
通往曙光的路上4 小时前
时隔一天第二阶段他来了 html!!!!!!!!!!!
前端·html