Files
MeBox/web/src/hooks/useHorizontalSwipe.ts
T
truewhile b50cb7c31e 优化
2026-10-08 23:43:28 +08:00

154 lines
6.2 KiB
TypeScript

import { useCallback, useRef, type TouchEvent as ReactTouchEvent } from 'react'
// 横向滑动手势:给「左右翻页」的阅读区补上手机上的滑动手势。
// 原来只能点左 30% / 右 30% 分区翻页,手指横滑没有任何反应,翻页很别扭。
//
// 判定规则(和纵向滚动共存的关键):
// - 方向要先判定出来:位移超过 slop 后,横向位移要明显大于纵向位移(dominance 倍)
// 才算横滑,否则算纵向滑动/滚动,本次手势彻底放弃(连 click 也不吞);
// - 没走跟手(点按、或没传 onDrag)时,仍按 minDistance + maxDuration 判定一次滑动,
// 短距离当点击处理,交给下面的分区点击。
//
// 跟手拖拽:传了 onDrag 之后,手指横向移动会实时回调位移,松手再回调 onDragEnd,
// 由宿主把内容平移到位(正文列 / 漫画舞台)并决定翻页还是回弹。滑动途中内容就跟着
// 手指走,不再等到松手才动一下。跟手手势一律按「拖动」处理,不再走 maxDuration 判定
// (慢慢拖过去也是拖动)。
//
// 一次滑动之后浏览器仍可能补一个 click(落在原来的分区按钮上),
// 于是会「滑一页又点一页」翻两页。用 consumeSwipe() 让点击回调先问一句:
// 这次 click 是不是刚被滑动吃掉,是则跳过。每次 touchstart 会重置这个标记,
// 所以真实滑动不产生 click 时也不会误吞下一次点击。
/** 判定手势方向的位移阈值(px):比这还小就还在点按的抖动范围内。 */
const DIRECTION_SLOP = 12
interface HorizontalSwipeOptions {
/** 从左往右滑(手指向右移)。 */
onSwipeRight: () => void
/** 从右往左滑(手指向左移)。 */
onSwipeLeft: () => void
/** 触发翻页所需的最小水平位移(px)。 */
minDistance?: number
/** 水平位移需达到垂直位移的这个倍数才算横滑,避免和纵向滚动打架。 */
dominance?: number
/** 手势超过这个时长不算滑动(毫秒)。只对非跟手的滑动判定生效。 */
maxDuration?: number
/**
* 跟手拖拽开始(方向判定通过、第一次 onDrag 之前)调一次。
* 宿主在这里记下跟手层当前的实际位移:上一次翻页的归位动画可能还没滑完,
* 新手势要接着那点残余位移继续,否则中途接管会把残余一笔抹掉,画面跳一格。
*/
onDragStart?: () => void
/** 跟手拖拽中的位移(px,向右为正)。传了才启用跟手。 */
onDrag?: (dx: number) => void
/** 跟手松手:宿主在这里回弹或翻页(随后浏览器补发的 click 会被吞掉)。 */
onDragEnd?: (dx: number) => void
}
interface HorizontalSwipeHandlers {
onTouchStart: (e: ReactTouchEvent) => void
onTouchMove: (e: ReactTouchEvent) => void
onTouchEnd: (e: ReactTouchEvent) => void
onTouchCancel: () => void
/** 点击回调里先调用;返回 true 表示这次点击已被滑动消费,应跳过默认动作。 */
consumeSwipe: () => boolean
}
export function useHorizontalSwipe({
onSwipeRight,
onSwipeLeft,
minDistance = 45,
dominance = 1.2,
maxDuration = 800,
onDragStart,
onDrag,
onDragEnd,
}: HorizontalSwipeOptions): HorizontalSwipeHandlers {
const startRef = useRef<{ x: number; y: number; t: number } | null>(null)
const consumedRef = useRef(false)
/** 本次手势是否已判定为横滑并在跟手。 */
const draggingRef = useRef(false)
const lastDxRef = useRef(0)
const onTouchStart = useCallback((e: ReactTouchEvent) => {
const t = e.touches[0]
if (!t) return
startRef.current = { x: t.clientX, y: t.clientY, t: Date.now() }
consumedRef.current = false
draggingRef.current = false
lastDxRef.current = 0
}, [])
const onTouchMove = useCallback(
(e: ReactTouchEvent) => {
const start = startRef.current
const t = e.touches[0]
if (!start || !t) return
const dx = t.clientX - start.x
const dy = t.clientY - start.y
if (!draggingRef.current) {
if (!onDrag) return
if (Math.abs(dx) < DIRECTION_SLOP && Math.abs(dy) < DIRECTION_SLOP) return
// 纵向占优:这是滚动,不是翻页。丢掉起点,之后不再判定滑动也不吞 click。
if (Math.abs(dx) < Math.abs(dy) * dominance) {
startRef.current = null
return
}
draggingRef.current = true
onDragStart?.()
}
lastDxRef.current = dx
onDrag?.(dx)
},
[onDragStart, onDrag, dominance],
)
const onTouchEnd = useCallback(
(e: ReactTouchEvent) => {
const start = startRef.current
startRef.current = null
if (draggingRef.current) {
draggingRef.current = false
// 跟手手势一律吞掉随之而来的 click:翻页已经在 onDragEnd 里决定了,
// 不吞的话短距离拖动会「回弹 + 再翻一页」。
consumedRef.current = true
const t = e.changedTouches[0]
// 用松手这一刻的位置而不是最后一次 touchmove:手指最后那几像素也要算进去。
const dx = start && t ? t.clientX - start.x : lastDxRef.current
onDragEnd?.(dx)
return
}
const t = e.changedTouches[0]
if (!start || !t) return
const dx = t.clientX - start.x
const dy = t.clientY - start.y
if (Date.now() - start.t > maxDuration) return
if (Math.abs(dx) < minDistance) return
if (Math.abs(dx) < Math.abs(dy) * dominance) return
consumedRef.current = true
if (dx > 0) onSwipeRight()
else onSwipeLeft()
},
[onSwipeRight, onSwipeLeft, minDistance, dominance, maxDuration, onDragEnd],
)
const onTouchCancel = useCallback(() => {
startRef.current = null
if (!draggingRef.current) return
draggingRef.current = false
// 手势被系统打断(来电、边缘返回手势):按「没翻页」处理,让内容回弹。
// 传 0 而不是最后的位置——打断不等于用户想翻页,别顺手翻过去。
onDragEnd?.(0)
}, [onDragEnd])
const consumeSwipe = useCallback(() => {
if (!consumedRef.current) return false
consumedRef.current = false
return true
}, [])
return { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel, consumeSwipe }
}