抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

uni-app 原生 tabBar 只能做图标 + 文字的平铺样式,而项目需要的是悬浮胶囊 + 滑动指示器 + 中央 + 按钮的毛玻璃效果。本文记录自定义 TabBar 的实现:状态管理、样式与动画、切换时机,以及 H5 与 App 两端的平台差异处理。

一、方案:保留原生 tabBar 配置,渲染交给组件

pages.json 中 tabBar 仍保留完整配置,只是加上 custom: true:

1
2
3
4
5
6
7
"tabBar": {
"custom": true,
"color": "#6b7280",
"selectedColor": "#17abe3",
"backgroundColor": "#ffffff",
"list": [ ... 4 个 tab ... ]
}

这样做的原因:switchTab、页面路由等能力仍依赖原生 tabBar 的 list 定义,custom: true 只是接管渲染。四个 tab 页各自引入 <glass-tabbar /> 组件,视觉层完全由组件实现。

TAB_LIST(图标、文案、路径)定义在 store/tabbar.js,顺序必须与 pages.json 的 tabBar.list 严格一致——原生层和组件层各持一份清单,靠约定对齐:

1
2
3
4
5
6
7
// 与 pages.json tabBar.list 顺序保持一致
const TAB_LIST = [
{ pagePath: '/pages/index/index', text: '首页', icon: '...', activeIcon: '...' },
{ pagePath: '/pages/calendar/calendar', text: '日历', ... },
{ pagePath: '/pages/statistics/statistics', text: '明细', ... },
{ pagePath: '/pages/about/about', text: '我的', ... },
]

二、状态对齐:onShow 回填,不在点击时改写

指示器位置由 store 中的 activeIndex 驱动,而它的更新时机有一条明确规则:点击时不改写,由目标页 onShow 回填。

1
2
3
4
5
6
7
8
// store/tabbar.js —— 各 tab 页 onShow 调用
function sync(path) {
const idx = TAB_LIST.findIndex(tab => tab.pagePath === path)
if (idx >= 0) {
state.activeIndex = idx
state.activePath = path
}
}

组件侧点击只做一件事——立即切换路由:

1
2
3
4
5
const onTab = (tab) => {
// 选中态由目标页 onShow 的 sync() 更新,不能在旧页面上提前滑动。
// 否则会出现"旧页面内容 + 新 Tab 高亮"的中间帧。
uni.switchTab({ url: tab.pagePath })
}

这条规则是闪屏修复的产物。早期实现是点击后先改 activeTab、等 400ms 胶囊滑完再 switchTab,结果制造出”旧页面内容 + 新 Tab 高亮”的中间帧,路由切换瞬间又叠加首帧空窗。改为”立即切换、onShow 回填”后,高亮变化与页面切换落在同一帧附近。外部导航进入(如从详情页返回某个 tab)同样由 sync() 对齐,指示器不会指错。

sync 之外,store 还承担预热期间的就绪回报:各 Tab 页 onReady 时调 markReady(path),App 启动预热用 waitForReady(path, timeout) 等待页面首个 DOM 创建完成——这部分与闪屏修复共用同一状态。

三、毛玻璃样式与滑动指示器

结构分三层:底部胶囊条(glass-tabbar)、绝对定位的指示器(gt-indicator)、悬浮 + 按钮。

毛玻璃胶囊:

1
2
3
4
5
6
7
.glass-tabbar {
background: $surface-floating;
-webkit-backdrop-filter: blur($glass-blur-nav) saturate(140%);
backdrop-filter: blur($glass-blur-nav) saturate(140%);
border: 1rpx solid $border-floating;
box-shadow: $glass-highlight, $shadow-floating;
}

这里有一条针对 Android 的妥协:基座对 backdrop-filter 支持不稳定,背景色不透明度必须给高——即使模糊完全失效,文字也不会透出底层内容,效果降级为半透明纯色条而不是花屏。

滑动指示器:指示器是绝对定位的一格宽容器,宽 calc((100% - 10rpx) / 4)(4 个 tab 均分,减去胶囊 5rpx 内边距),位移只靠一个 transform:

1
2
3
<view class="gt-indicator" :style="indicatorStyle">
<view class="gt-indicator-inner"></view>
</view>
1
2
3
const indicatorStyle = computed(() => ({
transform: `translateX(${activeIndex * 100}%)`
}))
1
2
3
.gt-indicator {
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}

translateX(100%) 恰好移过一格自身宽度,不需要按像素计算偏移;缓动用 cubic-bezier(0.22, 1, 0.36, 1)(快出缓停),比线性更接近 iOS 的手感。内层白胶囊再叠一层阴影做出悬浮层次,选中态文字变主色、图标 scale(1.08) 淡入。

安全区:悬浮栏不占文档流,底部定位要吃掉 iPhone 刘海区:

1
2
3
4
.gt-wrap {
position: fixed;
bottom: calc($font-xl + env(safe-area-inset-bottom));
}

各 tab 页内容区底部同步预留 calc(160rpx + env(safe-area-inset-bottom)),否则最后一张卡片会被悬浮栏遮住。

四、平台差异:H5 端 custom 不生效

tabBar.custom 在 H5 端不生效——原生底栏照样渲染,等于两条 TabBar 叠在一起。处理方式是各 tab 页 onShow 里手动隐藏:

1
2
3
4
onShow(() => {
uni.hideTabBar({ animation: false })
tabbarStore.sync('/pages/index/index')
})

由此引出一个连带问题:早期首页有”弹窗打开时 hide、关闭时 show”的逻辑,弹窗一关 showTabBar 又把原生栏放回来盖住自定义栏。这段逻辑已移除——整个生命周期内都隐藏原生栏,弹窗层级问题自然消失。

App 端 custom: true 正常生效,无需处理;两端共用同一组件与 store,差异只收敛在 hideTabBar 一行。

五、验证清单

  1. H5:四个 tab 显示自定义栏、原生栏隐藏;点击后胶囊滑动与页面切换同步,无”旧内容 + 新高亮”中间帧;
  2. App:毛玻璃生效或降级为不透明纯色(不花屏)、安全区不遮挡;
  3. + 按钮进入新增物品页,activeIcon/inactiveIcon 随选中切换;
  4. 页面内容底部不被悬浮栏遮挡;
  5. 外部导航进入某 tab 后,指示器位置与当前页一致。

总结

自定义 TabBar 的难点不在样式,而在状态归属:高亮何时改、由谁改(目标页 onShow 的 sync()),以及两份 Tab 清单(pages.json 与 TAB_LIST)如何靠约定保持一致。毛玻璃本身只是几行 backdrop-filter,真正要处理的是 Android 降级、H5 原生栏共存和安全区这三端差异。样式可以随时换皮,状态规则和平台适配层才是稳定下来的部分。

相关阅读:Uniapp 首次切 Tab 闪屏的排查与修复

灌水


灌水