☰
uniapp App端凸起tabBar实现:原生view叠加与自定义组件方案解析
2026/10/15 1:36:16 网站建设 项目流程

最近在做一个社区类App,UI给了一个“发布”按钮中间凸起、周围带呼吸光圈的动效设计。第一反应是先改pages.json里的tabBar配置,但翻了半天文档,发现uniapp原生tabBar只支持矩形icon和文本,图标凸出边界这种事根本不给你留口子。于是只能自己动手。这篇文章把我在App端实际跑通的两种tabBar图标凸起方案完整复盘一下:一种是关闭原生tabBar、用自定义组件自绘;另一种是保留原生tabBar、用plus.nativeObj.view在原生层叠一个凸起view。两种都能在App端实现同样效果,但开发量、性能、坑位完全不同。

如果你也在处理这个需求,希望这篇能帮你少走弯路。

1. 为什么凸起tabBar不能靠原生配置,必须先搞懂App端的渲染机制

1.1 产品要的“凸起”在原生tabBar上为什么做不了

uniapp的App端实际是双引擎:逻辑层和视图层分离,原生tabBar是系统级控件,不是普通HTML/CSS,无法通过view样式覆盖。tabBar每一个item都是系统绘制的方形区域,图标和文字只能在固定边界内布局,想让它“长”出边界,系统没有开放接口。更关键的是原生tabBar的层级在webview之下,页面里就算写一个fixed元素也盖不住它,所以常规CSS方案直接死掉。

也许有人会想,那我能不能在原生tabBar上方放一个普通view,通过遮盖来模拟凸起?在App端不行,因为webview的渲染层级低于原生tabBar,你写一个position: fixed; bottom: 0的按钮,会被原生tabBar盖住,根本露不出来。H5端倒是可以,但App端彻底没戏。

因此,App端要做出凸起,思路只有两个:第一,把原生tabBar干掉,完全自己画;第二,保留原生tabBar,在原生层再加一块view。这就是标题里说的“两种方式”的底层逻辑。

1.2 两种实现路线的本质区别:vue组件渲染与原生控件叠加

路线一是设置"custom": true,pages.json里的tabBar配置只保留页面路径和跳转逻辑,真正渲染交给页面内的自定义组件。这个组件本质是一个普通的vue视图,由webview渲染,能随便用CSS变形、动画、阴影,开发体验最好,代价是所有tab页面都要引入同一个组件,并且原生tabBar切换页面时会有状态同步问题需要处理。

路线二用的是plus.nativeObj.view,这是5+App提供的原生view,可以无视webview和原生tabBar的层级,直接画在屏幕上。我们可以在原生tabBar上方叠一个透明、带凸起图标的原生view,让原生tabBar看起来像是被“挤”出了一个按钮。由于是原生绘制,性能最高,但只能跑在App端,而且调试麻烦,动画能力弱。

这两种路线不冲突,完全可以根据团队技术储备和项目目标来选择。下面我把两条路线的完整实现和坑位都拆开讲。

2. 路线一:自定义tabBar组件方案,unshift一个“肿瘤”按钮

2.1 从pages.json的custom配置开始

首先是pages.json。假设项目有5个tab页:首页、分类、发布、消息、我的,中间发布按钮凸起。配置如下:

"tabBar": { "custom": true, "color": "#999999", "selectedColor": "#333333", "backgroundColor": "#ffffff", "borderStyle": "black", "list": [ { "pagePath": "pages/index/index", "text": "首页" }, { "pagePath": "pages/category/category", "text": "分类" }, { "pagePath": "pages/publish/publish", "text": "发布" }, { "pagePath": "pages/message/message", "text": "消息" }, { "pagePath": "pages/mine/mine", "text": "我的" } ] }

注意,因为custom: true,list里的iconPath可以不再设置,即使设置了也不会被原生渲染。但pagePath和text必须正确,因为uni.switchTab需要靠这些路径识别tab页面。很多人在这一步犯错,后面怎么跳转都高亮不了,就是因为list和实际页面路径对不上。

