TS自动轮播图
App开发中经常用到这种轮播图组件。

最近在做Vue商城类的应用时正好用到,整理记录一下一遍后续使用。主要逻辑就是通过定时器间隔一定时间移动显示内容到对应位置改变offset,需要特殊处理的地方是滚动到最后一页时,把首页拼接到后边,下一次滚动时滚到第一页然后重置,形成循环往复自动播放。本组件还添加了处理手动滑动以及添加页码
主要逻辑代码
import { useChildren } from '@/use/useChildren'
import { doubleRaf } from '@/utils/raf'
import { clamp, createNamespace } from 'vant/lib/utils'
import { ref, defineComponent, computed, reactive, onMounted, onBeforeUnmount } from 'vue'
import './OpSwipe.scss'
//import OpSwipeItem from './OpSwipeItem'
import { useTouch } from '@/use/useTouch'
const [name, bem] = createNamespace('swipe')
export const SWIPE_KEY = Symbol('swipe')
export type SwipeState = {
rect: { width: number; height: number } | null
width: number
height: number
offset: number
active: number
swiping: boolean
}
export default defineComponent({
name,
props: {
//是否自动播放
autoplay: {
type: Number,
default: 0,
},
//时间间隔
duration: {
type: Number,
default: 1000,
},
//是否循环播放
loop: {
type: Boolean,
default: true,
},
//是否展示页码
showIndicators: {
type: Boolean,
default: true,
},
//方向 水平还是数值方向
vertical: {
type: Boolean,
defalut: false,
},
//滚动方向是否正方向(下/右为正)
forward: {
type: Boolean,
defalut: false,
},
},
setup(props, { slots }) {
const root = ref()
const track = ref()
const state = reactive<SwipeState>({
rect: null,
offset: 0,
width: 0,
height: 0,
active: 0,
swiping: false,
})
const { children, linkChildren } = useChildren(SWIPE_KEY)
const count = computed(() => children.length)
const size = computed(() => state[props.vertical ? 'height' : 'width'])
const trackSize = computed(() => count.value * size.value)
const firstChild = computed(() => track.value.children[0])
const lastChild = computed(() => track.value.children[count.value - 1])
const pStyle = computed(() => {
const x = props.vertical ? 'bottom' : 'left'
const y = props.vertical ? 'right' : 'bottom'
const style = {
Position: 'absolute',
[x]: '50%',
[y]: '10px',
transform: `translate${props.vertical ? 'Y' : 'X'}(-50%)`,
display: 'flex',
FlexDirection: `${props.vertical ? 'column' : 'row'}`,
}
return style
})
const trackStyle = computed(() => {
const mainAxis = props.vertical ? 'height' : 'width'
const style = {
transform: `translate${props.vertical ? 'Y' : 'X'}(${state.offset}px)`,
transitionDuration: `${state.swiping ? 0 : props.duration}ms`,
[mainAxis]: `${trackSize.value}px`,
display: 'flex',
FlexDirection: `${props.vertical ? 'column' : 'row'}`,
}
return style
})
//获取下一页对应页码,pace移动几页
const getTargetActive = (pace: number) => {
const active = state.active
if (pace) {
if (props.loop) {
return clamp(active + pace, -1, count.value)
} else {
return clamp(active + pace, 0, count.value - 1)
}
}
return active
}
//获取下一页对应的偏移距离
const getTargetOffset = (active: number, offset: number) => {
const position = active * size.value
const targetOffset = offset - position
return targetOffset
}
//最小偏移距离
const minOffset = computed(() => {
if (state.rect) {
const base = props.vertical ? state.rect.height : state.rect.width
return base - trackSize.value
}
return 0
})
//移动到下一页
const move = ({ pace = 0, offset = 0 }) => {
if (count.value > 1) {
const targetActive = getTargetActive(pace)
const targetOffset = getTargetOffset(targetActive, offset)
if (props.loop) {
// 正向滚动,从右向左
if (children[0] && targetOffset !== minOffset.value) {
const outRightBound = targetOffset < minOffset.value
//把第一个元素复原offset 从-size*count 变成0
children[0].setOffset(outRightBound ? trackSize.value : 0)
}
// 反向滚动,从左向右
const last = children[count.value - 1]
if(last && targetOffset !== 0) {
const onLeftBound = targetOffset > 0
last.setOffset(onLeftBound ? -trackSize.value : 0)
}
}
state.active = targetActive
state.offset = targetOffset //改变offset触发滚动
}
}
const correctPositon = () => {
state.swiping = true
//如果超出页码范围返回首页初始位置,形成循环播放
if (state.active < 0) {
move({ pace: count.value })
} else if (state.active >= count.value) {
move({ pace: -count.value })
}
}
const next = () => {
correctPositon()
doubleRaf(() => {
state.swiping = false
move({ pace: props.forward ? -1 : 1 })
})
}
let timer: number
const stopAutoplay = () => {
clearTimeout(timer)
}
const autoplay = () => {
stopAutoplay()
if (props.autoplay > 0 && count.value > 1) {
timer = setTimeout(() => {
next()
autoplay()
}, props.autoplay)
}
}
const init = () => {
if (!root.value) {
return
}
const rect = {
width: root.value?.offsetWidth,
height: root.value?.offsetHeight,
}
state.rect = rect
state.width = rect.width
state.height = rect.height
autoplay()
}
linkChildren({
size,
props,
})
onMounted(init)
onBeforeUnmount(stopAutoplay)
watch(() => props.autoplay, autoplay)
return () => ( <div ref={root} class={bem()}> <div ref={track} style={trackStyle.value} class={bem('track')} onTouchstart={onTouchStart} onTouchmove={onTouchMove} onTouchend={onTouchEnd} > {slots.default?.()} </div> {renderIndicator()} </div> ) }, })
添加手势滑动处理
//对滑动手势的一些处理,主要是获取滑动距离位置等的封装
const touch = useTouch()
const delta = computed(() => (props.vertical ? touch.deltaY.value : touch.deltaX.value))
let touchStartTime: number
const onTouchStart = (evevt: TouchEvent) => {
touch.start(evevt)
touchStartTime = Date.now()
//停止制动播放
stopAutoplay()
correctPositon()
}
//触发手势滑动
const onTouchMove = (event: TouchEvent) => {
touch.move(event)
event.preventDefault()
move({ offset: delta.value })
}
//手势滑动结束时决定是否滚到下一下
const onTouchEnd = () => {
const duration = Date.now() - touchStartTime
const speed = delta.value / duration
const shouldSwipe = Math.abs(speed) > 0.25 || Math.abs(delta.value) > size.value / 2
if (shouldSwipe) {
const offset = props.vertical ? touch.offsetY.value : touch.offsetX.value
let pace = 0
if (props.loop) {
pace = offset > 0 ? (delta.value > 0 ? -1 : 1) : 0
} else {
pace = -Math[delta.value > 0 ? 'ceil' : 'floor'](delta.value / size.value)
}
move({ pace: pace })
} else {
move({ pace: 0 })
}
state.swiping = false
autoplay()
}
添加页码
//页码
const activeIndicator = computed(() => {
const num = state.active % count.value
return num >= 0 ? num : count.value - 1
})
const renderDot = (_: string, index: number) => {
const active = index === activeIndicator.value
return <i class={bem('indicator', { active })}> </i>
}
const renderIndicator = () => {
if (props.showIndicators) {
return <div class={bem('indicators') style={pStyle.value}}>{Array(count.value).fill('').map(renderDot)}</div>
}
}
封装手势移动距离工具useTouch
import { ref } from 'vue'
//垂直和水平,哪个方向移动距离大算哪个
const getDirection = (x: number, y: number) => {
if (x > y) {
return 'horizontal'
}
if (y > x) {
return 'vertical'
}
return ''
}
export function useTouch() {
const startX = ref(0)
const startY = ref(0)
//移动的水平距离(有正负)
const deltaX = ref(0)
const deltaY = ref(0)
//距离绝对值
const offsetX = ref(0)
const offsetY = ref(0)
const direction = ref('')
const isVertical = () => direction.value === 'vertical'
const isHorizontal = () => direction.value === 'horizontal'
const reset = () => {
deltaX.value = 0
deltaY.value = 0
offsetX.value = 0
offsetY.value = 0
}
const start = (event: TouchEvent) => {
reset()
startX.value = event.touches[0].clientX
startY.value = event.touches[0].clientY
}
const move = (event: TouchEvent) => {
const touch = event.touches[0]
deltaX.value = (touch.clientX < 0 ? 0 : touch.clientX) - startX.value
deltaY.value = touch.clientY - startY.value
offsetX.value = Math.abs(deltaX.value)
offsetY.value = Math.abs(deltaY.value)
const LOCK_DIRECTION_DISTANCE = 10
if (
!direction.value ||
(offsetX.value < LOCK_DIRECTION_DISTANCE && offsetY.value < LOCK_DIRECTION_DISTANCE)
) {
direction.value = getDirection(offsetX.value, offsetY.value)
}
}
return {
move,
start,
reset,
startX,
startY,
deltaX,
deltaY,
offsetX,
offsetY,
direction,
isVertical,
isHorizontal,
}
}
通过父子组件自动添加,父组件获取子组件数组确定轮播图数量,子组件获取轮播图size大小
useParent代码:
import type { InjectionKey } from 'vue'
import type { Child } from './useChildren'
import { inject, getCurrentInstance, onUnmounted } from 'vue'
export type ParentProvide = {
link(instance: Child): void
unlink(instance: Child): void
[key: string]: any
}
export function useParent(key: InjectionKey<ParentProvide>) {
//为子组件注入父组件提供的属性
const parent = inject(key, null)
if (!parent) {
return {
parent: null,
}
}
//当前的子组件 加入到数组中
const instance = getCurrentInstance()
const { link, unlink } = parent
link(instance)
//生命周期结束时 从数组中移除,防止内存泄漏
onUnmounted(() => unlink(instance))
return {
parent,
}
}
useChildren代码:
import type { ComponentInternalInstance, InjectionKey, Ref } from 'vue'
import type { ParentProvide } from './useParent'
import { reactive, provide } from 'vue'
export type NotNullChild = ComponentInternalInstance & Record<string, any>
export type Child = NotNullChild | null
export function useChildren(key: InjectionKey<ParentProvide>) {
const children = reactive<Child[]>([])
const linkChildren = (value?: any) => {
const link = (child: Child) => {
children.push(child)
}
const unlink = (child: Child) => {
const index = children.indexOf(child)
children.splice(index, 1)
}
//提供注入
provide(key, {
link,
unlink,
...value, //把value对象所有属性添加进去
})
}
return {
children,
linkChildren,
}
}
子组件逻辑代码:
import { useParent } from '@/use/useParent'
import { createNamespace } from '@/utils/create'
import { computed, defineComponent, type CSSProperties } from 'vue'
import { SWIPE_KEY } from './OpSwipe'
import { useExpose } from '@/use/useExpose'
const [name, bem] = createNamespace('swipe-item')
export default defineComponent({
name,
//props: {},
setup(props, { slots }) {
const { parent } = useParent(SWIPE_KEY)
const style = computed(() => {
const style: CSSProperties = {}
style['width'] = '100px'
if (parent) {
if (parent.size.value) {
style[parent.vertical ? 'height' : 'width'] = `${parent.size.value}px`
}
}
return style
})
return () => (
<div class={bem()} style={style.value}>
{slots.default?.()}
</div>
)
},
})

浙公网安备 33010602011771号