import { computed, nextTick, onBeforeUnmount, ref, type ComputedRef, type CSSProperties } from 'vue' import type { Router } from 'vue-router' type SwipePhase = 'idle' | 'tracking' | 'dragging' | 'settling' | 'leaving' type SwipeGesture = { startX: number startY: number startTime: number horizontal: boolean } type RouterHistoryState = { back?: unknown } type UseSwipeBackOptions = { router: Router enabled: ComputedRef } const EDGE_START_WIDTH = 28 const AXIS_LOCK_DISTANCE = 8 const MIN_FLING_DISTANCE = 42 const MIN_FLING_VELOCITY = 0.5 const MAX_COMPLETE_DISTANCE = 110 const COMPLETE_DISTANCE_RATIO = 0.25 const ANIMATION_DURATION = 40 /** * 为移动端页面提供从左侧边缘右滑返回的交互。 * * 只有 Vue Router 写入了站内 back 记录时才允许触发,避免退到宿主页面 * 或关闭 WebView。手势过程中只移动当前页面;完成动画后才真正执行路由返回。 */ export function useSwipeBack({ router, enabled }: UseSwipeBackOptions) { const phase = ref('idle') const offsetX = ref(0) const viewportWidth = ref(1) const backTitle = ref('上一页') const animationDuration = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 1 : ANIMATION_DURATION let gesture: SwipeGesture | null = null let animationTimer: number | undefined let navigationFallbackTimer: number | undefined const progress = computed(() => Math.min(Math.max(offsetX.value / viewportWidth.value, 0), 1), ) const isActive = computed(() => phase.value !== 'idle') const isAnimating = computed(() => phase.value === 'settling' || phase.value === 'leaving') const pageStyle = computed(() => { const style = { '--swipe-back-progress': String(progress.value), '--swipe-back-shadow-opacity': String(progress.value * 0.18), '--swipe-back-animation-duration': `${animationDuration}ms`, } as CSSProperties if (isActive.value) { style.transform = `translate3d(${offsetX.value}px, 0, 0)` } return style }) const underlayStyle = computed(() => ({ '--swipe-back-progress': String(progress.value), '--swipe-back-title-opacity': String(0.35 + progress.value * 0.65), '--swipe-back-title-offset': `${-12 + progress.value * 12}px`, '--swipe-back-content-opacity': String(0.28 + progress.value * 0.72), '--swipe-back-content-offset': `${-20 + progress.value * 20}px`, '--swipe-back-accent-scale': String(0.72 + progress.value * 0.28), }) as CSSProperties, ) function handleTouchStart(event: TouchEvent) { if ( !enabled.value || phase.value !== 'idle' || event.touches.length !== 1 || !hasInternalBackEntry() || shouldIgnoreTarget(event.target) ) { return } const touch = event.touches[0] if (!touch || touch.clientX > EDGE_START_WIDTH) return viewportWidth.value = Math.max(document.documentElement.clientWidth, window.innerWidth, 1) backTitle.value = resolveBackTitle() gesture = { startX: touch.clientX, startY: touch.clientY, startTime: performance.now(), horizontal: false, } phase.value = 'tracking' } function handleTouchMove(event: TouchEvent) { if (!gesture || event.touches.length !== 1) return const touch = event.touches[0] if (!touch) return const deltaX = touch.clientX - gesture.startX const deltaY = touch.clientY - gesture.startY const absX = Math.abs(deltaX) const absY = Math.abs(deltaY) if (!gesture.horizontal) { if (Math.max(absX, absY) < AXIS_LOCK_DISTANCE) return if (deltaX <= 0 || absY >= absX) { resetImmediately() return } gesture.horizontal = true phase.value = 'dragging' } if (event.cancelable) event.preventDefault() offsetX.value = Math.min(Math.max(deltaX, 0), viewportWidth.value) } function handleTouchEnd(event: TouchEvent) { if (!gesture) return const touch = event.changedTouches[0] const activeGesture = gesture gesture = null if (!touch || !activeGesture.horizontal || phase.value !== 'dragging') { settleBack() return } const deltaX = Math.max(touch.clientX - activeGesture.startX, 0) const elapsed = Math.max(performance.now() - activeGesture.startTime, 1) const velocity = deltaX / elapsed const completeDistance = Math.min( viewportWidth.value * COMPLETE_DISTANCE_RATIO, MAX_COMPLETE_DISTANCE, ) const shouldComplete = deltaX >= completeDistance || (deltaX >= MIN_FLING_DISTANCE && velocity >= MIN_FLING_VELOCITY) if (shouldComplete && hasInternalBackEntry()) { completeBack() return } settleBack() } function handleTouchCancel() { if (!gesture) return gesture = null settleBack() } function completeBack() { clearTimers() phase.value = 'leaving' offsetX.value = viewportWidth.value animationTimer = window.setTimeout(() => { router.back() navigationFallbackTimer = window.setTimeout( resetImmediately, Math.max(animationDuration * 2, ANIMATION_DURATION * 2), ) }, animationDuration) } function settleBack() { if (phase.value === 'idle') return clearTimers() phase.value = 'settling' offsetX.value = 0 animationTimer = window.setTimeout(resetImmediately, animationDuration) } function resetImmediately() { clearTimers() gesture = null offsetX.value = 0 phase.value = 'idle' } function hasInternalBackEntry() { const state = window.history.state as RouterHistoryState | null return typeof state?.back === 'string' && state.back.length > 0 } function resolveBackTitle() { const state = window.history.state as RouterHistoryState | null if (typeof state?.back !== 'string' || !state.back) return '上一页' try { return String(router.resolve(state.back).meta.title || '上一页') } catch { return '上一页' } } function clearTimers() { if (animationTimer !== undefined) { window.clearTimeout(animationTimer) animationTimer = undefined } if (navigationFallbackTimer !== undefined) { window.clearTimeout(navigationFallbackTimer) navigationFallbackTimer = undefined } } const removeAfterEach = router.afterEach(() => { if (!isActive.value) return void nextTick().then(() => { window.requestAnimationFrame(resetImmediately) }) }) onBeforeUnmount(() => { clearTimers() removeAfterEach() }) return { backTitle, isActive, isAnimating, pageStyle, underlayStyle, handleTouchStart, handleTouchMove, handleTouchEnd, handleTouchCancel, } } function shouldIgnoreTarget(target: EventTarget | null) { if (!(target instanceof Element)) return false if ( target.closest( [ '[data-swipe-back-disabled]', 'input', 'textarea', 'select', '[contenteditable="true"]', '.van-swipe', '.van-slider', '.van-tabs__wrap', '.van-popup', ].join(','), ) ) { return true } let element: Element | null = target while (element && element !== document.documentElement) { const style = window.getComputedStyle(element) const canScrollHorizontally = element.scrollWidth > element.clientWidth + 1 && (style.overflowX === 'auto' || style.overflowX === 'scroll') if (canScrollHorizontally) return true element = element.parentElement } return false }