【山竹记账前端-react】6.自己封装一个 Hook 实现滑动功能


大纲链接 §

[toc]


1. BUG:errorElement造成无限循环 ⇧

误用 errorElement

  • 当代码中有错误,比如未引入依赖造成页面报错时,目前的路由中配置了 errorElement: <RedirectToWelcome1 />
  • 页面会先去 <WelcomLayout />,但 path: 'welcome' 未配置 errorElement 错误时的路由,会访问上一级,导致无限循环

解决方法

  • 在 path: 'welcome' 路由定义一个 errorElement

显示 routes 的类型提示

  • 点击进入查看 export const router = createBrowserRouter(routes) 中 createBrowserRouter 的参数类型
  • 添加类型 export const routes: RouteObject[] = [...],之后就会有诸如 errorElement 的提示了

移动、重命名 @/pages/NotFoundPage.tsx

1
2
3
export const NotFound: React.FC = () => {
  return <div>当前路径不存在</div>
}

@/route/routes.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import { NotFoundPage } from '@/pages/NotFoundPage.tsx'
import { MainLayout } from '@/layouts/MainLayout'
import { welcomeRouter } from '@/route/welcomeRouter'
import type { RouteObject } from 'react-router-dom'
import { redirect } from 'react-router-dom'

export const routes: RouteObject[] = [
  {
    path: '/',
    element: <MainLayout />,
    errorElement: (<NotFoundPage />),
    loader: () => redirect('/welcome/1'),
  },
  welcomeRouter,
]
  • 不重定义到一个不能确保一定不报错的组件

@/route/welcomeRouter.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import { NotFoundPage } from '@/pages/NotFoundPage.tsx'
import { WelcomeMainLayout } from '@/layouts/WelcomeMainLayout'
import { Welcome1 } from '@/pages/Welcome1'
import { Welcome2 } from '@/pages/Welcome2'
import { Welcome3 } from '@/pages/Welcome3'
import { Welcome4 } from '@/pages/Welcome4'
import type { RouteObject } from 'react-router-dom'

export const welcomeRouter: RouteObject = {
  path: '/welcome/',
  element: <WelcomeMainLayout />,
  errorElement: (<NotFoundPage />),
  children: [
    { path: '1', element: (<Welcome1 />) },
    { path: '2', element: (<Welcome2 />) },
    { path: '3', element: (<Welcome3 />) },
    { path: '4', element: (<Welcome4 />) },
  ],
}

2. BUG:解决TS报错,顺便重构 ⇧

  • const [extraStyle, setExtraStyle] = useState<Record<string, string | number>>({ position: 'relative' })
  • 如果unocss相关样式属性有报错,可以将属性追加到 src/shims.d.ts 中

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
    import { HTMLAttributes } from 'react'
    import type { AttributifyAttributes } from '@unocss/preset-attributify'
    
    declare module 'react' {
    interface HTMLAttributes<T> extends AriaAttributes, AttributifyAttributes, DOMAttributes<T> {
    // 允许传入 UnoCSS 属性
    bold?: boolean
    text?: string
    bordered?: boolean
    bg?: string
    flex?: string | boolean
    block?: boolean
    relative?: boolean
    absolute?: boolean
    scopedId?: string
    w?: string
    h?: string
    // 或者直接使用通配符或特殊定义
    [key: string]: unknown
    }
    }
    

重构前 @/layouts/WelcomeMainLayout.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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import { animated, useTransition } from '@react-spring/web'
import type { ReactNode } from 'react'
import { useRef, useState } from 'react'
import { NavLink, useLocation, useOutlet } from 'react-router-dom'
import logo from '@/assets/icons/mangosteen.svg'
import { cn } from 'cn'

