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
+71 -60
View File
@@ -15,7 +15,6 @@ defineProps({
}, },
}) })
const display = useDisplay() const display = useDisplay()
// PWA模式检测 // PWA模式检测
const { appMode } = usePWA() const { appMode } = usePWA()
@@ -171,51 +170,57 @@ const showDynamicButton = computed(() => {
<template> <template>
<Teleport v-if="appMode && showNav" to="body"> <Teleport v-if="appMode && showNav" to="body">
<div class="footer-nav-container"> <div class="footer-nav-container">
<VCard elevation="3" class="footer-nav-card border" rounded="pill" :class="{ 'shift-left': showDynamicButton }"> <TransitionGroup name="footer-nav" tag="div" class="footer-nav-group">
<VCardText class="footer-card-content"> <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"> <div ref="indicator" class="nav-indicator"></div>
<!-- 遍历底部菜单项 --> <VBtnToggle class="footer-btn-group" :mandatory="true" v-model="currentMenu">
<VBtn <!-- 遍历底部菜单项 -->
v-for="menu in footerMenus" <VBtn
:key="menu.to" v-for="menu in footerMenus"
:to="menu.to" :key="menu.to"
:variant="currentMenu === menu.to ? 'text' : 'plain'" :to="menu.to"
color="primary" :variant="currentMenu === menu.to ? 'text' : 'plain'"
:ripple="false" color="primary"
class="footer-nav-btn" :ripple="false"
rounded="pill" class="footer-nav-btn"
:class="{ 'footer-nav-btn-active': currentMenu === menu.to }" rounded="pill"
:value="menu.to" :class="{ 'footer-nav-btn-active': currentMenu === menu.to }"
> :value="menu.to"
<div class="btn-content"> >
<VIcon :icon="menu.icon" size="32"></VIcon> <div class="btn-content">
<span v-if="!isEnglish" class="text-xs">{{ menu.title }}</span> <VIcon :icon="menu.icon" size="32"></VIcon>
</div> <span v-if="!isEnglish" class="text-xs">{{ menu.title }}</span>
</VBtn> </div>
</VBtn>
<!-- 更多按钮 --> <!-- 更多按钮 -->
<VBtn <VBtn
:variant="currentMenu === '/apps' ? 'text' : 'plain'" :variant="currentMenu === '/apps' ? 'text' : 'plain'"
color="primary" color="primary"
:ripple="false" :ripple="false"
to="/apps" to="/apps"
rounded="pill" rounded="pill"
class="footer-nav-btn" class="footer-nav-btn"
:class="{ 'footer-nav-btn-active': currentMenu === '/apps' }" :class="{ 'footer-nav-btn-active': currentMenu === '/apps' }"
value="/apps" value="/apps"
> >
<div class="btn-content"> <div class="btn-content">
<VIcon icon="mdi-dots-horizontal" size="32"></VIcon> <VIcon icon="mdi-dots-horizontal" size="32"></VIcon>
<span v-if="!isEnglish" class="text-xs">{{ t('nav.more') }}</span> <span v-if="!isEnglish" class="text-xs">{{ t('nav.more') }}</span>
</div> </div>
</VBtn> </VBtn>
</VBtnToggle> </VBtnToggle>
</VCardText> </VCardText>
</VCard> </VCard>
<Transition name="fade-slide"> <VCard
<VCard v-if="showDynamicButton" elevation="3" class="footer-nav-card dynamic-btn-card border" rounded="pill"> v-if="showDynamicButton"
key="dynamic-btn"
elevation="3"
class="footer-nav-card dynamic-btn-card border"
rounded="pill"
>
<VCardText class="footer-card-content"> <VCardText class="footer-card-content">
<!-- 各页面的动态按钮 --> <!-- 各页面的动态按钮 -->
<VBtn <VBtn
@@ -230,7 +235,7 @@ const showDynamicButton = computed(() => {
</VBtn> </VBtn>
</VCardText> </VCardText>
</VCard> </VCard>
</Transition> </TransitionGroup>
</div> </div>
</Teleport> </Teleport>
</template> </template>
@@ -246,6 +251,12 @@ const showDynamicButton = computed(() => {
inset-inline: 0; inset-inline: 0;
padding-block-end: calc(6px + env(safe-area-inset-bottom, 0px)); padding-block-end: calc(6px + env(safe-area-inset-bottom, 0px));
pointer-events: none; pointer-events: none;
}
.footer-nav-group {
display: flex;
align-items: center;
justify-content: center;
// 按钮卡片之间的间距 // 按钮卡片之间的间距
> .v-card + .v-card { > .v-card + .v-card {
@@ -260,6 +271,7 @@ const showDynamicButton = computed(() => {
background-color: rgba(var(--v-theme-surface), 0.6); background-color: rgba(var(--v-theme-surface), 0.6);
pointer-events: auto; pointer-events: auto;
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1); transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
will-change: transform, max-width, opacity;
// 透明主题下的特殊样式 // 透明主题下的特殊样式
.v-theme--transparent & { .v-theme--transparent & {
@@ -267,10 +279,6 @@ const showDynamicButton = computed(() => {
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5)); background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));
} }
&.shift-left {
transform: translateX(0);
}
.v-btn-toggle { .v-btn-toggle {
block-size: auto; block-size: auto;
min-block-size: 56px; min-block-size: 56px;
@@ -328,6 +336,7 @@ const showDynamicButton = computed(() => {
block-size: auto; block-size: auto;
inline-size: auto; inline-size: auto;
min-block-size: 0; min-block-size: 0;
max-width: 60px;
.footer-card-content { .footer-card-content {
padding: 3px; 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); transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
overflow: hidden;
} }
.fade-slide-leave-active { .footer-nav-enter-from,
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1); .footer-nav-leave-to {
}
.fade-slide-enter-from {
opacity: 0; opacity: 0;
max-width: 0 !important;
margin-inline-start: 0 !important;
border-width: 0 !important;
padding: 0 !important;
transform: translateX(20px); transform: translateX(20px);
} }
.fade-slide-leave-to { .footer-nav-move {
opacity: 0; transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
transform: translateX(20px);
} }
@keyframes fade-in { @keyframes fade-in {
+23 -2
View File
@@ -231,12 +231,23 @@ registerHeaderTab({
], ],
}) })
// 页面是否准备就绪
const isReady = ref(false)
// 定时器
let timer: ReturnType<typeof setTimeout>
onBeforeMount(async () => { onBeforeMount(async () => {
await loadConfig() await loadConfig()
initializeColors() initializeColors()
}) })
onMounted(async () => { onMounted(async () => {
// 延迟渲染内容,避免阻塞页面切换动画
timer = setTimeout(() => {
isReady.value = true
}, 400)
await loadExtraRecommendSources() await loadExtraRecommendSources()
// 为新增的数据源也生成颜色 // 为新增的数据源也生成颜色
extraRecommendSources.value.forEach(source => { extraRecommendSources.value.forEach(source => {
@@ -246,6 +257,10 @@ onMounted(async () => {
}) })
}) })
onUnmounted(() => {
if (timer) clearTimeout(timer)
})
onActivated(async () => { onActivated(async () => {
await loadExtraRecommendSources() await loadExtraRecommendSources()
}) })
@@ -256,10 +271,16 @@ onActivated(async () => {
<!-- 滚动内容区域 --> <!-- 滚动内容区域 -->
<div class="recommend-content"> <div class="recommend-content">
<TransitionGroup name="fade"> <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> </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" /> <VIcon icon="mdi-alert-circle-outline" size="large" class="empty-icon" />
<p class="empty-text">{{ t('recommend.noCategoryContent') }}</p> <p class="empty-text">{{ t('recommend.noCategoryContent') }}</p>
<VBtn color="primary" variant="tonal" size="small" @click="dialog = true"> <VBtn color="primary" variant="tonal" size="small" @click="dialog = true">
+3 -3
View File
@@ -74,17 +74,17 @@ html.v-overlay-scroll-blocked body {
// 路由过渡动画 // 路由过渡动画
.fade-slide-leave-active, .fade-slide-leave-active,
.fade-slide-enter-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 { .fade-slide-enter-from {
opacity: 0; opacity: 0;
transform: translateY(-45px); transform: translateX(20px);
} }
.fade-slide-leave-to { .fade-slide-leave-to {
opacity: 0; opacity: 0;
transform: translateY(45px); transform: translateX(20px);
} }
// 网格布局样式 // 网格布局样式
+50 -12
View File
@@ -4,6 +4,7 @@ import type { MediaInfo } from '@/api/types'
import MediaCard from '@/components/cards/MediaCard.vue' import MediaCard from '@/components/cards/MediaCard.vue'
import SlideView from '@/components/slide/SlideView.vue' import SlideView from '@/components/slide/SlideView.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useIntersectionObserver, until } from '@vueuse/core'
const { t } = useI18n() const { t } = useI18n()
@@ -12,6 +13,10 @@ const props = defineProps({
apipath: String, apipath: String,
linkurl: String, linkurl: String,
title: String, title: String,
ready: {
type: Boolean,
default: true,
},
}) })
// 提供给子组件的属性 // 提供给子组件的属性
@@ -19,38 +24,71 @@ provide('rankingPropsKey', reactive({ ...props }))
// 组件加载完成 // 组件加载完成
const componentLoaded = ref(false) const componentLoaded = ref(false)
// 是否已尝试加载
const hasTriedLoading = ref(false)
// 数据列表 // 数据列表
const dataList = ref<MediaInfo[]>([]) const dataList = ref<MediaInfo[]>([])
// 容器引用
const containerRef = ref<HTMLElement | null>(null)
// 获取订阅列表数据 // 获取订阅列表数据
async function fetchData() { async function fetchData() {
try { try {
if (!props.apipath) return if (!props.apipath) return
dataList.value = await api.get(props.apipath) 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) { } catch (error) {
console.error(error) console.error(error)
componentLoaded.value = true
} finally {
hasTriedLoading.value = true
} }
} }
// 加载时获取数据 // 使用 IntersectionObserver 实现懒加载
onMounted(() => { const { stop } = useIntersectionObserver(
fetchData() containerRef,
}) ([{ isIntersecting }]) => {
if (isIntersecting) {
fetchData()
stop()
}
},
{
rootMargin: '300px', // 提前加载距离
},
)
onActivated(() => { onActivated(() => {
if (dataList.value.length == 0) { if (dataList.value.length == 0 && hasTriedLoading.value) {
fetchData() fetchData()
} }
}) })
</script> </script>
<template> <template>
<SlideView v-if="componentLoaded"> <div ref="containerRef">
<template #content> <SlideView v-if="componentLoaded">
<template v-for="data in dataList" :key="data.tmdb_id || data.douban_id || data.bangumi_id"> <template #content>
<MediaCard :media="data" width="9rem" /> <template v-for="data in dataList" :key="data.tmdb_id || data.douban_id || data.bangumi_id">
<MediaCard :media="data" width="9rem" />
</template>
</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> </template>