Files
MoviePilot-Frontend/src/composables/usePullDownGesture.ts

278 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ref, computed, onMounted, onBeforeUnmount, readonly, watch } from 'vue'
import { useDisplay } from 'vuetify'
import { usePWA } from './usePWA'
// 下拉手势配置类型
export interface PullDownConfig {
START_THRESHOLD: number // 开始下拉的最小距离
SHOW_INDICATOR: number // 显示指示器的距离
TRIGGER_THRESHOLD: number // 触发回调的距离
MAX_PULL_DISTANCE: number // 最大下拉距离
PULL_RESISTANCE: number // 下拉阻力系数
CONTENT_FOLLOW_RATIO: number // 页面内容跟随比例
TOLERANCE: number // 手指抖动容忍度
}
// 下拉手势选项
export interface PullDownOptions {
config?: Partial<PullDownConfig>
// 检查是否可以使用下拉手势的函数
canUsePullGesture?: () => boolean
// 触发回调
onTrigger?: () => void
// 是否启用默认true
enabled?: boolean
}
// 默认配置
const DEFAULT_CONFIG: PullDownConfig = {
START_THRESHOLD: 20,
SHOW_INDICATOR: 60,
TRIGGER_THRESHOLD: 100,
MAX_PULL_DISTANCE: 200,
PULL_RESISTANCE: 0.75,
CONTENT_FOLLOW_RATIO: 0.4,
TOLERANCE: 80,
}
export function usePullDownGesture(options: PullDownOptions = {}) {
const display = useDisplay()
const { appMode } = usePWA()
// 合并配置
const config = { ...DEFAULT_CONFIG, ...options.config }
// 状态管理
const isPulling = ref(false)
const startY = ref(0)
const pullDistance = ref(0)
const initialScrollTop = ref(0)
const hasDialogOpen = ref(false)
const lastDialogCheckTime = ref(0)
const DIALOG_CHECK_INTERVAL = 500
// 计算属性
const contentTransform = computed(() => {
if (!isPulling.value || pullDistance.value <= 0) return 'translateY(0)'
const moveDistance = pullDistance.value * config.CONTENT_FOLLOW_RATIO
return `translateY(${moveDistance}px)`
})
const contentTransition = computed(() => {
return isPulling.value ? 'none' : 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
})
const showPullIndicator = computed(() => {
return isPulling.value && pullDistance.value >= config.SHOW_INDICATOR
})
const indicatorRotation = computed(() => {
if (!isPulling.value) return 0
const progress = Math.min(
(pullDistance.value - config.SHOW_INDICATOR) / (config.TRIGGER_THRESHOLD - config.SHOW_INDICATOR),
1,
)
return progress * 180
})
const indicatorOpacity = computed(() => {
if (!isPulling.value) return 0
const progress = Math.min(
(pullDistance.value - config.SHOW_INDICATOR) / (config.TRIGGER_THRESHOLD - config.SHOW_INDICATOR),
1,
)
return 0.7 + progress * 0.3
})
const indicatorTransform = computed(() => {
return `translate(-50%, ${Math.min(60 + pullDistance.value - config.SHOW_INDICATOR, 70)}px)`
})
// 弹窗检测函数
const hasOpenDialog = (excludeSelector?: string) => {
try {
const dialogSelectors = [
'.v-overlay--active:not(.v-overlay--scroll-blocked)',
'.v-dialog--active',
'.v-menu--active',
'.v-bottom-sheet--active',
'.v-snackbar--active',
'[role="dialog"]:not([style*="display: none"])',
'.modal:not(.d-none):not([style*="display: none"])',
'[aria-modal="true"]:not([style*="display: none"])',
]
for (const selector of dialogSelectors) {
const elements = document.querySelectorAll(selector)
if (elements.length > 0) {
// 如果需要排除特定元素如QuickAccess面板
if (excludeSelector && elements.length === 1) {
const element = elements[0]
if (element.closest(excludeSelector)) {
continue
}
}
return true
}
}
return false
} catch (error) {
console.warn('检测弹窗状态时出错:', error)
return true
}
}
// 事件处理函数
const handleTouchStart = (event: TouchEvent) => {
if (!appMode.value || !display.mdAndDown.value || !options.enabled) return
// 检查是否可以使用下拉手势
if (options.canUsePullGesture && !options.canUsePullGesture()) return
// 检查是否有弹窗打开
hasDialogOpen.value = hasOpenDialog('.quick-access-panel')
lastDialogCheckTime.value = Date.now()
if (hasDialogOpen.value) return
const touch = event.touches[0]
startY.value = touch.clientY
// 重置下拉状态
isPulling.value = false
pullDistance.value = 0
// 记录开始时的滚动位置
initialScrollTop.value = window.scrollY || document.documentElement.scrollTop || 0
}
const handleTouchMove = (event: TouchEvent) => {
if (!appMode.value || !display.mdAndDown.value || !options.enabled) return
// 检查是否可以使用下拉手势
if (options.canUsePullGesture && !options.canUsePullGesture()) return
// 只在必要时重新检测弹窗
const currentTime = Date.now()
if (currentTime - lastDialogCheckTime.value > DIALOG_CHECK_INTERVAL) {
hasDialogOpen.value = hasOpenDialog('.quick-access-panel')
lastDialogCheckTime.value = currentTime
}
if (hasDialogOpen.value) {
isPulling.value = false
pullDistance.value = 0
return
}
const touch = event.touches[0]
const deltaY = touch.clientY - startY.value
if (isPulling.value) {
if (deltaY > -config.TOLERANCE) {
pullDistance.value = Math.max(0, Math.min(deltaY * config.PULL_RESISTANCE, config.MAX_PULL_DISTANCE))
event.preventDefault()
} else {
isPulling.value = false
pullDistance.value = 0
}
} else {
if (deltaY > config.START_THRESHOLD) {
const currentScrollTop = window.scrollY || document.documentElement.scrollTop || 0
if (currentScrollTop <= 100 && initialScrollTop.value <= 100) {
isPulling.value = true
pullDistance.value = Math.min(deltaY * config.PULL_RESISTANCE, config.MAX_PULL_DISTANCE)
event.preventDefault()
}
}
}
}
const handleTouchEnd = () => {
if (!appMode.value || !display.mdAndDown.value || !options.enabled) return
// 检查是否可以使用下拉手势
if (options.canUsePullGesture && !options.canUsePullGesture()) return
// 重置弹窗检测标志
hasDialogOpen.value = false
lastDialogCheckTime.value = 0
if (isPulling.value && pullDistance.value >= config.TRIGGER_THRESHOLD) {
// 达到触发阈值,执行回调
options.onTrigger?.()
}
// 停止拖拽状态
isPulling.value = false
// 延迟重置其他状态
setTimeout(() => {
pullDistance.value = 0
startY.value = 0
}, 300)
}
// 生命周期管理
let eventsAdded = false
const addEventListeners = () => {
if (!eventsAdded && appMode.value) {
document.addEventListener('touchstart', handleTouchStart, { passive: false })
document.addEventListener('touchmove', handleTouchMove, { passive: false })
document.addEventListener('touchend', handleTouchEnd, { passive: true })
eventsAdded = true
}
}
const removeEventListeners = () => {
if (eventsAdded) {
document.removeEventListener('touchstart', handleTouchStart)
document.removeEventListener('touchmove', handleTouchMove)
document.removeEventListener('touchend', handleTouchEnd)
eventsAdded = false
}
}
// 监听 appMode 变化动态添加/移除事件监听器
onMounted(() => {
watch(
appMode,
newValue => {
if (newValue) {
addEventListeners()
} else {
removeEventListeners()
}
},
{ immediate: true },
)
})
onBeforeUnmount(() => {
removeEventListeners()
})
return {
// 状态
isPulling: readonly(isPulling),
pullDistance: readonly(pullDistance),
// 计算属性
contentTransform,
contentTransition,
showPullIndicator,
indicatorRotation,
indicatorOpacity,
indicatorTransform,
// 配置
config,
// 工具函数
hasOpenDialog,
}
}