export const WelcomeMainLayout: React.FC = () => {
  const mapRef = useRef<Record<string, ReactNode>>({})
  const { pathname } = useLocation()
  // 获取当前的 outlet
  const currentOutlet = useOutlet() as React.ReactElement
  mapRef.current[pathname] = currentOutlet

  const [extraStyle, setExtraStyle] = useState<Record<string, string | number>>({ position: 'relative' })
  const transitions = useTransition(pathname, {
    onStart: () => { setExtraStyle({ position: 'absolute' }) },
    onRest: () => { setExtraStyle({ position: 'relative' }) },
    from: { opacity: 0, transform: 'translate3D(100%, 0, 0)' },
    enter: { opacity: 1, transform: 'translate3D(0%, 0, 0)' },
    leave: { opacity: 0, transform: 'translate3D(-100%, 0, 0)' },
    config: { tension: 280, friction: 30, duration: 300 },
  })

  return (
    <div className={cn(
      'overflow-clip',
      'bg-[#5f34bf]',
      'h-screen pb-16px',
      'flex flex-col items-stretch',
    )}>

      <header shrink-0 text-center pt-4em>
        <img alt="logo" src={logo} w-64px h-69px inline />
        <h1 text="#d4d4ee" text-2em>山竹记账</h1>
      </header>

      <main
        className={cn(
          'grow-1 shrink-1 mb-2em relative overflow-clip',
        )}>
        {transitions((style, pathname) => (
          <animated.div
            key={pathname}
            style={{ ...style, ...extraStyle }}
            className={cn(
              'flex h-100% w-100% p-16px',
            )}>
            <div
              className={cn(
                'grow-1 flex justify-center items-center',
                'bg-white rounded-8px',
              )}>
              {mapRef.current[pathname]}
            </div>
          </animated.div>
        ))}
      </main>

      <section
        className={cn(
          'fixed right-[0.5em] top-[0.25em]',
          'text-[#d4d4ee] text-[1.5em]',
        )}>
        <NavLink to="/welcome/1">跳过</NavLink>
      </section>
    </div>
  )
}
  • 重构思路
    • 将静态确定的结构提取出来
    • 将动画的逻辑封装成单独 hook 逻辑

重构拆分 @/modules/welcome/MangosteenHeader.tsx

1
2
3
4
5
6
7
8
import logo from '@/assets/icons/mangosteen.svg'

export const MangosteenHeader: React.FC = () => (
  <header shrink-0 text-center pt-4em>
    <img alt="logo" src={logo} w-64px h-69px inline />
    <h1 text="var(--title-text)" text-2em>山竹记账</h1>
  </header>
)

重构拆分 @/modules/welcome/ToStartPage.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import { cn } from 'cn'
import { NavLink } from 'react-router-dom'

export const ToStartPage: React.FC = () => (
  <section
    className={cn(
      'fixed right-[0.5em] top-[0.25em]',
      'text-[#d4d4ee] text-[1.5em]',
    )}>
    <NavLink to="/xxx">跳过</NavLink>
  </section>
)

重构拆分 @/modules/welcome/SwiperGuide.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
import { animated, useTransition } from '@react-spring/web'
import { cn } from 'cn'
import type { ReactNode } from 'react'
import { useRef, useState } from 'react'
import { useLocation, useOutlet } from 'react-router-dom'

export const SwiperGuide: React.FC = () => {
  const mapRef = useRef<Record<string, ReactNode>>({})
  const { pathname } = useLocation()
  // 获取当前的 outlet
  const currentOutlet = useOutlet() as React.ReactElement
  mapRef.current[pathname] = currentOutlet

  const [extraStyle, setExtraStyle] = useState<Record<string, string | number>>({ position: 'relative' })
  const transitions = useTransition(pathname, {
    onStart: () => { setExtraStyle({ position: 'absolute' }) },
    onRest: () => { setExtraStyle({ position: 'relative' }) },
    from: { opacity: 0, transform: 'translate3D(100%, 0, 0)' },
    enter: { opacity: 1, transform: 'translate3D(0%, 0, 0)' },
    leave: { opacity: 0, transform: 'translate3D(-100%, 0, 0)' },
    config: { tension: 280, friction: 30, duration: 300 },
  })

  return (
    <main
      className={cn(
        'grow-1 shrink-1 mb-2em relative overflow-clip',
      )}>
      {transitions((style, pathname) => (
        <animated.div
          key={pathname}
          style={{ ...style, ...extraStyle }}
          className={cn(
            'flex h-100% w-100% p-16px',
          )}>
          <div
            className={cn(
              'grow-1 flex justify-center items-center',
              'bg-white rounded-8px',
            )}>
            {mapRef.current[pathname]}
          </div>
        </animated.div>
      ))}
    </main>
  )
}

拆分后 @/layouts/WelcomeMainLayout.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { cn } from 'cn'
import { MangosteenHeader } from '@/modules/welcome/MangosteenHeader'
import { ToStartPage } from '@/modules/welcome/ToStartPage'
import { SwiperGuide } from '@/modules/welcome/SwiperGuide'

