【山竹记账前端-vue】7. 滑动手势封装 useSwipe Hook


大纲链接 §

[toc]


1. 重构拆分组件 ⇧

拆分组件 @/views/WelcomeView.tsx

  • 注意原本纯函数式写法不支持vue组件生命周期钩子函数
  • 在拆分的函数中使用 defineComponent 用法

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
    import { defineComponent } from 'vue'
    import { WelcomeHeader } from '@/modules/welcome/WelcomeHeader.tsx'
    import { WelcomeSkipper } from '@/modules/welcome/WelcomeSkipper.tsx'
    import { MainSwiper } from '@/modules/welcome/MainSwiper.tsx'
    import s from '@/views/WelcomeView.module.scss'
    
    export const WelcomeView = defineComponent(
    () => {
    return () => (
      <div class={s.wrapper}>
        <WelcomeHeader />
    
        <MainSwiper />
    
        <WelcomeSkipper />
      </div>
    )
    },
    {
    props: {},
    name: 'WelcomeView',
    },
    )
    

@/modules/welcome/WelcomeHeader.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { defineComponent } from 'vue'
import s from '@/views/WelcomeView.module.scss'
import { SvgIcon } from '@/components/icons/SvgIcon.tsx'
import { cn } from 'cn'

export const WelcomeHeader = defineComponent({
  name: 'WelcomeHeader',
  setup(/*props, ctx*/) {
    return () => (
      <header
        class={cn(
          s.title,
          'flex flex-shrink-1 flex-grow-0 flex-col items-center justify-center',
          'm-y-2em m-x-0 pt-10%',
          'text-2em color-[var(--title-text)]',
        )}>
        <SvgIcon class="w-64px h-69px" name="mangosteen" />
        <h1>山竹记账</h1>
      </header>
    )
  },
})

@/modules/welcome/WelcomeSkipper.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { defineComponent } from 'vue'
import { cn } from 'cn'

export const WelcomeSkipper = defineComponent({
  name: 'WelcomeSkipper',
  setup(/*props, ctx*/) {
    return () => (
      <section
        class={cn(
          'color-[var(--title-text)] text-[1.5em]',
          'top-0.5em right-0.5em fixed',
        )}>
        跳过
      </section>
    )
  },
})

@/modules/welcome/MainSwiper.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import { defineComponent, onMounted, Transition, useTemplateRef, type VNode } from 'vue'
import { type RouteLocationNormalizedLoaded, RouterView } from 'vue-router'
import { cn } from 'cn'
import '@/modules/welcome/MainSwiper.scoped.scss'

export const MainSwiper = defineComponent({
  name: 'MainSwiper',
  props: {},
  components: {},
  setup(/*props, ctx*/) {
    const mainRef = useTemplateRef<HTMLElement>('mainRef')

    onMounted(() => {
      console.log('WelcomeView_______________________')
      console.log("%c 4 --> mainRef: ","color:#f00;", mainRef);
      console.log("%c 10 --> mainRef: ","color:#42b883;", mainRef.value);
      console.log('_______________________WelcomeView')
    })

    return () => (
      <main
        ref="mainRef"
        class={cn([
          'max-h-[calc(100%-360px)]',
          'flex flex-col flex-1',
          'mb-62px mx-16px',
          'position-relative'
        ])}>
        <RouterView name="main">
          {({ Component, route }: { Component: VNode; route: RouteLocationNormalizedLoaded }) => (
            <Transition
              name="slide-fade"
              enterActiveClass="slide-fade-enter-active"
              leaveActiveClass="slide-fade-leave-active"
              enterFromClass="slide-fade-enter-from"
              leaveToClass="slide-fade-leave-to">
              <div
                key={route.path}
                class={cn([
                  'bg-welcomeCardBg rounded-lg',
                  'flex flex-col flex-grow items-center justify-around',
                  'position-absolute top-0 left-0 h-100% w-100%',
                ])}>
                {Component}
              </div>
            </Transition>
          )}
        </RouterView>
      </main>
    )
  },
})

@/modules/welcome/MainSwiper.scoped.scss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
.slide-fade-enter-active,
.slide-fade-leave-active {
  transition: all 0.8s ease-out;
}

.slide-fade-enter-from {
  transform: translateX(100vw);
  opacity: 0;
}

.slide-fade-leave-to {
  transform: translateX(-100vw);
  opacity: 0;
}

@/views/WelcomeView.module.scss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
.wrapper {
  height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: space-around;
  background-image: linear-gradient(
    to bottom,
    var(--welcome-card-start) 0%,
    var(--welcome-card-end) 100%
  );
}

2. 实现思路与为 useSwipe 搭框架 ⇧

支持滑动事件

  • 使用 Hooks,即使用 Composition Api 实现
  • 只支持在手机端触控滑动,不管 PC 鼠标事件

@/hooks/useSwipe.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import type { Ref } from 'vue'

// type Point = {x: number; y: number}
interface SwipeState {
  isSwiping: boolean
  direction: string
  distance: number
}

export const useSwipe = (
  elementRef: Ref<HTMLElement | null>
): SwipeState => {

  console.log('useSwipe_______________________')
  console.log("%c 2 --> elementRef: ","color:#58c4dc;", elementRef);
  console.log('_______________________useSwipe')

  return {
    isSwiping: false,
    direction: '',
    distance : 0
  }
}

