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, }, }, 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 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`, } 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 (targetOffset !== minOffset.value) { //再移动就右边出现空白了即最后一页了 const outRightBound = targetOffset < minOffset.value if (props.vertical) { if (outRightBound) { //到最后一页时把第一页拼接到后边,形成循环 firstChild.value.style.transform = `translateY(${trackSize.value}px)` } else { firstChild.value.style.transform = `translateY(0px)` } } else { if (outRightBound) { firstChild.value.style.transform = `translateX(${trackSize.value}px)` } else { firstChild.value.style.transform = `translateX(0px)` } } } if (targetOffset !== 0) { const outLeftBound = targetOffset > 0 if (props.vertical) { if (outLeftBound) { lastChild.value.style.transform = `translateY(${-trackSize.value}px)` } else { lastChild.value.style.transform = `translateY(0px)` } } else { if (outLeftBound) { lastChild.value.style.transform = `translateX(${-trackSize.value}px)` } else { lastChild.value.style.transform = `translateX(0px)` } } } } 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: 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) 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(() => state.active % count.value) 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')}>{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> ) }, })