export const WelcomeMainLayout: React.FC = () => {
  return (
    <div className={cn(
      'overflow-clip',
      'bg-[#5f34bf]',
      'h-screen pb-16px',
      'flex flex-col items-stretch',
    )}>
      <MangosteenHeader />

      <SwiperGuide />

      <ToStartPage />
    </div>
  )
}

3. 使用体验:添加 ESLint 规则 ⇧

自主掌控 eslint 我的代码我做主 eslint.config.ts

  • 代码规则划红线,鼠标浮动查看,点击链接进入相关规则页面
  • 按照规则配置 rules 即可

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    
    import antfu from '@antfu/eslint-config'
    
    export default antfu(
    {
    rules: {
      "curly": "off",
      "@typescript-eslint/brace-style": "off",
      "@typescript-eslint/no-unused-vars": [
        "error",
        {
          "vars": "local",
          "args": "none",
          "ignoreRestSiblings": true,
          "caughtErrors": "none"
        }
      ],
      "@typescript-eslint/comma-dangle": "off"
    }
    },
    )
    

4. 封装:自制 Hooks 之 useSwipe ⇧

使用 Hooks 实现:滑动手势,左滑切换欢迎页面

  • const {direction} = useSwipe(div)

新建 @/hooks/useSwipe.tsx

  • 参数传入一个HTML元素
  • 需要在组件挂载时监听这个元素 useEffect(() => {}, [])
  • 先搭一个框架
  • 入参参数类型 element: HTMLElement | null 存疑

     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
    
    import { useEffect, useState } from 'react'
    
    export const useSwipe = (element: HTMLElement | null) => {
    const [direction, setDirection] = useState<'' | 'left' | 'right'>('')
    const onTouchStart = (e: TouchEvent) => {
    window.console.log('onTouchStart', e)
    }
    const onTouchMove = (e: TouchEvent) => {
    window.console.log('onTouchMove', e)
    }
    const onTouchEnd = (e: TouchEvent) => {
    window.console.log('onTouchEnd', e)
    }
    
    useEffect(() => {
    if (!element) { return }
    
    element.addEventListener('touchstart', onTouchStart)
    element.addEventListener('touchmove', onTouchMove)
    element.addEventListener('touchend', onTouchEnd)
    
    return () => {
      element.removeEventListener('touchstart', onTouchStart)
      element.removeEventListener('touchmove', onTouchMove)
      element.removeEventListener('touchend', onTouchEnd)
    }
    }, [])
    
    return ({
    direction,
    })
    }
    

监听动画外层固定不动的元素 main:@/modules/welcome/SwiperGuide.tsx

  • 引用元素 const mainRef = useRef<HTMLElement>(null),默认值不填会报类型错误
  • 监听元素 const {direction} = useSwipe(mainRef.current)

    • 由于初始逻辑中 mainRef.current 一直为 null,所以反证了 useSwipe(element: HTMLElement | null) 参数必须接受一个 RefObject 类型

       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
      
      import type { RefObject } from 'react'
      import { useEffect, useState } from 'react'
      
      export const useSwipe = (elementRef: RefObject<HTMLElement | null>,
      ) => {
      const [direction, setDirection] = useState<'' | 'left' | 'right'>('')
      const onTouchStart = (e: TouchEvent) => {
      window.console.log('onTouchStart', e)
      }
      const onTouchMove = (e: TouchEvent) => {
      window.console.log('onTouchMove', e)
      }
      const onTouchEnd = (e: TouchEvent) => {
      window.console.log('onTouchEnd', e)
      }
      
      useEffect(() => {
      if (!elementRef.current) { return }
      elementRef.current.addEventListener('touchstart', onTouchStart)
      elementRef.current.addEventListener('touchmove', onTouchMove)
      elementRef.current.addEventListener('touchend', onTouchEnd)
      
      return () => {
      if (!elementRef.current) { return }
      elementRef.current.removeEventListener('touchstart', onTouchStart)
      elementRef.current.removeEventListener('touchmove', onTouchMove)
      elementRef.current.removeEventListener('touchend', onTouchEnd)
      }
      }, [])
      
      return ({
      direction,
      })
      }
      

修改 @/hooks/useSwipe.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
53
54
import { useSwipe } from '@/hooks/useSwipe'
import { animated, useTransition } from '@react-spring/web'
import { cn } from 'cn'
import type { ReactNode } from 'react'
import { useRef, useState } from 'react'
import { useLocation, useOutlet } from 'react-router-dom'

