perf: 优化导航栏动画流畅度

This commit is contained in:
PKC278
2026-01-01 12:29:42 +08:00
parent b470f182c9
commit 95282f9883
4 changed files with 147 additions and 77 deletions

View File

@@ -15,7 +15,6 @@ defineProps({
},
})
const display = useDisplay()
// PWA模式检测
const { appMode } = usePWA()
@@ -171,51 +170,57 @@ const showDynamicButton = computed(() => {
<template>
<Teleport v-if="appMode && showNav" to="body">
<div class="footer-nav-container">
<VCard elevation="3" class="footer-nav-card border" rounded="pill" :class="{ 'shift-left': showDynamicButton }">
<VCardText class="footer-card-content">
<!-- 添加指示器 -->
<div ref="indicator" class="nav-indicator"></div>
<VBtnToggle class="footer-btn-group" :mandatory="true" v-model="currentMenu">
<!-- 遍历底部菜单项 -->
<VBtn
v-for="menu in footerMenus"
:key="menu.to"
:to="menu.to"
:variant="currentMenu === menu.to ? 'text' : 'plain'"
color="primary"
:ripple="false"
class="footer-nav-btn"
rounded="pill"
:class="{ 'footer-nav-btn-active': currentMenu === menu.to }"
:value="menu.to"
>
<div class="btn-content">
<VIcon :icon="menu.icon" size="32"></VIcon>
<span v-if="!isEnglish" class="text-xs">{{ menu.title }}</span>
</div>
</VBtn>
<TransitionGroup name="footer-nav" tag="div" class="footer-nav-group">
<VCard key="main-nav" elevation="3" class="footer-nav-card border" rounded="pill">
<VCardText class="footer-card-content">
<!-- 添加指示器 -->
<div ref="indicator" class="nav-indicator"></div>
<VBtnToggle class="footer-btn-group" :mandatory="true" v-model="currentMenu">
<!-- 遍历底部菜单项 -->
<VBtn
v-for="menu in footerMenus"
:key="menu.to"
:to="menu.to"
:variant="currentMenu === menu.to ? 'text' : 'plain'"
color="primary"
:ripple="false"
class="footer-nav-btn"
rounded="pill"
:class="{ 'footer-nav-btn-active': currentMenu === menu.to }"
:value="menu.to"
>
<div class="btn-content">
<VIcon :icon="menu.icon" size="32"></VIcon>
<span v-if="!isEnglish" class="text-xs">{{ menu.title }}</span>
</div>
</VBtn>
<!-- 更多按钮 -->
<VBtn
:variant="currentMenu === '/apps' ? 'text' : 'plain'"
color="primary"
:ripple="false"
to="/apps"
rounded="pill"
class="footer-nav-btn"
:class="{ 'footer-nav-btn-active': currentMenu === '/apps' }"
value="/apps"
>
<div class="btn-content">
<VIcon icon="mdi-dots-horizontal" size="32"></VIcon>
<span v-if="!isEnglish" class="text-xs">{{ t('nav.more') }}</span>
</div>
</VBtn>
</VBtnToggle>
</VCardText>
</VCard>
<Transition name="fade-slide">
<VCard v-if="showDynamicButton" elevation="3" class="footer-nav-card dynamic-btn-card border" rounded="pill">
<!-- 更多按钮 -->
<VBtn
:variant="currentMenu === '/apps' ? 'text' : 'plain'"
color="primary"
:ripple="false"
to="/apps"
rounded="pill"
class="footer-nav-btn"
:class="{ 'footer-nav-btn-active': currentMenu === '/apps' }"
value="/apps"
>
<div class="btn-content">
<VIcon icon="mdi-dots-horizontal" size="32"></VIcon>
<span v-if="!isEnglish" class="text-xs">{{ t('nav.more') }}</span>
</div>
</VBtn>
</VBtnToggle>
</VCardText>
</VCard>
<VCard
v-if="showDynamicButton"
key="dynamic-btn"
elevation="3"
class="footer-nav-card dynamic-btn-card border"
rounded="pill"
>
<VCardText class="footer-card-content">
<!-- 各页面的动态按钮 -->
<VBtn
@@ -230,7 +235,7 @@ const showDynamicButton = computed(() => {
</VBtn>
</VCardText>
</VCard>
</Transition>
</TransitionGroup>
</div>
</Teleport>
</template>
@@ -246,6 +251,12 @@ const showDynamicButton = computed(() => {
inset-inline: 0;
padding-block-end: calc(6px + env(safe-area-inset-bottom, 0px));
pointer-events: none;
}
.footer-nav-group {
display: flex;
align-items: center;
justify-content: center;
// 按钮卡片之间的间距
> .v-card + .v-card {
@@ -260,6 +271,7 @@ const showDynamicButton = computed(() => {
background-color: rgba(var(--v-theme-surface), 0.6);
pointer-events: auto;
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
will-change: transform, max-width, opacity;
// 透明主题下的特殊样式
.v-theme--transparent & {
@@ -267,10 +279,6 @@ const showDynamicButton = computed(() => {
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));
}
&.shift-left {
transform: translateX(0);
}
.v-btn-toggle {
block-size: auto;
min-block-size: 56px;
@@ -328,6 +336,7 @@ const showDynamicButton = computed(() => {
block-size: auto;
inline-size: auto;
min-block-size: 0;
max-width: 60px;
.footer-card-content {
padding: 3px;
@@ -349,23 +358,25 @@ const showDynamicButton = computed(() => {
}
}
// 淡入滑动动画
.fade-slide-enter-active {
// 底部导航动画
.footer-nav-enter-active,
.footer-nav-leave-active {
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
overflow: hidden;
}
.fade-slide-leave-active {
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
}
.fade-slide-enter-from {
.footer-nav-enter-from,
.footer-nav-leave-to {
opacity: 0;
max-width: 0 !important;
margin-inline-start: 0 !important;
border-width: 0 !important;
padding: 0 !important;
transform: translateX(20px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(20px);
.footer-nav-move {
transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
}
@keyframes fade-in {

View File

@@ -231,12 +231,23 @@ registerHeaderTab({
],
})
// 页面是否准备就绪
const isReady = ref(false)
// 定时器
let timer: ReturnType<typeof setTimeout>
onBeforeMount(async () => {
await loadConfig()
initializeColors()
})
onMounted(async () => {
// 延迟渲染内容,避免阻塞页面切换动画
timer = setTimeout(() => {
isReady.value = true
}, 400)
await loadExtraRecommendSources()
// 为新增的数据源也生成颜色
extraRecommendSources.value.forEach(source => {
@@ -246,6 +257,10 @@ onMounted(async () => {
})
})
onUnmounted(() => {
if (timer) clearTimeout(timer)
})
onActivated(async () => {
await loadExtraRecommendSources()
})
@@ -256,10 +271,16 @@ onActivated(async () => {
<!-- 滚动内容区域 -->
<div class="recommend-content">
<TransitionGroup name="fade">
<MediaCardSlideView v-for="item in filteredViews" :key="item.title" v-bind="item" class="content-group" />
<MediaCardSlideView
v-for="item in filteredViews"
:key="item.title"
v-bind="item"
:ready="isReady"
class="content-group"
/>
</TransitionGroup>
<div v-if="filteredViews.length === 0" class="empty-category">
<div v-if="isReady && filteredViews.length === 0" class="empty-category">
<VIcon icon="mdi-alert-circle-outline" size="large" class="empty-icon" />
<p class="empty-text">{{ t('recommend.noCategoryContent') }}</p>
<VBtn color="primary" variant="tonal" size="small" @click="dialog = true">

View File

@@ -74,17 +74,17 @@ html.v-overlay-scroll-blocked body {
// 路由过渡动画
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.6s;
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(-45px);
transform: translateX(20px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(45px);
transform: translateX(20px);
}
// 网格布局样式

View File

@@ -4,6 +4,7 @@ import type { MediaInfo } from '@/api/types'
import MediaCard from '@/components/cards/MediaCard.vue'
import SlideView from '@/components/slide/SlideView.vue'
import { useI18n } from 'vue-i18n'
import { useIntersectionObserver, until } from '@vueuse/core'
const { t } = useI18n()
@@ -12,6 +13,10 @@ const props = defineProps({
apipath: String,
linkurl: String,
title: String,
ready: {
type: Boolean,
default: true,
},
})
// 提供给子组件的属性
@@ -19,38 +24,71 @@ provide('rankingPropsKey', reactive({ ...props }))
// 组件加载完成
const componentLoaded = ref(false)
// 是否已尝试加载
const hasTriedLoading = ref(false)
// 数据列表
const dataList = ref<MediaInfo[]>([])
// 容器引用
const containerRef = ref<HTMLElement | null>(null)
// 获取订阅列表数据
async function fetchData() {
try {
if (!props.apipath) return
dataList.value = await api.get(props.apipath)
if (dataList.value.length > 0) componentLoaded.value = true
if (dataList.value.length > 0) {
// 数据获取后,等待 ready 信号再渲染,避免阻塞动画
await until(() => props.ready).toBe(true)
}
componentLoaded.value = true
} catch (error) {
console.error(error)
componentLoaded.value = true
} finally {
hasTriedLoading.value = true
}
}
// 加载时获取数据
onMounted(() => {
fetchData()
})
// 使用 IntersectionObserver 实现懒加载
const { stop } = useIntersectionObserver(
containerRef,
([{ isIntersecting }]) => {
if (isIntersecting) {
fetchData()
stop()
}
},
{
rootMargin: '300px', // 提前加载距离
},
)
onActivated(() => {
if (dataList.value.length == 0) {
if (dataList.value.length == 0 && hasTriedLoading.value) {
fetchData()
}
})
</script>
<template>
<SlideView v-if="componentLoaded">
<template #content>
<template v-for="data in dataList" :key="data.tmdb_id || data.douban_id || data.bangumi_id">
<MediaCard :media="data" width="9rem" />
<div ref="containerRef">
<SlideView v-if="componentLoaded">
<template #content>
<template v-for="data in dataList" :key="data.tmdb_id || data.douban_id || data.bangumi_id">
<MediaCard :media="data" width="9rem" />
</template>
</template>
</template>
</SlideView>
</SlideView>
<SlideView v-else-if="!componentLoaded">
<template #content>
<div v-for="i in 10" :key="i" style="width: 9rem">
<VCard class="outline-none overflow-hidden">
<div style="padding-bottom: 150%"></div>
</VCard>
</div>
</template>
</SlideView>
</div>
</template>