引入到 @src/modules/welcome/MainSwiper.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// ...
export const MainSwiper = defineComponent({
  name: 'MainSwiper',
  props: {},
  components: {},
  setup(/*props, ctx*/) {
    const mainRef = useTemplateRef<HTMLElement>('mainRef')

    const {isSwiping}  =  useSwipe(mainRef)

    onMounted(() => {
      console.log('WelcomeView_______________________')
      console.log("%c 5 --> isSwiping: ","color:#ff6a00;", isSwiping);
      console.log("%c 4 --> mainRef: ","color:#f00;", mainRef);
      console.log("%c 10 --> mainRef: ","color:#42b883;", mainRef.value);
      console.log('_______________________WelcomeView')
    })

    return () => (
      <main
        ref="mainRef"
        class={cn([/* ... */])}>
        <RouterView name="main">
          {({ Component, route }: { Component: VNode; route: RouteLocationNormalizedLoaded }) => (
            <Transition
              name="slide-fade"
              enterActiveClass="slide-fade-enter-active"
              leaveActiveClass="slide-fade-leave-active"
              enterFromClass="slide-fade-enter-from"
              leaveToClass="slide-fade-leave-to">
              <div
                key={route.path}
                class={cn([/* ... */])}>
                {Component}
              </div>
            </Transition>
          )}
        </RouterView>
      </main>
    )
  },
})
  • 声明 const mainRef = useTemplateRef<HTMLElement>('mainRef')
  • 关联 <main ref="mainRef">

swipe_demo_1

3. 监听 Touch / Pointer 事件 ⇧

思路

  • onMounted 组件挂载后再监听事件
    • 手指先按下 对应 ontouchstart
    • 手指滑动 对应 ontouchmove
    • 手指松开 对应 ontouchend
  • 移动方向判定:手指按下的瞬间,记录一个坐标;在移动时,记录一个坐标,并对比大小
    • y 坐标轴向下,x 坐标轴向右
    • 松开时y坐标比按下时y坐标大,则向下移动
    • 松开时x坐标比按下时x坐标大,则向右移动

@/hooks/useSwipe.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import { computed, type ComputedRef, onMounted, onUnmounted, ref, type Ref } from 'vue'

type Point = { x: number; y: number }
interface SwipeState {
  isSwiping: Ref<boolean>
  direction: ComputedRef<'' | 'right' | 'left' | 'up' | 'down'>
  distance: ComputedRef<Point | null>
}

const calcDistance = (start: Point, end: Point) => {
  return {
    x: end.x - start.x,
    y: end.y - start.y,
  }
}

const calcDirection = (distance: Point) => {
  if (!distance) { return '' }
  const { x, y } = distance
  if (Math.abs(x) > Math.abs(y)) {
    return x > 0 ? 'right' : 'left'
  }
  return y > 0 ? 'down' : 'up'
}

export const useSwipe = (elementRef: Ref<HTMLElement | null>): SwipeState => {
  const start = ref<Point | null>(null)
  const end = ref<Point | null>(null)
  const isSwiping = ref(false)
  const distance = computed(() => {
    if (!start.value || !end.value) { return null }
    return calcDistance(start.value, end.value)
  })

  const direction = computed(() => {
    if (!distance.value || !isSwiping.value) { return '' }
    return calcDirection(distance.value)
  })

  const aborter = new AbortController()
  const opt = { signal: aborter.signal }

  const onStart = (e: TouchEvent) => {
    start.value = {
      x: e.touches[0]?.screenX ?? 0,
      y: e.touches[0]?.screenY ?? 0,
    }
    end.value = null
    isSwiping.value = true
  }

  const onMove = (e: TouchEvent) => {
    end.value = {
      x: e.touches[0]?.screenX ?? 0,
      y: e.touches[0]?.screenY ?? 0,
    }
  }

  const onEnd = () => {
    isSwiping.value = false
    start.value = null
    end.value = null
  }

  onMounted(() => {
    if(!elementRef.value) {return}
    elementRef.value.addEventListener('touchstart', onStart, opt)
    elementRef.value.addEventListener('touchmove', onMove, opt)
    elementRef.value.addEventListener('touchend', onEnd, opt)
  })

  onUnmounted(() => {aborter.abort()})

  return {
    isSwiping,
    direction,
    distance,
  }
}
  • 坐标数据类型 type Point = {x: number; y: number}
  • 记录开始按下的坐标 const start = ref<Point>()
  • 记录松开的坐标 const end = ref<Point>()
  • 只处理单指点击的情况 e.touches[0]
  • 记录是否在移动 const isSwiping = ref(false)
  • 计算移动距离:<开始坐标 - 结束坐标>
    • x: end.x - start.x
    • y: end.y - start.y
    • 使用计算属性做缓存
  • 根据 start 和 end 计算方向:
    • 先判断 x、y 绝对值距离更大的一方作为主要方向
    • 再根据 x、y 和 0 比较结果返回方向字符串
    • 使用计算属性做缓存
  • 回调函数 onStart
    • 记录开始位移坐标,清空上一次结束坐标位移
    • 将是否滑动状态设置为 true
  • 回调函数 onMove
    • 记录当前位移坐标为结束坐标
  • 回调函数 onEnd
    • 清空开始坐标和结束坐标
    • 将是否滑动状态设置为 false
  • 声明终止控制器 const aborter = new AbortController()
    • 声明监听事件选项 const opt = { signal: aborter.signal }
  • onMounted 组件挂载后再监听事件,添加三个回调,并提供参数选项 opt 终止信号
  • onUnmounted 组件卸载,终止控制器取消监听事件
    • aborter.abort()

参考


4. 滑动切换路由 + 重构 ⇧


5. 选看:TS 类型体操 ⇧


6. 选看:对比同行的代码,VueUse 社区的实现 usePointerSwipe 和 useSwipe ⇧


·未完待续·

参考文章

1~3

4~6

相关文章


  • 作者: Joel
  • 文章链接:
  • 版权声明
  • 非自由转载-非商用-非衍生-保持署名