export const SwiperGuide: React.FC = () => {
  const mapRef = useRef<Record<string, ReactNode>>({})
  const { pathname } = useLocation()
  // 获取当前的 outlet
  const currentOutlet = useOutlet() as React.ReactElement
  mapRef.current[pathname] = currentOutlet

  const [extraStyle, setExtraStyle] = useState<Record<string, string | number>>({ position: 'relative' })

  const transitions = useTransition(pathname, {
    onStart: () => { setExtraStyle({ position: 'absolute' }) },
    onRest: () => { setExtraStyle({ position: 'relative' }) },
    from: { opacity: 0, transform: 'translate3D(100%, 0, 0)' },
    enter: { opacity: 1, transform: 'translate3D(0%, 0, 0)' },
    leave: { opacity: 0, transform: 'translate3D(-100%, 0, 0)' },
    // 物理参数微调:让过渡更轻快
    config: { tension: 280, friction: 30, duration: 300 },
  })

  const mainRef = useRef<HTMLElement>(null)
  const { direction } = useSwipe(mainRef)

  return (
    <main
      ref={mainRef}
      className={cn(
        'grow-1 shrink-1 mb-2em relative overflow-clip',
      )}>
      {transitions((style, pathname) => (
        <animated.div
          key={pathname}
          style={{ ...style, ...extraStyle }}
          className={cn(
            'flex h-100% w-100% p-16px',
          )}>
          <div
            className={cn(
              'grow-1 flex justify-center items-center',
              'bg-white rounded-8px',
            )}>
            {mapRef.current[pathname]}
          </div>
        </animated.div>
      ))}
    </main>
  )
}

用户手指第一次滑动操作方向 ⇧

由于需要记录的坐标不参与 UI 的展示,使用 useRef

  • const coordinateRef = useRef(-1) 由于 0 具有特殊含义,所以使用 -1 作为初始值
  • 使用第一根手指 e.touches[0]
  • 根据开始和移动时的 x 坐标判断方向
  • 方向决定动画的移动方向,参与 UI 的展示,所以 const [direction, setDirection] = useState<'' | 'left' | 'right'>('')

@/hooks/useSwipe.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
import type { RefObject } from 'react'
import { useEffect, useRef, useState } from 'react'

// eslint-disable-next-line antfu/top-level-function
export const useSwipe = (elementRef: RefObject<HTMLElement | null>,
) => {
  const [direction, setDirection] = useState<'' | 'left' | 'right'>('')
  const coordinateXRef = useRef(-1)

  const onTouchStart = (e: TouchEvent) => {
    coordinateXRef.current = e.touches[0].clientX
  }

  const onTouchMove = (e: TouchEvent) => {
    const tempX = e.touches[0].clientX
    const distance = tempX - coordinateXRef.current

    if (Math.abs(distance) < 3) { setDirection('') }
    if (distance > 0) { setDirection('right') }
    if (distance < 0) { setDirection('left') }
  }

  const onTouchEnd = (e: TouchEvent) => {}

  useEffect(() => {
    if (!elementRef.current) { return }
    elementRef.current.addEventListener('touchstart', onTouchStart)
    elementRef.current.addEventListener('touchmove', onTouchMove)
    elementRef.current.addEventListener('touchend', onTouchEnd)

    return () => {
      if (!elementRef.current) { return }
      elementRef.current.removeEventListener('touchstart', onTouchStart)
      elementRef.current.removeEventListener('touchmove', onTouchMove)
      elementRef.current.removeEventListener('touchend', onTouchEnd)
    }
  // eslint-disable-next-line react/exhaustive-deps
  }, [])

  return ({
    direction,
  })
}
  • 距离小于 3 像素忽略

