优化快速访问插件的下拉手势逻辑

This commit is contained in:
jxxghp
2025-07-02 15:59:11 +08:00
parent b8dff560f0
commit 8d65f0c2a8
5 changed files with 150 additions and 90 deletions
+67 -30
View File
@@ -42,8 +42,10 @@ const recentPlugins = ref<Plugin[]>([])
// 是否加载中 // 是否加载中
const loading = ref(false) const loading = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 上滑关闭相关状态 // 上滑关闭相关状态
const swipeStartY = ref(0)
const isDraggingToClose = ref(false) const isDraggingToClose = ref(false)
const dragOffset = ref(0) const dragOffset = ref(0)
@@ -85,6 +87,7 @@ const componentOpacity = computed(() => {
// 计算插件图标路径 // 计算插件图标路径
function getPluginIcon(plugin: Plugin): string { function getPluginIcon(plugin: Plugin): string {
if (!plugin.plugin_icon) return noImage if (!plugin.plugin_icon) return noImage
if (imageLoadError.value) return noImage
// 如果是网络图片则使用代理后返回 // 如果是网络图片则使用代理后返回
if (plugin?.plugin_icon?.startsWith('http')) if (plugin?.plugin_icon?.startsWith('http'))
@@ -260,10 +263,7 @@ function handleBackdropClick(event: MouseEvent) {
> >
<div class="plugin-icon"> <div class="plugin-icon">
<VAvatar size="48" class="plugin-avatar"> <VAvatar size="48" class="plugin-avatar">
<VImg :src="getPluginIcon(plugin)" :alt="plugin.plugin_name" cover> <VImg :src="getPluginIcon(plugin)" :alt="plugin.plugin_name" cover @error="imageLoadError = true">
<template #error>
<VIcon icon="mdi-puzzle" size="24" />
</template>
</VImg> </VImg>
</VAvatar> </VAvatar>
<!-- 运行状态指示 --> <!-- 运行状态指示 -->
@@ -294,25 +294,25 @@ function handleBackdropClick(event: MouseEvent) {
<style lang="scss" scoped> <style lang="scss" scoped>
.plugin-quick-access { .plugin-quick-access {
position: fixed; position: fixed;
z-index: 9999; /* 提高z-index确保在最上层 */ z-index: 9999;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
background: rgba(var(--v-theme-surface), 0.95); background: rgba(var(--v-theme-surface), 0.95);
block-size: 100vh; block-size: 100vh;
block-size: 100dvh; /* 使用动态视口高度,支持移动端 */ block-size: 100dvh;
inset-block-start: 0; inset-block-start: 0;
inset-inline: 0; inset-inline: 0;
opacity: 0; opacity: 0;
padding-block: env(safe-area-inset-top) env(safe-area-inset-bottom); padding-block: env(safe-area-inset-top) env(safe-area-inset-bottom);
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right); padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
pointer-events: none; /* 隐藏时不阻挡点击 */ pointer-events: none;
transform: translateY(-100%); transform: translateY(-100%);
transition: all 1s cubic-bezier(0.4, 0, 0.2, 1); transition: all 1s cubic-bezier(0.4, 0, 0.2, 1);
&.visible { &.visible {
opacity: 1; opacity: 1;
pointer-events: auto; /* 显示时恢复点击 */ pointer-events: auto;
transform: translateY(0); transform: translateY(0);
} }
} }
@@ -360,13 +360,31 @@ function handleBackdropClick(event: MouseEvent) {
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
min-block-size: 0;
/* 优化滚动体验 */
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
overflow-y: auto; overflow-y: auto;
padding-block: 24px; padding-block: 24px;
padding-inline: 20px; padding-inline: 20px;
scroll-behavior: smooth; scroll-behavior: smooth;
scrollbar-color: rgba(var(--v-theme-on-surface), 0.2) transparent;
scrollbar-width: thin;
&::-webkit-scrollbar {
inline-size: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
background: rgba(var(--v-theme-on-surface), 0.2);
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(var(--v-theme-on-surface), 0.3);
}
} }
.loading-container { .loading-container {
@@ -417,26 +435,41 @@ function handleBackdropClick(event: MouseEvent) {
} }
.recent-plugins-row { .recent-plugins-row {
display: flex; display: grid;
gap: 16px; gap: 16px;
grid-auto-rows: 100px;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
max-block-size: 220px;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
-ms-overflow-style: none; overflow-y: auto;
overflow-x: auto;
padding-block: 0 8px; padding-block: 0 8px;
padding-inline: 0; padding-inline: 0;
scroll-behavior: smooth; scroll-behavior: smooth;
scrollbar-color: rgba(var(--v-theme-on-surface), 0.2) transparent;
/* 隐藏滚动条但保持功能 */ scrollbar-width: thin;
scrollbar-width: none;
&::-webkit-scrollbar { &::-webkit-scrollbar {
display: none; inline-size: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
border-radius: 2px;
background: rgba(var(--v-theme-on-surface), 0.2);
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(var(--v-theme-on-surface), 0.3);
} }
} }
.all-plugins-grid { .all-plugins-grid {
display: grid; display: grid;
gap: 20px; gap: 20px;
grid-auto-rows: 100px;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
} }
@@ -444,10 +477,12 @@ function handleBackdropClick(event: MouseEvent) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
border-radius: 12px; border-radius: 12px;
block-size: 100px;
cursor: pointer; cursor: pointer;
gap: 8px; gap: 6px;
padding-block: 12px; padding-block: 8px;
padding-inline: 8px; padding-inline: 8px;
transition: all 0.2s ease; transition: all 0.2s ease;
@@ -462,20 +497,19 @@ function handleBackdropClick(event: MouseEvent) {
} }
} }
.recent-plugins-row .plugin-item {
flex-shrink: 0;
min-inline-size: 80px;
}
.plugin-icon { .plugin-icon {
position: relative; position: relative;
display: flex;
flex-shrink: 0; /* 防止图标被压缩 */
align-items: center;
justify-content: center;
.plugin-avatar { .plugin-avatar {
box-shadow: 0 2px 8px rgba(0, 0, 0, 10%); box-shadow: 0 1px 3px rgba(0, 0, 0, 10%);
transition: box-shadow 0.2s ease; transition: box-shadow 0.2s ease;
.plugin-item:hover & { .plugin-item:hover & {
box-shadow: 0 4px 12px rgba(0, 0, 0, 15%); box-shadow: 0 2px 6px rgba(0, 0, 0, 15%);
} }
} }
@@ -498,10 +532,16 @@ function handleBackdropClick(event: MouseEvent) {
} }
.plugin-name { .plugin-name {
display: -webkit-box;
overflow: hidden;
flex-shrink: 0;
-webkit-box-orient: vertical;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)); color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
-webkit-line-clamp: 2;
line-height: 1.2; line-height: 1.2;
max-block-size: 2.4em;
text-align: center; text-align: center;
word-break: break-all; word-break: break-all;
} }
@@ -536,8 +576,6 @@ function handleBackdropClick(event: MouseEvent) {
display: flex; display: flex;
justify-content: center; justify-content: center;
inline-size: 100%; inline-size: 100%;
/* 增加可触摸区域 */
padding-block: 12px; padding-block: 12px;
padding-inline: 0; padding-inline: 0;
} }
@@ -568,7 +606,6 @@ function handleBackdropClick(event: MouseEvent) {
} }
} }
/* 优化触摸体验 */
@media (hover: none) and (pointer: coarse) { @media (hover: none) and (pointer: coarse) {
.plugin-item:hover { .plugin-item:hover {
background: transparent; background: transparent;
+82 -53
View File
@@ -57,12 +57,13 @@ const showPluginQuickAccess = ref(false)
const isPulling = ref(false) const isPulling = ref(false)
const startY = ref(0) const startY = ref(0)
const pullDistance = ref(0) const pullDistance = ref(0)
const initialScrollTop = ref(0)
// 计算页面内容的transform // 计算页面内容的transform
const contentTransform = computed(() => { const contentTransform = computed(() => {
if (!isPulling.value || pullDistance.value <= 0) return 'translateY(0)' if (!isPulling.value || pullDistance.value <= 0) return 'translateY(0)'
// 页面内容的移动距离是下拉距离的30%提供更自然的阻尼感 // 页面内容的移动距离是下拉距离的35%与新的下拉距离相匹配
const moveDistance = pullDistance.value * 0.3 const moveDistance = pullDistance.value * 0.35
return `translateY(${moveDistance}px)` return `translateY(${moveDistance}px)`
}) })
@@ -74,7 +75,7 @@ const contentTransition = computed(() => {
// 计算下拉指示器的显示状态 // 计算下拉指示器的显示状态
const showPullIndicator = computed(() => { const showPullIndicator = computed(() => {
return isPulling.value && pullDistance.value > 20 return isPulling.value && pullDistance.value > 30
}) })
// 计算下拉指示器的旋转角度 // 计算下拉指示器的旋转角度
@@ -87,7 +88,7 @@ const indicatorRotation = computed(() => {
// 计算下拉指示器的透明度 // 计算下拉指示器的透明度
const indicatorOpacity = computed(() => { const indicatorOpacity = computed(() => {
if (!isPulling.value) return 0 if (!isPulling.value) return 0
return Math.min(pullDistance.value / 60, 1) return Math.min(pullDistance.value / 80, 1)
}) })
// 根据分类获取菜单列表 // 根据分类获取菜单列表
@@ -115,35 +116,56 @@ function handleUnreadMessage(count: number) {
} }
} }
// 检查是否在页面顶部
function isAtTop(): boolean {
return window.scrollY <= 5
}
// 处理触摸开始 // 处理触摸开始
function handleTouchStart(event: TouchEvent) { function handleTouchStart(event: TouchEvent) {
if (!appMode || !display.mdAndDown.value || !isAtTop()) return if (!appMode || !display.mdAndDown.value) return
const touch = event.touches[0] const touch = event.touches[0]
startY.value = touch.clientY startY.value = touch.clientY
// 重置下拉状态,但不立即阻止滚动
isPulling.value = false isPulling.value = false
pullDistance.value = 0 pullDistance.value = 0
// 记录开始时的滚动位置,用于更准确的判断
initialScrollTop.value = window.scrollY || document.documentElement.scrollTop || 0
} }
// 处理触摸移动 // 处理触摸移动
function handleTouchMove(event: TouchEvent) { function handleTouchMove(event: TouchEvent) {
if (!appMode || !display.mdAndDown.value || !isAtTop()) return if (!appMode || !display.mdAndDown.value) return
const touch = event.touches[0] const touch = event.touches[0]
const deltaY = touch.clientY - startY.value const deltaY = touch.clientY - startY.value
if (deltaY > 0 && isAtTop()) { // 如果已经开始下拉,继续保持下拉状态,避免中途中断
// 向下拖拽且在页面顶部 if (isPulling.value) {
isPulling.value = true // 继续下拉,但要确保是向下移动
pullDistance.value = Math.min(deltaY * 0.6, 150) // 增加最大距离到150px if (deltaY > -5) {
// 允许轻微的向上偏移(-5px),避免手指抖动导致中断
pullDistance.value = Math.max(0, Math.min(deltaY * 0.7, 250))
// 阻止默认滚动行为
event.preventDefault()
} else {
// 如果向上移动超过阈值,停止下拉
isPulling.value = false
pullDistance.value = 0
}
} else {
// 还没开始下拉,检查是否应该开始
if (deltaY > 5) {
// 检查当前的滚动位置
const currentScrollTop = window.scrollY || document.documentElement.scrollTop || 0
// 阻止默认滚动 // 必须同时满足:1. 向下拖拽超过5px 2. 当前在页面顶部 3. 从顶部开始拖拽
event.preventDefault() if (currentScrollTop <= 100 && initialScrollTop.value <= 100) {
// 向下拖拽且在页面顶部附近,开始下拉
isPulling.value = true
pullDistance.value = Math.min(deltaY * 0.7, 250)
// 阻止默认滚动
event.preventDefault()
}
}
} }
} }
@@ -249,19 +271,16 @@ onMounted(() => {
class="pull-indicator" class="pull-indicator"
:style="{ :style="{
opacity: indicatorOpacity, opacity: indicatorOpacity,
transform: `translate(-50%, ${Math.min(pullDistance * 0.5, 50)}px)`, transform: `translate(-50%, ${Math.min(pullDistance * 0.3, 30)}px)`,
}" }"
> >
<div <div
class="indicator-icon" class="indicator-icon"
:style="{ :style="{
transform: `rotate(${indicatorRotation}deg)`, transform: `scale(${Math.min(1 + pullDistance / 400, 1.15)}) rotate(${indicatorRotation}deg)`,
}" }"
> >
<VIcon icon="mdi-chevron-down" size="large" :color="pullDistance > 120 ? 'success' : 'primary'" /> <VIcon icon="mdi-gesture-swipe-down" size="24" :color="pullDistance > 120 ? 'success' : 'primary'" />
</div>
<div class="indicator-text">
{{ pullDistance > 120 ? t('plugin.releaseToOpen') : t('plugin.pullToOpen') }}
</div> </div>
</div> </div>
<VerticalNavLayout> <VerticalNavLayout>
@@ -327,7 +346,7 @@ onMounted(() => {
<template #after-vertical-nav-items /> <template #after-vertical-nav-items />
<!-- 👉 Pages - 添加下拉跟随动画 --> <!-- 👉 下拉跟随动画 -->
<div <div
class="main-content-wrapper" class="main-content-wrapper"
:style="{ :style="{
@@ -355,39 +374,29 @@ onMounted(() => {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
/* 主内容包装器样式 */
.main-content-wrapper { .main-content-wrapper {
/* 在下拉状态下优化渲染性能 */
backface-visibility: hidden; backface-visibility: hidden;
block-size: 100%; block-size: 100%;
/* 确保包装器不影响原有布局 */
inline-size: 100%; inline-size: 100%;
/* 使用GPU加速来优化动画性能 */
transform: translateZ(0); transform: translateZ(0);
will-change: transform; will-change: transform;
} }
/* 下拉指示器样式 */
.pull-indicator { .pull-indicator {
position: fixed; position: fixed;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
padding: 0; justify-content: center;
border-radius: 0; padding: 6px;
backdrop-filter: none; border-radius: 50%;
backdrop-filter: blur(20px);
/* 完全透明,无背景装饰 */ background: rgba(var(--v-theme-surface), 0.3);
background: none; box-shadow: 0 2px 6px rgba(0, 0, 0, 10%), 0 1px 4px rgba(0, 0, 0, 6%);
box-shadow: none; inset-block-start: 80px;
gap: 4px;
inset-block-start: 64px; /* 紧贴导航栏下方 */
inset-inline-start: 50%; inset-inline-start: 50%;
pointer-events: none; pointer-events: none;
transform: translateX(-50%); transform: translateX(-50%);
transition: opacity 0.2s ease; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
.indicator-icon { .indicator-icon {
@@ -395,18 +404,38 @@ onMounted(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 50%; border-radius: 50%;
background: none; background: rgba(var(--v-theme-primary), 0.08);
block-size: 28px; block-size: 40px;
inline-size: 28px; inline-size: 40px;
transition: transform 0.2s ease; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
.indicator-text { /* 透明主题适配 */
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)); html[class*='transparent'] .pull-indicator,
font-size: 12px; html[class*='mica'] .pull-indicator,
font-weight: 600; html[class*='acrylic'] .pull-indicator {
text-align: center; border: 1px solid rgba(255, 255, 255, 20%);
text-shadow: 0 1px 1px rgba(0, 0, 0, 30%); background: rgba(255, 255, 255, 95%);
white-space: nowrap; box-shadow: 0 8px 32px rgba(0, 0, 0, 12%), 0 4px 16px rgba(0, 0, 0, 8%);
}
html[class*='transparent'] .indicator-icon,
html[class*='mica'] .indicator-icon,
html[class*='acrylic'] .indicator-icon {
background: rgba(var(--v-theme-primary), 0.12);
}
html[data-theme='dark'][class*='transparent'] .pull-indicator,
html[data-theme='dark'][class*='mica'] .pull-indicator,
html[data-theme='dark'][class*='acrylic'] .pull-indicator {
border: 1px solid rgba(255, 255, 255, 10%);
background: rgba(18, 18, 18, 95%);
box-shadow: 0 8px 32px rgba(0, 0, 0, 30%), 0 4px 16px rgba(0, 0, 0, 20%);
}
html[data-theme='dark'][class*='transparent'] .indicator-icon,
html[data-theme='dark'][class*='mica'] .indicator-icon,
html[data-theme='dark'][class*='acrylic'] .indicator-icon {
background: rgba(var(--v-theme-primary), 0.15);
} }
</style> </style>
-2
View File
@@ -2185,8 +2185,6 @@ export default {
quickAccess: 'Quick Access', quickAccess: 'Quick Access',
noPluginsWithPage: 'No plugins with detail pages available', noPluginsWithPage: 'No plugins with detail pages available',
tapToOpen: 'Tap to Return', tapToOpen: 'Tap to Return',
pullToOpen: 'Pull to Open Quick Access',
releaseToOpen: 'Release to Open Quick Access',
recentlyUsed: 'Recently Used', recentlyUsed: 'Recently Used',
allPlugins: 'All Plugins', allPlugins: 'All Plugins',
noRecentPlugins: 'None', noRecentPlugins: 'None',
+1 -3
View File
@@ -2158,12 +2158,10 @@ export default {
cloneFailedGeneral: '插件分身创建失败', cloneFailedGeneral: '插件分身创建失败',
logTitle: '插件日志', logTitle: '插件日志',
quickAccess: '快速访问', quickAccess: '快速访问',
tapToOpen: '点击顶部可回到主界面', tapToOpen: '点击返回主界面',
pullToOpen: '下拉打开快速访问',
noPluginsWithPage: '暂无可用插件', noPluginsWithPage: '暂无可用插件',
recentlyUsed: '最近使用', recentlyUsed: '最近使用',
allPlugins: '所有插件', allPlugins: '所有插件',
releaseToOpen: '松手打开插件',
noRecentPlugins: '无', noRecentPlugins: '无',
}, },
profile: { profile: {
-2
View File
@@ -2159,8 +2159,6 @@ export default {
quickAccess: '快速訪問', quickAccess: '快速訪問',
noPluginsWithPage: '暫無可展示的插件', noPluginsWithPage: '暫無可展示的插件',
tapToOpen: '點擊返回主界面', tapToOpen: '點擊返回主界面',
pullToOpen: '下拉打開快速訪問',
releaseToOpen: '松手打開快速訪問',
recentlyUsed: '最近使用', recentlyUsed: '最近使用',
allPlugins: '所有插件', allPlugins: '所有插件',
noRecentPlugins: '無', noRecentPlugins: '無',