586 lines
14 KiB
Vue
586 lines
14 KiB
Vue
<template>
|
||
<view class="simple-vertical-swiper" :style="{ height: height + 'px' }">
|
||
<view
|
||
class="swiper-wrapper"
|
||
@touchstart.stop="onTouchStart"
|
||
@touchmove.stop.prevent="onTouchMove"
|
||
@touchend.stop="onTouchEnd"
|
||
>
|
||
<view
|
||
v-for="(item, index) in items"
|
||
:key="index"
|
||
class="swiper-slide"
|
||
:class="{ 'swiper-slide-active': index === currentIndex }"
|
||
:style="getSlideStyle(index)"
|
||
>
|
||
<!-- 公告类型 -->
|
||
<view v-if="type === 'notice'" class="notice-item">
|
||
<text class="notice-icon">📢</text>
|
||
<text class="notice-text">{{ item.content || item }}</text>
|
||
</view>
|
||
|
||
<!-- 商品类型 -->
|
||
<view v-else-if="type === 'product'" class="product-item">
|
||
<image
|
||
class="product-image"
|
||
:src="item.image"
|
||
mode="aspectFill"
|
||
></image>
|
||
<view class="product-info">
|
||
<text class="product-name">{{ item.name }}</text>
|
||
<text class="product-price">¥{{ item.price }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 内容类型 -->
|
||
<view v-else-if="type === 'content'" class="content-item">
|
||
<text class="content-title">{{ item.title }}</text>
|
||
<text class="content-desc">{{ item.description }}</text>
|
||
</view>
|
||
|
||
<!-- 文本类型 -->
|
||
<view v-else-if="type === 'text'" class="text-item">
|
||
<text class="text-content">{{ item.text || item }}</text>
|
||
</view>
|
||
|
||
<!-- 自定义插槽 -->
|
||
<slot v-else :item="item" :index="index">
|
||
<view class="default-item">
|
||
<text>{{ item.text || item.content || item }}</text>
|
||
</view>
|
||
</slot>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 指示器 -->
|
||
<view v-if="showIndicators && items.length > 1" class="indicators">
|
||
<view
|
||
v-for="(item, index) in items"
|
||
:key="index"
|
||
class="indicator"
|
||
:class="{ active: index === currentIndex }"
|
||
></view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
export default {
|
||
name: 'SimpleVerticalSwiper',
|
||
props: {
|
||
// 轮播数据
|
||
items: {
|
||
type: Array,
|
||
default: () => [],
|
||
},
|
||
// 数据类型:notice(公告), product(商品), content(内容), text(文本), custom(自定义)
|
||
type: {
|
||
type: String,
|
||
default: 'text',
|
||
},
|
||
// 组件高度
|
||
height: {
|
||
type: Number,
|
||
default: 60,
|
||
},
|
||
// 是否自动轮播
|
||
autoplay: {
|
||
type: Boolean,
|
||
default: true,
|
||
},
|
||
// 自动轮播间隔时间(ms)
|
||
interval: {
|
||
type: Number,
|
||
default: 3000,
|
||
},
|
||
// 动画持续时间(ms)
|
||
duration: {
|
||
type: Number,
|
||
default: 300,
|
||
},
|
||
// 是否显示指示器
|
||
showIndicators: {
|
||
type: Boolean,
|
||
default: false,
|
||
},
|
||
// 是否循环轮播
|
||
circular: {
|
||
type: Boolean,
|
||
default: true,
|
||
},
|
||
},
|
||
data() {
|
||
return {
|
||
currentIndex: 0,
|
||
timer: null,
|
||
isTransitioning: false,
|
||
// 触摸相关
|
||
touchStartY: 0,
|
||
touchStartTime: 0,
|
||
isTouching: false,
|
||
}
|
||
},
|
||
computed: {
|
||
itemsLength() {
|
||
return this.items.length
|
||
},
|
||
},
|
||
watch: {
|
||
items: {
|
||
handler() {
|
||
this.initSwiper()
|
||
},
|
||
immediate: true,
|
||
},
|
||
autoplay(newVal) {
|
||
if (newVal) {
|
||
this.startAutoplay()
|
||
} else {
|
||
this.clearTimer()
|
||
}
|
||
},
|
||
},
|
||
mounted() {
|
||
this.initSwiper()
|
||
},
|
||
beforeDestroy() {
|
||
this.clearTimer()
|
||
},
|
||
// uniapp 生命周期
|
||
onReady() {
|
||
this.initSwiper()
|
||
},
|
||
onUnload() {
|
||
this.clearTimer()
|
||
},
|
||
methods: {
|
||
// 获取滑块样式 - 解决循环轮播动画连续性问题
|
||
getSlideStyle(index) {
|
||
if (this.itemsLength <= 1) {
|
||
return {
|
||
transform: 'translateY(0px)',
|
||
opacity: 1,
|
||
zIndex: 1,
|
||
}
|
||
}
|
||
|
||
let translateY = 0
|
||
let opacity = 0.6
|
||
let zIndex = 1
|
||
|
||
if (index === this.currentIndex) {
|
||
// 当前激活项
|
||
translateY = 0
|
||
opacity = 1
|
||
zIndex = 10
|
||
} else {
|
||
// 计算偏移量
|
||
let offset = index - this.currentIndex
|
||
|
||
// 循环轮播的关键:选择最短路径
|
||
if (this.circular && this.itemsLength > 2) {
|
||
const halfLength = this.itemsLength / 2
|
||
|
||
// 如果偏移量的绝对值大于一半,说明走相反方向更近
|
||
if (offset > halfLength) {
|
||
offset = offset - this.itemsLength // 从负方向走
|
||
} else if (offset < -halfLength) {
|
||
offset = offset + this.itemsLength // 从正方向走
|
||
}
|
||
}
|
||
|
||
translateY = offset * this.height
|
||
|
||
// 控制可见性,只显示相邻的项目
|
||
const absOffset = Math.abs(offset)
|
||
if (absOffset <= 1) {
|
||
opacity = absOffset === 1 ? 0.3 : 0.6
|
||
zIndex = absOffset === 1 ? 5 : 1
|
||
} else {
|
||
opacity = 0
|
||
zIndex = 0
|
||
}
|
||
}
|
||
|
||
const transition =
|
||
this.isTransitioning && !this.isTouching
|
||
? `all ${this.duration}ms cubic-bezier(0.25, 0.46, 0.45, 0.94)`
|
||
: 'none'
|
||
|
||
return {
|
||
transform: `translateY(${translateY}px)`,
|
||
opacity: opacity,
|
||
zIndex: zIndex,
|
||
transition: transition,
|
||
}
|
||
},
|
||
|
||
// 初始化轮播
|
||
initSwiper() {
|
||
console.log(
|
||
'初始化轮播, items数量:',
|
||
this.itemsLength,
|
||
'autoplay:',
|
||
this.autoplay
|
||
)
|
||
|
||
if (this.itemsLength === 0) return
|
||
|
||
this.currentIndex = 0
|
||
|
||
if (this.autoplay && this.itemsLength > 1) {
|
||
this.startAutoplay()
|
||
}
|
||
},
|
||
|
||
// 开始自动轮播
|
||
startAutoplay() {
|
||
this.clearTimer()
|
||
if (this.autoplay && this.itemsLength > 1) {
|
||
console.log('开始自动轮播,间隔:', this.interval)
|
||
this.timer = setInterval(() => {
|
||
this.next()
|
||
}, this.interval)
|
||
}
|
||
},
|
||
|
||
// 清除定时器
|
||
clearTimer() {
|
||
if (this.timer) {
|
||
clearInterval(this.timer)
|
||
this.timer = null
|
||
}
|
||
},
|
||
|
||
// 下一页
|
||
next() {
|
||
if (this.isTransitioning || this.itemsLength <= 1) return
|
||
|
||
console.log('切换到下一页,当前:', this.currentIndex)
|
||
|
||
this.isTransitioning = true
|
||
|
||
const nextIndex = this.circular
|
||
? (this.currentIndex + 1) % this.itemsLength
|
||
: Math.min(this.currentIndex + 1, this.itemsLength - 1)
|
||
|
||
this.currentIndex = nextIndex
|
||
|
||
setTimeout(() => {
|
||
this.isTransitioning = false
|
||
this.$emit('change', {
|
||
current: this.currentIndex,
|
||
currentItem: this.items[this.currentIndex],
|
||
})
|
||
}, this.duration)
|
||
},
|
||
|
||
// 上一页
|
||
prev() {
|
||
if (this.isTransitioning || this.itemsLength <= 1) return
|
||
|
||
this.isTransitioning = true
|
||
|
||
const prevIndex = this.circular
|
||
? (this.currentIndex - 1 + this.itemsLength) % this.itemsLength
|
||
: Math.max(this.currentIndex - 1, 0)
|
||
|
||
this.currentIndex = prevIndex
|
||
|
||
setTimeout(() => {
|
||
this.isTransitioning = false
|
||
this.$emit('change', {
|
||
current: this.currentIndex,
|
||
currentItem: this.items[this.currentIndex],
|
||
})
|
||
}, this.duration)
|
||
},
|
||
|
||
// 跳转到指定页
|
||
goToSlide(index) {
|
||
if (index === this.currentIndex || this.isTransitioning) return
|
||
if (index < 0 || index >= this.itemsLength) return
|
||
|
||
this.isTransitioning = true
|
||
this.currentIndex = index
|
||
|
||
setTimeout(() => {
|
||
this.isTransitioning = false
|
||
this.$emit('change', {
|
||
current: this.currentIndex,
|
||
currentItem: this.items[this.currentIndex],
|
||
})
|
||
}, this.duration)
|
||
},
|
||
|
||
// 触摸开始
|
||
onTouchStart(e) {
|
||
if (this.itemsLength <= 1) return
|
||
|
||
console.log('触摸开始')
|
||
|
||
this.clearTimer() // 停止自动轮播
|
||
this.touchStartY = e.touches[0].clientY
|
||
this.touchStartTime = Date.now()
|
||
this.isTouching = true
|
||
},
|
||
|
||
// 触摸移动
|
||
onTouchMove(e) {
|
||
if (this.isTransitioning || !this.isTouching || this.itemsLength <= 1)
|
||
return
|
||
|
||
const deltaY = e.touches[0].clientY - this.touchStartY
|
||
console.log('触摸移动, deltaY:', deltaY)
|
||
|
||
// 事件已通过修饰符处理,这里只做逻辑判断
|
||
// 可以在这里添加实时跟随手指的效果
|
||
},
|
||
|
||
// 触摸结束
|
||
onTouchEnd(e) {
|
||
if (!this.isTouching || this.itemsLength <= 1) return
|
||
|
||
this.isTouching = false
|
||
|
||
const currentY = e.changedTouches[0].clientY
|
||
const deltaY = currentY - this.touchStartY
|
||
const deltaTime = Date.now() - this.touchStartTime
|
||
const velocity = Math.abs(deltaY) / deltaTime
|
||
|
||
console.log('触摸结束, deltaY:', deltaY, 'velocity:', velocity)
|
||
|
||
// 判断是否需要切换
|
||
const threshold = this.height * 0.25 // 降低阈值,更容易触发
|
||
const velocityThreshold = 0.3
|
||
const shouldSwitch =
|
||
Math.abs(deltaY) > threshold || velocity > velocityThreshold
|
||
|
||
if (shouldSwitch) {
|
||
if (deltaY > 0) {
|
||
// 向下滑动,显示上一项
|
||
console.log('手势触发:上一页')
|
||
this.prev()
|
||
} else {
|
||
// 向上滑动,显示下一项
|
||
console.log('手势触发:下一页')
|
||
this.next()
|
||
}
|
||
} else {
|
||
console.log('手势未达到切换阈值,不切换')
|
||
}
|
||
|
||
// 重新开始自动轮播
|
||
setTimeout(() => {
|
||
if (this.autoplay) {
|
||
this.startAutoplay()
|
||
}
|
||
}, 1000)
|
||
},
|
||
},
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.simple-vertical-swiper {
|
||
position: relative;
|
||
overflow: hidden;
|
||
// 防止触摸时选中文本
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
// App端需要的设置
|
||
-webkit-overflow-scrolling: touch;
|
||
|
||
.swiper-wrapper {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
// uniapp App端触摸控制
|
||
touch-action: none;
|
||
// 防止触摸时的默认行为
|
||
-webkit-touch-callout: none;
|
||
-webkit-tap-highlight-color: transparent;
|
||
// App端滚动穿透防护
|
||
overflow: hidden;
|
||
}
|
||
|
||
.swiper-slide {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
// 防止子元素被选中
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
|
||
&.swiper-slide-active {
|
||
opacity: 1 !important;
|
||
z-index: 10 !important;
|
||
}
|
||
}
|
||
|
||
// 公告样式
|
||
.notice-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 15px;
|
||
width: calc(100% - 30px);
|
||
height: 80%;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
border-radius: 6px;
|
||
|
||
.notice-icon {
|
||
font-size: 16px;
|
||
margin-right: 8px;
|
||
color: #fff;
|
||
}
|
||
|
||
.notice-text {
|
||
color: #fff;
|
||
font-size: 14px;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
// 商品样式
|
||
.product-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 10px 15px;
|
||
width: calc(100% - 30px);
|
||
height: calc(100% - 20px);
|
||
background-color: #fff;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||
|
||
.product-image {
|
||
width: 60px;
|
||
height: 60px;
|
||
border-radius: 6px;
|
||
margin-right: 12px;
|
||
background-color: #f0f0f0;
|
||
}
|
||
|
||
.product-info {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
|
||
.product-name {
|
||
font-size: 14px;
|
||
color: #333;
|
||
margin-bottom: 4px;
|
||
font-weight: 500;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.product-price {
|
||
font-size: 16px;
|
||
color: #ff6b6b;
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 内容样式
|
||
.content-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
align-items: center;
|
||
padding: 15px;
|
||
width: calc(100% - 30px);
|
||
height: calc(100% - 30px);
|
||
text-align: center;
|
||
background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%);
|
||
border-radius: 8px;
|
||
|
||
.content-title {
|
||
font-size: 16px;
|
||
color: #fff;
|
||
font-weight: 600;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.content-desc {
|
||
font-size: 12px;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
line-height: 1.3;
|
||
}
|
||
}
|
||
|
||
// 文本样式
|
||
.text-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0 15px;
|
||
width: calc(100% - 30px);
|
||
height: 80%;
|
||
background: linear-gradient(135deg, #fd79a8 0%, #e84393 100%);
|
||
border-radius: 6px;
|
||
|
||
.text-content {
|
||
color: #fff;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
text-align: center;
|
||
}
|
||
}
|
||
|
||
// 默认样式
|
||
.default-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0 15px;
|
||
width: calc(100% - 30px);
|
||
height: 80%;
|
||
background-color: #f8f9fa;
|
||
border-radius: 6px;
|
||
border: 1px solid #e9ecef;
|
||
|
||
text {
|
||
color: #333;
|
||
font-size: 14px;
|
||
text-align: center;
|
||
}
|
||
}
|
||
|
||
// 指示器
|
||
.indicators {
|
||
position: absolute;
|
||
right: 8px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
z-index: 20;
|
||
|
||
.indicator {
|
||
width: 3px;
|
||
height: 12px;
|
||
background-color: rgba(255, 255, 255, 0.4);
|
||
border-radius: 2px;
|
||
transition: all 0.3s ease;
|
||
|
||
&.active {
|
||
background-color: rgba(255, 255, 255, 0.9);
|
||
height: 16px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|