此时运行App,底部会空出一段空白,因为原生tabBar已经不渲染了,自己写的固定组件还没放上去。这段空白其实是个好事,你可以直接在页面里调试自定义组件,不会受到任何原生遮挡。

2.2 自定义组件的布局与凸起实现细节

新建一个components/custom-tabbar/custom-tabbar.vue文件,模板核心是固定的五个按钮,最关键的中间按钮要“凸出”来。示例:

<template> <view class="tabbar"> <view class="tabbar__item" @click="switchTab(0)"> <text class="tabbar__text">首页</text> </view> <view class="tabbar__item" @click="switchTab(1)"> <text class="tabbar__text">分类</text> </view> <view class="tabbar__item tabbar__item--center" @click="switchTab(2)"> <image class="tabbar__center-icon" src="/static/publish.png" /> <text class="tabbar__center-text">发布</text> </view> <view class="tabbar__item" @click="switchTab(3)"> <text class="tabbar__text">消息</text> </view> <view class="tabbar__item" @click="switchTab(4)"> <text class="tabbar__text">我的</text> </view> </view> </template>

样式上,整个tabbar用position: fixed; bottom: 0; left: 0; right: 0;。关键点:父容器tabbar不能设置overflow: hidden,否则中间的凸起会被裁掉。中间按钮用margin-top: -40rpx抬高,同时增加box-shadow和圆角,形成“肿瘤”效果:

