TS自动轮播图

发布时间 2024-01-02 16:07:26作者: 不停奔跑的蜗牛

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>
    )
  },
})