监听返回的 direction:@/modules/welcome/SwiperGuide.tsx

  • 判断方向,跳转下一页
  • 使用 useEffect,依赖 [direction, nav, pathname]
  • 左滑跳转 if (direction === 'left') { nav(welcomeLinkMap[pathname]) }

     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
    
    import { useSwipe } from '@/hooks/useSwipe'
    import { NotFoundPage } from '@/pages/NotFoundPage'
    import { animated, useTransition } from '@react-spring/web'
    import { cn } from 'cn'
    import type { ReactNode } from 'react'
    import { useEffect, useRef, useState } from 'react'
    import { useLocation, useNavigate, useOutlet } from 'react-router-dom'
    
    const welcomeLinkMap: Record<string, string> = {
    '/welcome/1': '/welcome/2',
    '/welcome/2': '/welcome/3',
    '/welcome/3': '/welcome/4',
    '/welcome/4': '/welcome/xxx',
    } as const
    
    export const SwiperGuide: React.FC = () => {
    const { pathname } = useLocation()
    const mapRef = useRef<Record<string, ReactNode>>({})
    // 获取当前的 outlet
    const currentOutlet = useOutlet() as React.ReactElement
    mapRef.current[pathname] = currentOutlet
    
    const [extraStyle, setExtraStyle] = useState<Record<string, string | number>>({ position: 'relative' })
    
    const transitions = useTransition(pathname, {
    onStart: () => { setExtraStyle({ position: 'absolute' }) },
    onRest: () => { setExtraStyle({ position: 'relative' }) },
    from: { opacity: 0, transform: 'translate3D(100%, 0, 0)' },
    enter: { opacity: 1, transform: 'translate3D(0%, 0, 0)' },
    leave: { opacity: 0, transform: 'translate3D(-100%, 0, 0)' },
    // 物理参数微调:让过渡更轻快
    config: { tension: 280, friction: 30, duration: 300 },
    })
    
    const mainRef = useRef<HTMLElement>(null)
    const { direction } = useSwipe(mainRef)
    const nav = useNavigate()
    useEffect(() => {
    if (direction === 'left') { nav(welcomeLinkMap[pathname]) }
    }, [direction, nav, pathname])
    
    window.console.log('SwiperGuide_______________________')
    window.console.log('✌️direction --->', direction)
    window.console.log('_______________________SwiperGuide')
    
    return (
    <main
      ref={mainRef}
      className={cn(
        'grow-1 shrink-1 mb-2em relative overflow-clip',
      )}>
      {transitions((style, pathname) => (
        <animated.div
          key={pathname}
          style={{ ...style, ...extraStyle }}
          className={cn(
            'flex h-100% w-100% p-16px',
          )}>
          <div
            className={cn(
              'grow-1 flex justify-center items-center',
              'bg-white rounded-8px',
            )}>
            {mapRef.current[pathname]}
          </div>
        </animated.div>
      ))}
    </main>
    )
    }
    
  • 目前一滑动,页面就一下子跳转到最终页面了

  • 需要节流,控制滑动动画,标记当前是否正正在动画中,判断阻止跳转下一页的执行


添加节流标志,防止一滑到底 ⇧

  • 是否正正在动画中不参与 UI 的展示,仅用来阻止跳转函数的执行
  • const isAnimatingRef = useRef(false)
  • 主要监听 [direction, pathname]
    • 已经正在动画中,则不处理 if (isAnimatingRef.current) { return }
    • 不正在动画中,则设置 isAnimatingRef.current = true
    • 并执行判断跳转逻辑 if (direction === 'left') { nav(welcomeLinkMap[pathname]) }
  • 在 onRest 时还原 isAnimatingRef.current = false
  • 由于监听依赖了 direction 如果不重置的话,就监听不了变化
    • 需要在 touchEnd 时,重新设置 setDirection('')

@/modules/welcome/SwiperGuide.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
// import ...

const welcomeLinkMap: Record<string, string> = {
  '/welcome/1': '/welcome/2',
  '/welcome/2': '/welcome/3',
  '/welcome/3': '/welcome/4',
  '/welcome/4': '/welcome/xxx',
} as const

export const SwiperGuide: React.FC = () => {
  // ...

  const isAnimatingRef = useRef(false)
  const transitions = useTransition(pathname, {
    onStart: () => { setExtraStyle({ position: 'absolute' }) },
    onRest: () => {
      // 还原
      isAnimatingRef.current = false
      setExtraStyle({ position: 'relative' })
    },
    // ...
  })

  const mainRef = useRef<HTMLElement>(null)
  const { direction } = useSwipe(mainRef)
  const nav = useNavigate()
  useEffect(() => {
    if (isAnimatingRef.current) { return }
    isAnimatingRef.current = true
    if (direction === 'left') { nav(welcomeLinkMap[pathname]) }
  }, [direction, nav, pathname])

  window.console.log('SwiperGuide_______________________')
  window.console.log('✌️direction --->', direction)
  window.console.log('_______________________SwiperGuide')

  return (
    <main
      ref={mainRef}
      className={cn(
        'grow-1 shrink-1 mb-2em relative overflow-clip',
      )}>
      {/* transitions... */}
    </main>
  )
}

@/hooks/useSwipe.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
import type { RefObject } from 'react'
import { useEffect, useRef, useState } from 'react'

