1. 方案选型与底层逻辑
做uniapp的App端开发,遇到tabBar中间图标凸起这种需求是迟早的事。电商、社区、工具类应用,基本都想把“发布”“扫一扫”或者“拍照”这种最高频的动作放在中间,做成一个突出的圆形按钮,视觉上更抓眼球,交互上也方便拇指点按。
但这个需求并不像想象中那么好实现。官方tabBar本来只是一个平铺的导航条,所有图标都是均匀排列,没有给你预留“凸起”这个位置。很多人在网上搜了一堆方案,拿过来改了改,结果不是iOS上正常、Android上错位,就是真机调试没问题、打包后图标直接不见了。问题出在没有理解清楚方案的底层逻辑。
先说结论:App端要实现tabBar图标凸起,本质上只有两条路。一条是继续使用官方tabBar,把某个tab的图标“伪装”成凸起效果;另一条是彻底抛弃官方tabBar,用自定义组件完全接管底部导航的渲染。
这两条路各有各的道理,也有各自的坑,选错了后续很麻烦。
官方tabBar的好处是稳定、原生渲染、性能和切换动画都不用你操心,应用启动时底部导航就已经就位了。隐患在于它能提供的定制能力极其有限,市面上流传的“使用cover-view实现凸起”的方案,本质上是把凸起按钮当成一个悬浮物盖在原生tabBar上面,官方导航本身并没有变。这种方案最大的问题是cover-view的排版能力弱,样式稍微复杂一点就容易出兼容性问题,尤其是Android端的渲染差异非常大。
自定义tabBar的思路则完全不同。它相当于你用一个普通组件,在页面底部自己画一个导航栏出来,点击哪个tab就切换到对应页面,同时管理选中态、角标、凸起样式。表面上看起来多写了不少代码,实际上因为每个像素都是自己控制的,所以凸起效果想怎么折腾都行。代价就是原生的切换动画没有了,路由切换需要你手动处理,部分机型上可能因为渲染时机问题出现短暂白屏。
这两类方案的适用场景也不一样。如果你的项目只改一个中间按钮,其它tab都保持原生行为,且希望尽量少改代码,那官方tabBar加悬浮层会更合适。如果你对底部导航的视觉要求较高,比如要毛玻璃背景、红点动画、动态字体大小,甚至做深色模式适配,自定义tabBar基本就是唯一的选择。
我见过不少开发者一上来就直奔自定义方案,结果页面多了几个hooks、弹窗、下拉刷新之后,底部导航的状态同步开始出问题,越改越乱。反过来,也有团队坚持用官方tabBar加悬浮层,做到后来发现角标位置、安全区适配、路由联动都要额外写兼容代码,代码量一点不比自定义方案少,反而更绕。
所以选方案之前先想清楚一件事:你对底部导航的定制需求只是“凸起”这一个点,还是说未来还可能有更多的视觉、交互上的改动?如果是前者,走官方tabBar路线,性价比最高;如果是后者,别犹豫,直接上自定义tabBar,越早切换成本越低。
另外,题目特别标注了“App端”,这一点很关键。因为同样的问题在H5端和小程序端的解法完全不同。H5端css的position: fixed、z-index那一套怎么玩都不会有太大问题,小程序的cover-view在iOS和Android上的表现差异也有成熟的兜底方案。但App端的渲染层逻辑不一样,尤其是用nvue渲染的时候,很多在H5上看起来没问题的写法,到App上直接失效。
2. 方案一:官方tabBar + 悬浮凸起按钮的完整拆解
2.1 为什么选择cover-view作为悬浮载体
方案一的整体结构非常简单:官方tabBar照常配置四个tab,中间的tab不做图标,或者只放一个透明占位。然后我在页面上叠加一个cover-view组件,把凸起的按钮渲染在tabBar中间位置的偏上方,点击它的时候通过uni.switchTab跳转到对应页面。
这里最关键的技术选型是悬浮层用什么组件承载。很多第一次做这个需求的人会想当然地用view,然后设置position: fixed,bottom: 30px,left: 50%,再加上transform: translateX(-50%),这套写法在H5端似乎一切正常,但放到App端就有问题。
问题出在原生tabBar和webview渲染的层级关系上。App端的官方tabBar是原生层组件,它永远在最上层,普通view组件不管设置多大的z-index,都不可能盖在原生tabBar上面。这就是为什么你在H5上调试得好好的,一跑到真机上,凸起按钮就莫名其妙地藏到tabBar底下去了。
解决这个层级问题有两个思路,一个是用plus自带的subNVue原生子窗体,另一个就是用cover-view。cover-view是专门设计来覆盖在原生组件之上的,它能盖住map、video这些原生组件,同样也能盖住原生tabBar。
我自己的实践也确认了这一点:把凸起按钮的样式放在cover-view上,它能稳定地悬浮在原生tabBar之上,层级没问题。
但cover-view也有明显的短板——它的样式支持范围很小,只支持有限的css属性,border-radius、box-shadow这些勉强能用,但如果你要做复杂的渐变色、毛玻璃模糊、头像圆环加旋转动画之类的效果,cover-view基本就撑不住了。所以这个方案最适合的场景是:凸起按钮的样式本身不算复杂,主要靠一张png图片或者iconfont字体。
顺便插一句,也有人用subNVue来放这个凸起按钮,优点是可以写完整的vue页面和css,视觉效果上限高很多,但subNVue的配置和通信成本不小,要用uni.openSubNVue、uni.postMessage来管理它的创建和通信,对于一个按钮来说,我觉得有点重了。
注意:All of this的前提是你的底部导航确实用的是原生tabBar。如果你用的是自定义tabBar,那方案一就完全不适用,你已经在管渲染层了,直接用view就好,不需要再费劲去搞层级的绕行。
2.2 完整实现:从pages.json配置到样式细节
方案一的落地,按照以下步骤操作。
pages.json里,tabBar的list配置保持四个tab,中间那个tab正常配置pagePath和text,但是iconPath和selectedIconPath都指向一个透明图片。这里需要两个1x1像素的透明png,放不放在static目录无所谓,记得路径别填错就行。
{ "pages": [ { "path": "pages/index/index" }, { "path": "pages/publish/publish" }, { "path": "pages/message/message" }, { "path": "pages/mine/mine" } ], "tabBar": { "color": "#999999", "selectedColor": "#333333", "backgroundColor": "#ffffff", "borderStyle": "black", "list": [ { "pagePath": "pages/index/index", "text": "首页", "iconPath": "static/tab-home.png", "selectedIconPath": "static/tab-home-active.png" }, { "pagePath": "pages/publish/publish", "text": "", "iconPath": "static/tab-transparent.png", "selectedIconPath": "static/tab-transparent.png" }, { "pagePath": "pages/message/message", "text": "消息", "iconPath": "static/tab-msg.png", "selectedIconPath": "static/tab-msg-active.png" }, { "pagePath": "pages/mine/mine", "text": "我的", "iconPath": "static/tab-mine.png", "selectedIconPath": "static/tab-mine-active.png" } ] } }注意中间那个text我留空了,因为官方tabBar的文字和凸起图标放在一起会出现两条文字或者位置错乱的问题,干脆就不显示文字,视觉上更干净。当然这也有代价,在官方tabBar的布局里,中间tab的体积仍然存在着,点击它的空白区域也会触发页面切换,这一点后面我会讲怎么处理。
然后在你需要在底部显示凸起按钮的页面上,也就是tabBar里的那几个页面,分别引入凸起按钮的组件。不想每个页面都手动引入的话,可以写一个通用混入或者全局组件注册,在main.js里用Vue.component注册一次,这样所有页面都能用。
凸起按钮的核心实现长这样,封装成一个组件:
<template> <cover-view class="tabbar-center-btn" @tap="handleTap"> <cover-view class="tabbar-center-btn__inner"> <cover-image class="tabbar-center-btn__icon" src="/static/ic-publish.png" /> </cover-view> </cover-view> </template> <script> export default { name: 'CenterBtn', methods: { handleTap() { uni.switchTab({ url: '/pages/publish/publish' }) } } } </script> <style lang="scss" scoped> .tabbar-center-btn { position: fixed; bottom: calc(20px + env(safe-area-inset-bottom)); left: 50%; transform: translateX(-50%); width: 104px; height: 104px; border-radius: 50%; background: linear-gradient(135deg, #ff9a56, #ff6a00); box-shadow: 0 8px 20px rgba(255, 106, 0, 0.35); display: flex; align-items: center; justify-content: center; z-index: 999; } .tabbar-center-btn__inner { width: 88px; height: 88px; border-radius: 50%; background: #ffffff; display: flex; align-items: center; justify-content: center; } .tabbar-center-btn__icon { width: 46px; height: 46px; } </style>几个关键的数值需要仔细斟酌。底部偏移量bottom的值,官方tabBar的高度一般是50px(部分平台是56px或60px),加上安全区高度。凸起按钮要露出一半左右在tabBar之上,所以底部的距离要稍微大于tabBar高度的一半,我给的是20px基准加安全区。这个值不是固定的,要结合你的按钮高度、希望露出的比例去做微调,建议真机上反复调试。
如果你要按基准值计算,可以这样想:官方tabBar的高度在App端一般约50px,安全区高度在iPhone X及以上的机型是34px,按钮高度若是104px,要让按钮上半部分露在外面,bottom应该等于安全区高度 + 按钮露出高度。按钮露出高度通常取按钮高度的40%-50%,比如50px,那bottom就是84px。加上半个按钮的高度52px,整体覆盖范围就能算出tabBar上大约会盖到多少。像上面这套配置,按钮顶部实际位于tabBar顶端上方约34px处,视觉上已经凸起得很明显了。
我实际测试下来,这个方案在iOS和Android上表现基本稳定。但cover-view对box-shadow的支持在不同平台上有差异,Android上偶尔会出现阴影被裁切或者不显示的情况,使用时可以提前准备一套无阴影的退化样式。
2.3 非tabBar页面的显示问题
方案一还有一个容易踩坑的点:凸起按钮并不会全局自动出现。它的载体是cover-view,你写在哪个页面,它就只在哪个页面渲染。tabBar页面有首页、消息、我的,加上中间那个publish一共四个,你在首页写了这个按钮,切到消息页,消息页是另外独立的页面,不会自动带上这个按钮。
解决方案有两种。一种是每个tab页面都手动引入CenterBtn组件,缺点是要改的地方多,而且每个页面都会有这份代码。另一种是把CenterBtn做成全局组件,在main.js里注册:
// main.js import CenterBtn from '@/components/CenterBtn.vue' Vue.component('CenterBtn', CenterBtn)然后在每个tab页面放一行:
<template> <view> <!-- 页面内容 --> <CenterBtn /> </view> </template>如果你用的是uniapp的easycom规范,甚至不需要手动引入和注册,组件放到components/CenterBtn.vue,页面模板里直接用 就会自动按需引入,这是最低成本的方案。
这是方案一最别扭的地方:它不是一个真正意义上的“全局”渲染,而是靠每个页面自己去挂载一次。好处是这样你能针对每个页面调整按钮的样式,比如首页淡一点,消息页深一点,但坏处是很容易漏,某个新页面忘记挂载,那个页面上就没有凸起按钮。
另一个要注意的是页面生命周期。cover-view是有渲染层概念的,偶尔在页面刚跳转过来时会晚一帧出现,看起来像是闪了一下,影响不大,但如果你的设计对视觉细节要求苛刻,还是需要心里有数。
实操心得:真正生产环境里,我反而推荐在publish页和首页这两个最高频场景里挂载,消息和我的页面如果产品没强制要求,可以不用挂。这样少两个页面的维护负担,而且这两个页面用户本来就很少盯着底部看。
3. 方案二:完全自定义tabBar的组件化实现
3.1 自定义tabBar的整体设计与路由联动
方案二走的是彻底接管的路子。官方tabBar在pages.json里直接不配置,或者只保留tabBar这个key的占位配置,页面的底部导航完全由一个自定义组件来渲染。
这个方案的架构核心是分清两个职责:视觉层和状态层。
视觉层就是那个自定义组件,它接收当前的选中索引,根据索引渲染对应的图标、文字、选中态以及中间的凸起按钮。状态层则是路由跳转,用户点击某项时调用uni.switchTab或uni.reLaunch去切换页面,同时把当前选中的tab索引传给组件,让组件更新高亮状态。
注意:既然你不用官方tabBar了,页面之间就不能用uni.navigateTo去跳转tab页面。uni.navigateTo会触发页面栈叠加,跳来跳去返回逻辑会乱。你要用uni.switchTab或uni.reLaunch,这两个方法才能保证tab页面的切换逻辑正确。
路由联动的关键是在每个tab页面的onShow生命周期里更新当前选中项。因为如果用户是通过分享卡片、扫码、系统通知等方式进入某个页面,你要保证底部导航的高亮状态和当前页面保持一致。
架构上我的做法是在组件内部保存一个activeIndex变量,页面每次onShow的时候去更新这个值。如果项目用的是Vuex或pinia,也可以把activeIndex放进store里,组件通过计算属性读取。数据放store的好处是将来如果需要在某个页面让底部导航直接切到指定tab,只需要改store里的值,组件会自动响应。
3.2 页面结构改造:pages.json与新页面写法
先看pages.json怎么改。最简单的方式是把官方tabBar整个删掉,然后在每个tab页面的config里加一个属性:
{ "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "首页", "disableScroll": true } }, { "path": "pages/publish/publish", "style": { "navigationBarTitleText": "发布", "disableScroll": true } }, { "path": "pages/message/message", "style": { "navigationBarTitleText": "消息", "disableScroll": true } }, { "path": "pages/mine/mine", "style": { "navigationBarTitleText": "我的", "disableScroll": true } } ], "globalStyle": { "navigationBarTextStyle": "black", "navigationBarTitleText": "正在加载", "navigationBarBackgroundColor": "#ffffff", "backgroundColor": "#f5f5f5" } }你没看错,就是完全不配置tabBar字段。官方tabBar的特性,如初始化时自动定位到指定tab、页面栈管理、原生的切换动画,你统统用不了。作为等价方案,你需要在App启动时默认展示第一个tab页面,页面之间的切换由自定义tabBar的点击事件接管。
页面写法上,每个tab页面在自己的结构里引入自定义tabBar组件,放在页面底部:
<template> <view class="page-wrapper"> <!-- 页面主要内容 --> <view class="page-content"> ... </view> <CustomTabBar :active-index="currentTab" /> </view> </template> <script> export default { data() { return { currentTab: 0 } }, onShow() { this.currentTab = 0 } } </script>每个页面里currentTab的值不一样,首页是0,发布页是1,消息页是2,我的页是3。onShow里同步更新,就能够保证组件的高亮状态渲染正确。
值得多提一句的是,因为要预留tabBar的高度给自定义导航栏,页面的主要滚动区域需要用padding-bottom或者margin-bottom把内容撑上去。我习惯的做法是在页面根view上设置padding-bottom: calc(140px + env(safe-area-inset-bottom)),这样内容就不会被底部的导航遮挡,避免滚动时最后几行文字被盖住。
3.3 凸起按钮的实现:圆环、阴影与高清图标适配
自定义tabBar组件的实现核心在于view可以随便布局,凸起按钮因此变得很简单。
我把中间tab的容器做成一个相对定位的元素,里面的凸起按钮用绝对定位往上偏移,视觉上就是从tabBar上长出来的。按钮组件内部使用view搭建,加阴影、圆角、边框,甚至用background-image做渐变背景,几乎没有任何限制。
<template> <view class="custom-tabbar"> <view class="tabbar-item" v-for="(item, index) in tabbarList" :key="index" @tap="handleSwitchTab(index)" > <image class="tabbar-item__icon" :src="item.iconPath" /> <text class="tabbar-item__text" :class="{ active: activeIndex === index }">{{ item.text }}</text> </view> <view class="tabbar-center-container"> <view class="tabbar-center-btn" @tap="handleSwitchTab(1)"> <image class="tabbar-center-btn__icon" src="/static/ic-publish-active.png" /> </view> <text class="tabbar-center-text">发布</text> </view> </view> </template> <script> export default { name: 'CustomTabBar', props: { activeIndex: { type: Number, default: 0 } }, data() { return { tabbarList: [ { iconPath: '/static/tab-home.png', text: '首页' }, { iconPath: '/static/tab-msg.png', text: '消息' }, { iconPath: '/static/tab-mine.png', text: '我的' } ] } }, methods: { handleSwitchTab(index) { if (index === 0) { uni.switchTab({ url: '/pages/index/index' }) } else if (index === 1) { // 发布是特殊页 uni.navigateTo({ url: '/pages/publish/publish' }) } else if (index === 2) { uni.switchTab({ url: '/pages/message/message' }) } else if (index === 3) { uni.switchTab({ url: '/pages/mine/mine' }) } } } } </script>等等,这里我换了一招。中间那个tab如果和官方tabBar一样用switchTab来控制页面切换,会有个问题:发布页如果是一个完整页面,用户从发布页退出时不一定想回到之前的tab,而且发布本来就是偏模态的操作。所以这里凸起按钮我用了navigateTo,将发布页做成一个普通页面,用户填写完内容可以直接返回,这样比tab切换的体验更加自然。
这个决策因人而异,如果你的发布页本质上是一个tab页,希望用户反复进出,那用switchTab没有问题。但我的设计里,发布页是一个带表单的独立页面,它有保存草稿、退出确认、图片上传等完整流程,用navigateTo承载更合理。
高德图标有一个常见问题:如果你用了一个图标库的字体文件来做凸起按钮的图标,在Android和iOS上的渲染会不一致,有些字体图标在Android上会显示偏左或偏上,因为字体基线不同。最稳妥的方案是用png图标,并且根据不同屏幕密度提供多套尺寸。uniapp里通常的做法是放到static目录,直接引用一个固定尺寸的png,使用起来没问题,但如果追求极致清晰度,可以按2x、3x命名,让系统自动选择。
关于icon尺寸,我建议凸起按钮的核心图标至少要做成88px x 88px的设计尺寸(在750px设计稿下),换到真正的物理像素上等于440px x 440px,也就是iPhone 6的3x分辨率的尺寸。这样在大多数现代手机上都能保证清晰,不会出现放大后边缘发虚的情况。
3.4 自定义tabBar常见的能力补齐
自定义tabBar虽然样式自由,但能力需要自己补齐。主要有这么几项:
角标显示。官方tabBar的角标用uni.setTabBarBadge就能设置,自定义后完全失效。你要自己维护一个badge状态,存到store里,tabBar组件渲染时读取,然后用view画一个小红点或者数字。注意数字大于99的时候怎么显示,通常是显示99+。
安全区适配。在iPhone X之后的全系机型上,底部有Home Indicator区域。自定义tabBar要用env(safe-area-inset-bottom)去填充,避免导航栏被系统黑条遮挡。我的处理方式是给整个自定义tabBar容器的padding-bottom设置为env(safe-area-inset-bottom),并且让凸起按钮的定位相对于这个容器内部计算,确保有安全区的机型上布局不错位。
切换动画。官方tabBar的页面切换没有过渡动画,自定义tabBar反而可以做。在页面切换时,用CSS的transition配合点按态,给图标加一点缩放效果,交互感受会好很多。但这只是点到为止的小动效,做太多反而喧宾夺主。
页面初始化时机。自定义tabBar是页面的一部分,所以页面在reLaunch或者冷启动进入首页时,tabBar会先和页面内容一起渲染。如果你的页面内容很重,初始化的瞬间可能会看到底部导航先出来,内容后加载,有白屏感。一个成熟的优化是给tabBar组件添加v-if控制,在页面onReady之后才渲染。不过这也意味着导航会比内容晚一点点出现,权衡取舍要看具体场景。
4. 两种方案的横向对比与选型建议
两种方案之间的选择,本质上是开发和维护成本、以及视觉自由度之间的权衡。
我做一个表格来对比,方便大家直观参考:
| 对比维度 | 方案一(官方tabBar + cover-view悬浮) | 方案二(自定义tabBar) |
|---|---|---|
| 实现难度 | 较低,改动集中在页面和组件样式 | 较高,需要自行管理切换逻辑与状态 |
| 视觉效果上限 | 受限,cover-view的css支持有限 | 几乎无限,view的样式全部可用 |
| 原生能力依赖 | 依赖官方tabBar原生渲染,稳定性高 | 完全脱离官方tabBar,全凭自己 |
| 角标支持 | 可用uni.setTabBarBadge | 需要自己开发,存储状态后渲染 |
| 安全区适配 | 官方tabBar自带适配 | 需要手动适配 |
| 切换动画 | 官方自带,流畅 | 需要自己追加,且性能不可控 |
| 兼容性风险 | 中,主要是cover-view在不同平台的行为差异 | 中,主要是页面栈和路由管理 |
| 维护成本 | 每个页面需要手动挂载组件 | 单组件维护,但路由逻辑需要统一 |
| 适合场景 | 只做中间凸起一个点,其它保持原生 | 视觉定制丰富,交互需求多 |
具体选型的时候,我通常会抛给团队三个问题:
第一个,你对底部导航的视觉改动是“点状”还是“面状”?如果只是中间凸起,官方tabBar加悬浮层就够;如果要做背景渐变、沉浸式透明、侧滑抽屉等整体性的改变,自定义tabBar是必然选择。
第二个,你们项目里对路由跳转的管理完善吗?自定义tabBar对路由的依赖很强,每个tab页面都必须用switchTab或reLaunch跳转。如果你的项目里有大量的api跳转、外部链接、扫码跳转页面等场景,这些非用户点击的跳转要保证tabBar状态不错乱,这很考验状态管理的设计。
第三个,团队的兼容性测试压力大吗?方案一在Android的低端机上要重点测试cover-view的显示,方案二则要重点测试路由切换的稳定性。选一个你觉得在目标用户机型分布中更可控的。
从我给自己项目做选择的经验看,绝大多数项目更适合方案一。因为需求一般都来自产品的一句“这里中间加一个大一点的按钮”,产品不会要求你连tabBar的背景都重新画一遍。而方案二的自由度也带来自制力和维护成本的代价,如果不是有明确的复杂需求,不建议主动上。
注意:还有一个隐藏的考虑因素,如果你同时要发布小程序端,自定义tabBar方案会带来更多适配工作量。小程序端的自定义tabBar官方有专门的支持方式,和App端细节不同,两边都要兼顾的话,后台维护的复杂度就不是乘二这么简单了,而是每个端都有自己的坑。这种情况我建议优先保证App端的实现质量,小程序端看运营策略再决定。
5. 实测踩坑与排查实录
这部分我把自己实际操作中遇到的问题集中写出来,不少是在真机调试过程中花了不少时间才定位的。
5.1 cover-view的transform偏移失效
方案一中,凸起按钮用position: fixed配合left: 50%和transform: translateX(-50%)来做水平居中,在H5和微信小程序上都没问题,但在某些Android App环境下,transform的translateX会失效,按钮会停在屏幕最左侧或者位置错乱。
排查思路是先确认是不是cover-view本身对transform的支持问题。我后来发现,Android的部分系统WebView版本对cover-view内部的transform支持不完整,导致偏移无效。
解决方式有两种。一种是放弃left: 50%加transform,改用left: 0; right: 0; margin: 0 auto;,让元素自己居中。但注意cover-view对margin: 0 auto的支持也有兼容性风险,实测下来并不总是好使。另一种更推荐的思路是把组件做成一个撑满全屏的子窗体,在页面层面直接定位:
.tabbar-center-btn-wrap { position: fixed; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; z-index: 999; } .tabbar-center-btn { position: absolute; left: 50%; bottom: calc(20px + env(safe-area-inset-bottom)); width: 104px; height: 104px; margin-left: -52px; border-radius: 50%; pointer-events: auto; }这个写法不依赖transform,用margin-left负一半宽度来补偿居中,兼容性明显更好。而且外层容器撑满全屏,点击非按钮区域时因为pointer-events: none不会拦截页面交互,只有按钮本身可以点击,也不会影响原本页面的手势操作。
5.2 中间的透明tab仍然可以点击跳转
方案一里,即使中间tab的图标和文字都做成透明的,它对应的那部分tabBar区域仍然是官方tabBar的真身,用户点上去不触发uni.switchTab也难。
这个问题的体验很尴尬:凸起按钮本身可以点,但如果用户手滑点到凸起按钮下方、或者按钮上方一点点区域,就会触发页面跳到发布页,让人感觉莫名其妙。
可以通过在每个tab页面里,把switchTab的目的地拦截掉一部分请求来处理。一个更直接的思路是,发布页做成一个简化版,用户在非凸起区域误进入时,提供一个快速的“返回”出口,但这样体验还是不够好。
目前没有官方办法让原生tabBar的某一格彻底透明且不可点击,所以方案一始终带有这个体验瑕疵。在产品层面能做的对策是:凸起按钮的尺寸尽量做大一些,覆盖tabBar中间逻辑区域的大部分面积,降低误触率。
5.3 打包后凸起图标消失或变成了默认灰色图标
这种问题常出现在方案一里,我在新手阶段也遇到过。症状是H5端预览正常,iOS真机调试正常,但是打正式包之后凸起的图片没了,偶尔变成灰色方块。
根源是图标资源没有被正确打包。uniapp的static目录下的文件通常会被原样打包进去,但如果你把图标放在一个自定义目录下,或者用了动态拼接路径的方式引用图片,在某些打包模式下这些资源会被tree-shaking掉,或者路径被打乱了。
无论方案一还是方案二,强烈建议所有tabBar图标(包括凸起按钮的图标)都放在static根目录下,并使用绝对路径引用,比如/static/ic-publish.png。如果你确实要动态切换图标,也要保证传入的路径是完整合法的绝对路径,不要用相对路径拼接。
5.4 自定义tabBar在苹果全面屏上布局被拉长
自定义tabBar在iPhone X系列以上机型容易遇到布局异常,原因是安全区适配没做对。很多人用fixed定位放置自定义tabBar的时候,bottom只设了0,没有考虑安全区,结果导航栏有一部分陷进Home Indicator区域,看起来就是内容被拉长或者说导航栏整体往下塌了一截。
解决方式是给自定义tabBar的根元素设置padding-bottom: env(safe-area-inset-bottom),也可以在uniapp的全局样式里对这类机型做特殊处理。仅当目标是全面屏机型时,env(safe-area-inset-bottom)才会返回一个非零的安全区高度,其他机型返回0,所以可以放心使用。
5.5 切换页面时自定义tabBar高亮状态错乱
自定义tabBar的高亮状态错乱,是方案二遇到最多的一个坑。症状是:用户从首页点进消息页,又通过消息页里的一条push通知跳到我的页,结果底部导航的高亮还停在消息页。
排查方向非常明确:高亮状态完全依赖每个页面onShow里同步activeIndex,所以一定要确保所有可能进入tab页面的入口都做了同步。如果入口太多太分散,除了在页面onShow里同步之外,还要考虑从路由层面做一个统一的拦截器。uniapp没有直接暴露全局路由拦截的api,但可以通过封装uni.switchTab和uni.navigateTo来实现类似效果:
// utils/route.js const originalSwitchTab = uni.switchTab uni.switchTab = function(options) { // 这里统一更新store里的activeIndex store.commit('setActiveTab', getTabIndexByUrl(options.url)) return originalSwitchTab(options) }这种做法虽然有点hack,但在维护一个庞大的老项目时很管用,能避免每个页面都去做同步的重复劳动。如果是新项目,一开始就把activeIndex放到store里,组件用computed去读,然后页面统一在onShow中更新store,就能从结构上避免这类问题。
5.6 真机调试正常但发布版渲染异常
最后一类问题发生在打包配置上。uniapp开发时用的是HBuilderX附带的调试基座,发布版本用的是另一个打包环境,两者的原生层版本有时不一致,尤其是cover-view、subNVue这些依赖原生渲染能力的组件,在打包时可能出现渲染行为不一致。
遇到这种问题,唯一的处理方式是在打包前就养成用自定义基座测试的习惯。HBuilderX允许你打一个自定义调试基座,这个基座和你最终发布版的运行环境一致,可以提前暴露很多兼容性问题。好不好用先不说,这个步骤省不了。
实操心得:我做凸起按钮的时候,先快速用方案一搭了一个demo,结果发现cover-view在特定Android机型上的阴影渲染有问题,临时改用自定义tabBar才通过。后来我也见过一个项目反过来,一开始就上自定义tabBar,却发现团队成员对路由状态管理不熟悉,反而改了半天。所以两边都值得老老实实试一遍,再做决定。
6. 我的一些经验体会
写了这么多,最后按我的习惯聊几句实在的。
中间凸起按钮这个需求,看着是纯UI问题,实际上牵扯到页面层级、路由设计、安全区适配、打包资源——哪一环不想到位都会出事。很多开发者在小程序平台做过类似需求,就想着把同样的代码搬到App端,结果被原生组件层级教育了一顿。uniapp这种跨端框架里,忽略平台差异往往是最贵的教训。
我自己兜兜转转过几次之后,现在的选择标准非常简单:如果需求只是在一两个tab页上放个凸起按钮,我绝对选方案一,简单直接,改起来也快;如果产品提出来的是“底部导航整体重新设计”,那就早点切换到方案二,然后把路由、状态、角标、安全区这些基础设施一次性做好,后面收益会越来越大。
无论如何,建议你在项目里建一个独立的组件目录,把这两个方案的核心能力都封装成可复用的组件和工具函数,包括凸起按钮、tabBar容器、路由封装、状态同步。因为这类需求几乎每个App都逃不掉,这次做好了,下次换项目还能直接搬过去用。
最后分享一个小技巧:不管用哪种方案,测试的时候一定要用真机跑一遍完整的页面流转路径,包括从二级页面返回tab页、从通知栏点击进入某个tab页、冷启动直接打开某个tab页。只有走完全部路径,底部导航的状态同步是否正常才能有结论。在模拟器和H5端跑出来的结果,说服力都有限。