.tabbar { position: fixed; bottom: 0; left: 0; right: 0; height: 110rpx; display: flex; align-items: stretch; z-index: 999; background-color: #ffffff; } .tabbar__item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; } .tabbar__item--center { margin-top: -40rpx; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; } .tabbar__center-icon { width: 88rpx; height: 88rpx; border-radius: 50%; box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.15); }

在App端,这个组件是运行在webview里的普通view,所以CSS能力完整,可以做圆形边框、渐变、动画。但在安卓低端机上,fixed布局和动画会造成列表滚动时轻微掉帧,需要留意。如果只是静态凸起,不掉帧;一旦加上呼吸动画,建议先在低端机实测一遍。

2.3 页面切换和状态保持的坑

自定义tabBar方案最烦的是“状态保持”问题。每个tab页面底部都要引入这个组件,并且要告诉组件当前选中了第几个。在每个页面写入:

<custom-tabbar :current="0"></custom-tabbar>

如果写了多个页面,切换时状态同步异常,是因为原生tabBar切换页面时,组件重新创建,current从父级props恢复,可能不是最新。建议用一个全局store保存当前索引,组件watch store里的值来更新高亮,同时点击时更新store。例如在uni-app中使用vuex或简单模块:

const store = { currentTab: 0 }; export default store;

然后在custom-tabbar的created里监听uni.$on('tabChange', ...),或者在每个页面onShow时更新。反正不要只依赖props传初始值,否则从发布页返回首页时,首页的高亮一定是错的。

另外一个实际坑:由于原生tabBar不渲染,页面底部到屏幕底部之间没有占位,列表内容会被自定义tabBar遮挡。因此每个tab页面根节点要加padding-bottom: 130rpx,对凸起按钮的高度尤其要留够,否则最底部的数据会被遮住一半。很多新手做完凸起后说列表最后一条看不全,就是这里漏了。

3. 路线二:plus.nativeObj.view原生控件方案,像原生tabBar的亲儿子

3.1 核心原理:在原生tabBar上叠一层原生view

路线二不需要关闭原生tabBar。原生tabBar会正常显示,它的底部有5个方形item。我们在原生层创建一个足够大、透明的view,摆放在中间item偏上的位置,绘制一个凸起的发布图标。因为plus.nativeObj.view是原生层,层级高于webview和原生tabBar,所以凸起部分可以自然超出tabBar边界,不被裁剪。

这种方式性能极好,因为它是原生绘制的,不参与vue的diff和样式计算。即使页面里有复杂图表或长列表,滚动时凸起按钮也不会掉帧,这是自定义组件方案做不到的。但它只能运行在App端,而且plus API不支持小程序和H5,所以前提是项目只针对App或愿意为App单独写实现。

3.2 关键代码实现:创建view和更新位置

我们通常在App.vue的onLaunch里创建这个view,做一个单例。核心代码如下:

// App.vue onLaunch() { // #ifdef APP-PLUS this.createConvexBtn(); // #endif }, methods: { createConvexBtn() { const { safeAreaInsets, screenWidth } = uni.getSystemInfoSync(); const bottom = safeAreaInsets.bottom || 0; // iPhone底部安全高度 const btnSize = 88; // 凸起按钮直径,单位px // 原生tabBar高度粗略按50px算,实际可以动态获取 const tabbarHeight = 50; const left = screenWidth / 2 - btnSize / 2; const top = `windowHeight - ${tabbarHeight + bottom}px - ${btnSize / 2}px`; this.convexView = new plus.nativeObj.View('convexView', { top: top, left: left, width: btnSize + 'px', height: (btnSize + 30) + 'px', backgroundColor: 'rgba(255,255,255,0)' }, [ { tag: 'img', id: 'convexIcon', src: '/static/publish.png', position: { top: '0px', left: '0px', width: btnSize + 'px', height: btnSize + 'px' } } ]); this.convexView.addEventListener('click', () => { uni.switchTab({ url: '/pages/publish/publish' }); }, false); } }

要注意的是,top这里使用了windowHeight - ...px,这是plus.nativeObj.View支持的相对计算写法。更稳妥的方式是在页面onReady里用uni.getSystemInfoSync().windowHeight计算绝对像素值,否则某些安卓ROM上百分比计算不准确。

创建后马上能看到凸起view悬浮在底部中央。它和原生tabBar是两个独立原生控件,互不干扰,视觉上就像从tabBar里长出来的按钮。不过这个view默认不会显示在所有页面上,它是全局的,所以发布页也会显示,这个后面单独处理。

3.3 生命周期处理与多页面同步

原生view一旦创建就是全局的,不需要每个tab页面重复创建,这是它比自定义组件省心的地方。但有两个生命周期问题必须处理:

第一,App从后台回到前台时,部分安卓机型可能会销毁或重绘view,导致凸起按钮消失。需要在App.vue的onShow里检测并重建,最简单的方式是销毁后重新走createConvexBtn逻辑。

第二,页面切换到发布页时,如果发布页也有tabBar,需要隐藏原生view,否则发布页底部还会悬浮一个凸起按钮。可以在发布页onShow里调用this.convexView.hide(),其他tab页onShow时调用this.convexView.show()。这里的this拿不到App实例,建议把convexView放在一个全局模块里导出,方便页面引用。

此外,如果要让原生view上的图标随选中状态变化,需要调用this.convexView.draw()重新绘制图片,比修改组件style更繁琐。所以原生view方案适合“发布按钮永远高亮”的场景,如果每个tab都有不同选中样式,自定义组件会更合算。

4. 两种方案的实际对比与选型建议

4.1 从性能、兼容性、开发效率三个维度打分

我根据自己的实际使用体验,把两个方案放在一个表里:

维度自定义组件方案plus.nativeObj.view方案
开发效率高,模板CSS随意写低,原生API不友好,布局用坐标计算
运行性能中,长列表滚动可能出现掉帧高,原生绘制极稳
多端兼容高,App/小程序/H5都能用低,仅App端
动画能力强,CSS动画随意加弱,只能靠连续换图模拟
包体积影响无无
调试难度低,前端工具即可高,需要真机看效果
页面状态同步需要自己管理原生tabBar自己管理,凸起仅做跳转

这个表不能算官方标准,但能帮助做技术选型。如果你的需求是“中间凸起、点击跳转、选中态另说”,自定义组件完全够用;如果你的项目对性能极其敏感,页面里有webview、视频或复杂canvas,同时只在App发布,原生view更合适。

4.2 哪些场景推荐用哪种,我踩过的坑

先说我踩过的坑。最初为了保证多端一致,我选了自定义组件方案,结果在一个安卓低端机上,用户一边列表滚动一边频繁点击凸起按钮,偶尔会出现按钮“跳动”,原因是组件里的CSS过渡动画在硬件加速没开启的机型上表现不稳定。后来给按钮加上will-change并简化动画,才算缓解。

后来在另一个纯App项目里改用原生view方案,又遇到一个新问题:原生view虽然固定,但页面里的cover-view或地图原生组件有可能把它盖住。因为plus.nativeObj.view之间也有层级关系,如果你在发布页画了地图,地图原生层会覆盖凸起按钮。解决办法是把凸起view的zIndex调到足够大,或者在需要时隐藏它。

所以我的建议是:如果项目未来要同时上小程序,优先自定义组件,至少tabBar样式可以一处维护;如果项目只做App,且凸起按钮旁边还需要放其他原生悬浮功能,可以考虑原生view,但一定要在页面层的onShow里规划好显隐逻辑。

5. 凸起按钮的细节打磨:动效、阴影与安全区适配

5.1 凸起部分超出tabBar边界的裁剪问题

两种方案都不存在“裁剪”,但要注意阴影和背景。自定义组件方案里,父容器tabbar设置了背景色,如果中间按钮向上凸起的部分超出父容器,而父容器没有overflow:hidden,没问题;但如果你在某个外部容器上设置了overflow:hidden,凸起会被切掉。我习惯把tabbar的背景放在一个子view里,凸起按钮放在外层,避免互相干扰。

原生view方案里,view的背景默认是透明的,凸起部分自然不会裁剪。但如果想要凸起按钮带一个圆形阴影,不能像CSS那样直接box-shadow,需要准备一张带阴影的PNG图片作为img绘制对象的src。这也是原生方案“视觉上很干净,但做特效麻烦”的原因。

5.2 水波纹点击态和微动效怎么加

自定义组件方案加动效非常方便。想要中间按钮一直上下呼吸,可以写一个CSS keyframes:

@keyframes breath { 0% { transform: scale(1) translateY(-10rpx); } 50% { transform: scale(1.05) translateY(-16rpx); } 100% { transform: scale(1) translateY(-10rpx); } } .tabbar__center-icon { animation: breath 2s ease-in-out infinite; }

实测这种单纯的缩放和位移动画在App端不会造成明显卡顿,但不要把阴影也放进动画里,否则会频繁触发重绘。原生view方案做动效就不太舒服。可以在点击时通过连续调用view.draw切换三张不同尺寸的图片模拟按压,但帧率受限于setTimeout的精度。所以如果产品坚持要高级动效,我建议用自定义组件,或者在自定义组件里增加一个原生view混合使用,复杂场景才值得这么折腾。

5.3 安全区与全面屏适配

无论哪种方案,都必须考虑iPhone底部安全区。自定义组件中,tabbar容器需要增加padding-bottom: constant(safe-area-inset-bottom)和env(safe-area-inset-bottom),否则凸起按钮会卡在Home Indicator附近,看起来特别别扭。建议写成:

.tabbar { padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom); }

原生view方案中,创建view时计算top就要把安全区高度算进去。刚才代码里已经用了safeAreaInsets.bottom,但要注意这个值在安卓全面屏手势模式下可能不为0且比较小,需要实际适配。同时原生tabBar在全面屏上的高度本身也包含了安全区,不同机型数值有差异,建议在App启动时读取uni.getSystemInfoSync()里的tabBarHeight或自行获取,不要写死。

最后说一点个人体会:这两个方案没有绝对的好坏,自定义组件适合大部分业务场景,原生view适合把性能榨干的App专用项目。如果时间允许,我会先花半天把两个demo都跑一遍,心里有数后再定最终实现。尤其是凸起按钮的点击命中区域,记得比视觉面积大一圈,否则用户会投诉“明明点到了却没反应”。这个细节我在两个方案里都吃过亏,希望大家不用再踩。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询