web-base-h5/components/simple-vertical-swiper.vue

659 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="simple-vertical-swiper" :style="{ height: height + 'rpx' }">
<view
class="swiper-wrapper"
@touchstart.stop="onTouchStart"
@touchmove.stop.prevent="onTouchMove"
@touchend.stop="onTouchEnd"
>
<view
v-for="(item, index) in displayItems"
:key="index"
class="swiper-slide"
:class="{ 'swiper-slide-active': index === virtualCurrentIndex }"
: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="getOriginalIndex(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, originalItemIndex) in items"
:key="originalItemIndex"
class="indicator"
:class="{ active: originalItemIndex === 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, // Index in original items array
virtualCurrentIndex: 0, // Index in displayItems array
timer: null,
isTransitioning: false,
isJumping: false, // Flag for instant jumps, to disable CSS transition
touchStartY: 0,
touchStartTime: 0,
isTouching: false,
}
},
computed: {
originalItemsLength() {
return this.items.length
},
displayItems() {
if (!this.items || this.originalItemsLength === 0) {
return []
}
if (!this.circular) {
return this.items
}
if (this.originalItemsLength === 1) {
return [this.items[0], this.items[0], this.items[0]]
}
const lastItem = this.items[this.originalItemsLength - 1]
const firstItem = this.items[0]
return [lastItem, ...this.items, firstItem]
},
},
watch: {
items: {
handler() {
this.initSwiperState()
},
immediate: true,
},
autoplay(newVal) {
if (this.originalItemsLength === 0) return
if (newVal) {
this.startAutoplay()
} else {
this.clearTimer()
}
},
circular() {
this.initSwiperState()
},
virtualCurrentIndex() {
// This watcher can be used for debugging or complex state reactions if needed
},
},
mounted() {
// Initialization is handled by immediate watch on items
},
beforeDestroy() {
this.clearTimer()
},
// uniapp 生命周期
onReady() {
this.initSwiperState()
},
onUnload() {
this.clearTimer()
},
methods: {
getOriginalIndex(displayIndex) {
if (this.originalItemsLength === 0) return 0
if (!this.circular) {
return displayIndex
}
if (this.originalItemsLength === 1) {
return 0
}
if (displayIndex === 0) return this.originalItemsLength - 1
if (displayIndex === this.displayItems.length - 1) return 0
if (displayIndex > 0 && displayIndex <= this.originalItemsLength) {
return displayIndex - 1
}
return 0 // Fallback, should ideally not be reached with correct logic
},
initSwiperState() {
this.clearTimer()
if (this.originalItemsLength === 0) {
this.currentIndex = 0
this.virtualCurrentIndex = 0
if (this.autoplay) this.startAutoplay() // Try to start autoplay even if no items initially
return
}
if (this.circular) {
if (this.originalItemsLength === 1) {
this.virtualCurrentIndex = 1
this.currentIndex = 0
} else {
this.virtualCurrentIndex = 1
this.currentIndex = 0
}
} else {
this.virtualCurrentIndex = 0
this.currentIndex = 0
}
// Ensure DOM is updated if items change, then start autoplay
this.$nextTick(() => {
if (this.autoplay) {
this.startAutoplay()
}
})
},
getSlideStyle(indexInDisplayItems) {
const offset = indexInDisplayItems - this.virtualCurrentIndex
const translateY = offset * this.height
let opacity = 0.6
let zIndex = 1
if (offset === 0) {
// Active slide
opacity = 1
zIndex = 10
} else if (Math.abs(offset) === 1) {
// Adjacent slides
opacity = 0.3
zIndex = 5
} else {
// Far away slides
opacity = 0
zIndex = 0
}
const transition =
(this.isTransitioning || this.isJumping) &&
!this.isTouching &&
!(this.isJumping && !this.isTransitioning) // Allow transition if only isTransitioning is true
? this.isJumping
? 'none'
: `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,
}
},
startAutoplay() {
this.clearTimer()
// Condition for autoplay: must have items, and if not circular, must have more than 1 item.
const canAutoplay =
this.originalItemsLength > 0 &&
(this.circular || this.originalItemsLength > 1)
if (this.autoplay && canAutoplay) {
this.timer = setInterval(() => {
this.next()
}, this.interval)
}
},
clearTimer() {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
},
updateCurrentIndex(currentVirtualIdx, isMidJump = false) {
const oldOriginalIndex = this.currentIndex
let newOriginalIndex = 0
if (this.originalItemsLength === 0) {
this.currentIndex = 0
return
}
if (!this.circular) {
newOriginalIndex = currentVirtualIdx
} else if (this.originalItemsLength === 1) {
newOriginalIndex = 0
} else {
if (currentVirtualIdx === 0) {
newOriginalIndex = this.originalItemsLength - 1
} else if (currentVirtualIdx === this.displayItems.length - 1) {
newOriginalIndex = 0
} else {
newOriginalIndex = currentVirtualIdx - 1
}
}
this.currentIndex = newOriginalIndex
// Emit change only if it's not a mid-jump correction that results in the same original index visually
// Or if the original index actually changed.
if (oldOriginalIndex !== newOriginalIndex || !isMidJump) {
this.$emit('change', {
current: this.currentIndex,
currentItem: this.items[this.currentIndex],
})
}
},
moveTo(targetVirtualIndex, direction) {
// direction can be 'next' or 'prev', used to emit correct original index during transition
if (this.isTransitioning && !this.isJumping) return
if (this.originalItemsLength === 0) return
this.isTransitioning = true
this.virtualCurrentIndex = targetVirtualIndex
// Emit change based on where we are going (even if it's a clone)
this.updateCurrentIndex(targetVirtualIndex)
setTimeout(() => {
this.isTransitioning = false
if (this.circular) {
this.checkAndCorrectLoopBoundary()
}
}, this.duration)
},
checkAndCorrectLoopBoundary() {
if (this.originalItemsLength <= 1 && this.circular) {
// Special handling for 1 item circular
// displayItems = [item, item, item], virtualCurrentIndex can be 0, 1, 2
if (this.virtualCurrentIndex !== 1) {
this.isJumping = true
this.virtualCurrentIndex = 1 // Jump to the middle "real" one
this.updateCurrentIndex(1, true)
this.$nextTick(() => {
this.isJumping = false
})
}
return
}
if (this.originalItemsLength < 2 || !this.circular) return // Only for 2+ items circular
const firstRealItemVirtualIdx = 1
const lastRealItemVirtualIdx = this.originalItemsLength
let jumpToVirtualIndex = -1
if (this.virtualCurrentIndex === 0) {
jumpToVirtualIndex = lastRealItemVirtualIdx
} else if (this.virtualCurrentIndex === this.displayItems.length - 1) {
jumpToVirtualIndex = firstRealItemVirtualIdx
}
if (jumpToVirtualIndex !== -1) {
this.isJumping = true
this.virtualCurrentIndex = jumpToVirtualIndex
this.updateCurrentIndex(jumpToVirtualIndex, true) // Update original index reflecting the jump destination
this.$nextTick(() => {
this.isJumping = false
})
}
},
next() {
if (this.originalItemsLength === 0) return
if (
!this.circular &&
this.virtualCurrentIndex >= this.originalItemsLength - 1
)
return
let targetVirtualIndex = this.virtualCurrentIndex + 1
this.moveTo(targetVirtualIndex, 'next')
},
prev() {
if (this.originalItemsLength === 0) return
if (!this.circular && this.virtualCurrentIndex <= 0) return
let targetVirtualIndex = this.virtualCurrentIndex - 1
this.moveTo(targetVirtualIndex, 'prev')
},
goToSlide(originalIndex) {
if (originalIndex < 0 || originalIndex >= this.originalItemsLength) return
// if (originalIndex === this.currentIndex && !this.isTransitioning && !this.isJumping) return;
let targetVirtualIndex
if (!this.circular) {
targetVirtualIndex = originalIndex
} else {
if (this.originalItemsLength === 1) targetVirtualIndex = 1
else targetVirtualIndex = originalIndex + 1
}
this.clearTimer()
this.moveTo(targetVirtualIndex)
if (this.autoplay) {
setTimeout(() => this.startAutoplay(), this.duration + 100)
}
},
onTouchStart(e) {
if (this.originalItemsLength === 0) return
if (!this.circular && this.originalItemsLength <= 1) return // No swipe for single non-circular item
this.clearTimer()
this.touchStartY = e.touches[0].clientY
this.touchStartTime = Date.now()
this.isTouching = true
this.isJumping = false
},
onTouchMove(e) {
if (!this.isTouching) return
},
onTouchEnd(e) {
if (!this.isTouching || this.originalItemsLength === 0) {
this.isTouching = false // Ensure isTouching is reset
return
}
this.isTouching = false
const currentY = e.changedTouches[0].clientY
const deltaY = currentY - this.touchStartY
const deltaTime = Date.now() - this.touchStartTime
const threshold = this.height * 0.2
const velocityThreshold = 0.3
const velocity = deltaTime > 0 ? Math.abs(deltaY) / deltaTime : 0
if (Math.abs(deltaY) > threshold || velocity > velocityThreshold) {
if (deltaY < 0) {
this.next()
} else if (deltaY > 0) {
this.prev()
}
} else {
// No swipe, or too small. Potentially restart autoplay if it was interrupted.
if (this.autoplay) {
this.startAutoplay()
}
}
// Autoplay restart is handled within next/prev through moveTo or if no swipe occurs
// If a swipe occurs, next/prev call moveTo, which stops autoplay via clearTimer. Then this onTouchEnd may restart.
// If no swipe, this onTouchEnd directly restarts.
// Consider a more unified autoplay restart logic after touch interaction concludes.
// For now, let's consolidate: if autoplay was on, restart it after a delay.
if (this.autoplay) {
setTimeout(() => {
if (!this.timer) this.startAutoplay() // Restart only if not already restarted by a quick succession
}, this.duration + 200) // Delay after potential transition
}
},
},
}
</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: pan-y; /* More specific touch action */
// 防止触摸时的默认行为
-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>