feat(mobile): App 竖屏从应用进入设置/日历/文件改为右侧划入、返回划出,并沉浸底部安全区

- 划入期间保留底部选项卡、划入页置顶覆盖,避免高度跳动与结束时闪帧
- 返回应用页时恢复其滚动位置
This commit is contained in:
kuaifan 2026-10-28 08:48:19 +00:00
parent 8ded349077
commit 9fce053b93
7 changed files with 167 additions and 12 deletions

View File

@ -1,5 +1,5 @@
<template>
<div id="app" class="app-view" :class="{'immersive-status': immersiveStatus}">
<div id="app" class="app-view" :class="{'immersive-status': immersiveStatus, 'immersive-navigation': immersiveNavigation}">
<!--顶部状态栏-->
<div class="child-status-bar"></div>
@ -79,6 +79,13 @@
}
}
// 沉浸底部安全区:隐藏占位,页面内容延伸到底部安全区
&.immersive-navigation {
.child-navigation-bar {
display: none;
}
}
.child-navigation-bar {
flex-shrink: 0;
height: var(--navigation-bar-height);
@ -163,7 +170,7 @@ export default {
computed: {
...mapGetters(['aiVisible']),
...mapState(['ws', 'themeConf', 'windowOrientation', 'safeAreaSize', 'isFirstPage', 'mobileTabbar', 'themeName']),
...mapState(['ws', 'themeConf', 'windowOrientation', 'safeAreaSize', 'isFirstPage', 'mobileTabbar', 'mobileTabbarHold', 'themeName']),
statusColor({routeName, windowLandscape}) {
if (!routeName) {
@ -186,6 +193,14 @@ export default {
return windowPortrait && routeName === 'manage-dashboard'
},
/**
* 移动端设置/日历/文件使用沉浸底部安全区(划入动画期间保持占位,避免下层页面高度跳变)
* @returns {boolean}
*/
immersiveNavigation({routeName, windowPortrait, mobileTabbarHold}) {
return windowPortrait && !mobileTabbarHold && /^manage-(setting|calendar|file)/.test(routeName || '')
},
navigationColor({routeName, windowLandscape, mobileTabbar}) {
if (!routeName) {
return null

View File

@ -296,9 +296,19 @@
<div class="manage-box-main" :role="routeName">
<div class="manage-status-bar"><span></span></div>
<keep-alive>
<router-view class="manage-box-view" @on-click="onTabbarClick"></router-view>
</keep-alive>
<transition
:enter-active-class="viewTransition.enter"
:leave-active-class="viewTransition.leave"
@before-leave="onViewBeforeLeave"
@enter="onViewEnter"
@after-enter="tabbarHold = false"
@enter-cancelled="tabbarHold = false"
@after-leave="releaseViewCover"
@leave-cancelled="releaseViewCover">
<keep-alive>
<router-view class="manage-box-view" @on-click="onTabbarClick"></router-view>
</keep-alive>
</transition>
<div class="manage-navigation-bar"><span></span></div>
</div>
@ -458,8 +468,8 @@
</DrawerOverlay>
<!--移动端选项卡-->
<transition name="mobile-slide">
<MobileTabbar v-if="mobileTabbar" @on-click="onTabbarClick"/>
<transition :name="tabbarTransition">
<MobileTabbar v-if="mobileTabbar || tabbarHold" @on-click="onTabbarClick"/>
</transition>
<!--应用详情-->
@ -529,6 +539,10 @@ export default {
return {
loadIng: 0,
viewTransition: {enter: '', leave: ''},
tabbarTransition: 'mobile-slide',
viewCoverEl: null,
mateName: /macintosh|mac os x/i.test(navigator.userAgent) ? '⌘' : 'Ctrl',
addShow: false,
@ -633,6 +647,16 @@ export default {
},
computed: {
// 划入动画期间保持Tabbar(存于 store,App.vue 据此延后沉浸底部安全区)
tabbarHold: {
get() {
return this.$store.state.mobileTabbarHold
},
set(val) {
this.$store.state.mobileTabbarHold = val
}
},
...mapState([
'userInfo',
'userIsAdmin',
@ -692,9 +716,9 @@ export default {
* @param userId
* @returns {{"show-tabbar", "not-logged": boolean}}
*/
pageClass({mobileTabbar, userId}) {
pageClass({mobileTabbar, tabbarHold, userId}) {
return {
'show-tabbar': mobileTabbar,
'show-tabbar': mobileTabbar || tabbarHold,
'not-logged': userId <= 0
}
},
@ -942,8 +966,9 @@ export default {
},
watch: {
'$route' () {
'$route' (to, from) {
this.chackPass();
this.updateViewTransition(to, from);
},
userIsAdmin: {
@ -1084,6 +1109,82 @@ export default {
},
methods: {
/**
* App 竖屏下「应用 → 设置/日历/文件」右侧划入,返回时划出(与设置子页面效果一致)
* @param to
* @param from
*/
updateViewTransition(to, from) {
this.releaseViewCover()
const isSubPage = route => /^manage-(setting|calendar|file)/.test(route.name || '')
if (this.$isEEUIApp && this.windowPortrait) {
if (from.name === 'manage-application' && isSubPage(to)) {
// 划入动画结束后再隐藏底部选项卡
this.tabbarHold = true
this.tabbarTransition = 'none'
this.viewTransition = {enter: 'mobile-dialog-enter-active', leave: 'manage-view-stay'}
return
}
if (isSubPage(from) && to.name === 'manage-application') {
this.tabbarTransition = 'mobile-slide'
this.viewTransition = {enter: 'manage-view-stay', leave: 'mobile-dialog-leave-active manage-view-top'}
return
}
}
this.tabbarHold = false
this.tabbarTransition = 'mobile-slide'
this.viewTransition = {enter: '', leave: ''}
},
/**
* 停留在下层的页面(应用)离开前记录滚动位置(keep-alive 移出 DOM 后会丢失)
* @param el
*/
onViewBeforeLeave(el) {
if (this.viewTransition.leave !== 'manage-view-stay') {
return
}
el.__scrollSnapshot = [el, ...el.querySelectorAll('*')]
.filter(item => item.scrollTop > 0 || item.scrollLeft > 0)
.map(item => ({item, top: item.scrollTop, left: item.scrollLeft}))
},
/**
* 页面重新进入时恢复滚动位置
* @param el
*/
onViewEnter(el) {
if (this.viewTransition.leave === 'manage-view-stay') {
// 划入页面保持置顶直到下层页面移除,避免划入先结束时下层页面透出闪一帧
el.setAttribute('data-view-cover', '')
this.viewCoverEl = el
}
const snapshot = el.__scrollSnapshot
if (!snapshot) {
return
}
delete el.__scrollSnapshot
if (this.viewTransition.enter !== 'manage-view-stay') {
return
}
snapshot.forEach(({item, top, left}) => {
if (el.contains(item)) {
item.scrollTop = top
item.scrollLeft = left
}
})
},
/**
* 移除划入页面的置顶
*/
releaseViewCover() {
if (this.viewCoverEl) {
this.viewCoverEl.removeAttribute('data-view-cover')
this.viewCoverEl = null
}
},
transformEmojiToHtml,
// 插件/微应用菜单角标 {count, dot}
microBadge(menu) {

View File

@ -69,6 +69,8 @@ export default {
// 显示移动端Tabbar
mobileTabbar: false,
// 页面划入动画期间保持Tabbar(见 manage.vue updateViewTransition)
mobileTabbarHold: false,
// 请求时间
callAt: [],

View File

@ -305,6 +305,26 @@
}
}
// 页面切换:划入/划出的页面置顶,另一页面原地停留至动画结束
.manage-view-top,
[data-view-cover] {
z-index: 1;
background-color: #ffffff;
}
.manage-view-stay {
animation: manage-view-stay-animation 0.3s;
}
@keyframes manage-view-stay-animation {
from {
opacity: 1;
}
to {
opacity: 1;
}
}
// 录音发送(缩放)
.mobile-send-enter-active {
animation: mobile-send-enter-animation 0.3s forwards;

View File

@ -826,6 +826,8 @@ body.window-portrait {
}
.file-drag {
.file-list {
// 沉浸底部安全区(见 App.vue immersiveNavigation)
padding-bottom: calc(20px + var(--navigation-bar-height));
> ul {
grid-template-columns: repeat(auto-fill, 80px);
grid-gap: 4px;
@ -847,7 +849,7 @@ body.window-portrait {
}
}
.file-table {
margin: 16px 0 0;
margin: 16px 0 var(--navigation-bar-height);
}
}
}

View File

@ -622,6 +622,13 @@ body.window-portrait {
&.show-tabbar {
.manage-box-main {
margin-bottom: 60px;
// 页面切换动画中,划入/划出的页面覆盖底部选项卡及安全区,避免高度跳变
.manage-view-top,
[data-view-cover] {
bottom: calc(-60px - var(--navigation-bar-height));
height: auto; // 页面自身 height: 100%(如 .page-file)会使 bottom 失效
z-index: 100;
}
}
.mobile-tabbar {
display: flex;
@ -629,6 +636,10 @@ body.window-portrait {
@media (height <= 400px) {
.manage-box-main {
margin-bottom: 0;
.manage-view-top,
[data-view-cover] {
bottom: 0;
}
}
.mobile-tabbar {
display: none;

View File

@ -554,7 +554,8 @@ body.window-portrait {
height: 100%;
z-index: 1;
> ul {
padding: 0 0 20px;
// 沉浸底部安全区(见 App.vue immersiveNavigation)
padding: 0 0 calc(20px + var(--navigation-bar-height));
> li {
position: relative;
padding: 0 32px;
@ -598,6 +599,9 @@ body.window-portrait {
.setting-content-title {
display: none;
}
.setting-content-view {
padding-bottom: var(--navigation-bar-height);
}
}
}