export const useSwipe = (elementRef: RefObject<HTMLElement | null>,
) => {
  const [direction, setDirection] = useState<'' | 'left' | 'right'>('')
  const coordinateXRef = useRef(-1)

  const onTouchStart = (e: TouchEvent) => {
    coordinateXRef.current = e.touches[0].clientX
  }

  const onTouchMove = (e: TouchEvent) => {
    const tempX = e.touches[0].clientX
    const distance = tempX - coordinateXRef.current

    if (Math.abs(distance) < 3) { setDirection('') }
    if (distance > 0) { setDirection('right') }
    if (distance < 0) { setDirection('left') }
  }

  const onTouchEnd = (/* e: TouchEvent */) => { setDirection('') }

  useEffect(() => {/* ... */}, [])

  return ({
    direction,
  })
}

阻止手机浏览器默认的弹性动画,左右滑动出现空白区间 ⇧

  • 可以在 onTouchStart 阻止默认事件 e.preventDefault()
  • 或者让用户传参选项来控制是否需要阻止默认事件

@/hooks/useSwipe.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
53
54
55
56
import type { RefObject } from 'react'
import { useEffect, useRef, useState } from 'react'

interface Config {
  onTouchStart?: (e: TouchEvent) => void
  onTouchMove?: (e: TouchEvent) => void
  onTouchEnd?: (e: TouchEvent) => void
}

// eslint-disable-next-line antfu/top-level-function
export const useSwipe = (
  elementRef: RefObject<HTMLElement | null>,
  config?: Config,
) => {
  const [direction, setDirection] = useState<'' | 'left' | 'right'>('')
  const coordinateXRef = useRef(-1)

  const onTouchStart = (e: TouchEvent) => {
    config?.onTouchStart?.(e)
    coordinateXRef.current = e.touches[0].clientX
  }

  const onTouchMove = (e: TouchEvent) => {
    config?.onTouchMove?.(e)
    const tempX = e.touches[0].clientX
    const distance = tempX - coordinateXRef.current

    if (Math.abs(distance) < 3) { setDirection('') }
    if (distance > 0) { setDirection('right') }
    if (distance < 0) { setDirection('left') }
  }

  const onTouchEnd = (e: TouchEvent) => {
    config?.onTouchEnd?.(e)
    setDirection('')
  }

  useEffect(() => {
    if (!elementRef.current) { return }
    elementRef.current.addEventListener('touchstart', onTouchStart)
    elementRef.current.addEventListener('touchmove', onTouchMove)
    elementRef.current.addEventListener('touchend', onTouchEnd)

    return () => {
      if (!elementRef.current) { return }
      elementRef.current.removeEventListener('touchstart', onTouchStart)
      elementRef.current.removeEventListener('touchmove', onTouchMove)
      elementRef.current.removeEventListener('touchend', onTouchEnd)
    }
  // eslint-disable-next-line react/exhaustive-deps
  }, [])

  return ({
    direction,
  })
}

使用时传参阻止默认事件

1
2
  // ...
  const { direction } = useSwipe(mainRef, { onTouchStart: (e) => { e.preventDefault() } })

还有 bug,初始上下滑动,判断错误 ⇧

  • 判断仅当左滑时,才设置 isAnimatingRef.current = true

@/modules/welcome/SwiperGuide.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
import { useSwipe } from '@/hooks/useSwipe'
import { animated, useTransition } from '@react-spring/web'
import { cn } from 'cn'
import type { ReactNode } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate, useOutlet } from 'react-router-dom'

const welcomeLinkMap: Record<string, string> = {
  '/welcome/1': '/welcome/2',
  '/welcome/2': '/welcome/3',
  '/welcome/3': '/welcome/4',
  '/welcome/4': '/welcome/xxx',
} as const

export const SwiperGuide: React.FC = () => {
  // ...

  const mainRef = useRef<HTMLElement>(null)
  const { direction } = useSwipe(mainRef, { onTouchStart: (e) => { e.preventDefault() } })
  const nav = useNavigate()
  useEffect(() => {
    if (isAnimatingRef.current) { return }
    if (direction === 'left') {
      isAnimatingRef.current = true
      nav(welcomeLinkMap[pathname])
    }
  }, [direction, nav, pathname])

  return (
    <main
      ref={mainRef}
      className={cn(
        'grow-1 shrink-1 mb-2em relative overflow-clip',
      )}>
      {/* ... */}
    </main>
  )
}

·未完待续·

参考文章

相关文章

  • 无

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