1. 这个需求到底在解决什么问题
做过移动端应用的人大概率都碰到过这种界面:底部导航栏中间杵着一个特别显眼的圆形按钮,比旁边几个图标大一圈,点它不跳页面,而是弹出一层半透明的遮罩,上面铺几个功能入口。这种设计在社区、电商、工具类应用里非常常见,用户一眼就知道"这是主操作"。
但真到动手实现的时候,问题就来了。uni-app 的tabBar是原生渲染的,你没法直接往中间塞一个自定义按钮,更没法监听它的点击事件。官方提供的midButton配置项虽然能做出中间凸起的效果,但它本质上还是一个 tab 切换按钮,点了就跳页面,想让它弹遮罩而不是切页,就得绕点路。
这个项目的核心目标就一句话:在 uni-app 的 App 端,让底部 tabBar 中间那个按钮被点击时,不切换页面,而是显示一层自定义遮罩;再点一次或者点遮罩区域,遮罩隐藏。听起来简单,但里面牵扯到原生 tabBar 的层级问题、事件穿透问题、页面生命周期问题,还有不同机型上的适配坑。
适合谁看?如果你正在用 uni-app 做 App 端项目,需要自定义底部导航的交互,尤其是中间按钮要触发非跳转行为,那这篇内容基本能帮你把路走通。我会把方案选型的逻辑、具体代码、参数计算、踩过的坑都摊开讲,你照着改就能用。
2. 方案选型:为什么不能直接用 midButton
2.1 原生 tabBar 的能力边界
uni-app 的pages.json里配置tabBar时,有个midButton字段,可以设置中间按钮的背景图、宽高、文字等。配置大概长这样:
{ "tabBar": { "midButton": { "width": "80px", "height": "60px", "backgroundImage": "/static/mid-btn.png", "text": "发布", "fontSize": "10px", "iconWidth": "24px" }, "list": [ { "pagePath": "pages/index/index", "text": "首页" }, { "pagePath": "pages/discover/discover", "text": "发现" }, { "pagePath": "pages/mine/mine", "text": "我的" } ] } }这个配置确实能让中间按钮凸出来,视觉上没问题。但它的行为是固定的:点击后切换到list里对应的某个页面。你没法给它绑定一个自定义的点击回调,也没法阻止它跳转。换句话说,midButton只解决了"看起来像",没解决"点起来不一样"。
2.2 两条可行的技术路线
既然原生能力不够,就得想办法。我实测下来,App 端主要有两条路:
路线一:隐藏原生 tabBar,完全自定义一套底部导航。用uni.hideTabBar()把原生 tabBar 藏掉,然后在每个页面底部放一个自定义组件,自己画图标、自己绑事件。这条路最灵活,中间按钮想干嘛就干嘛,遮罩、弹窗、跳转都随意。缺点是每个 tab 页面都得引入这个组件,页面切换时组件会重新渲染,状态管理要自己处理,而且原生 tabBar 的切换动画和性能优势就没了。
路线二:保留原生 tabBar,用透明层覆盖中间按钮区域。原生 tabBar 照常用,但在页面最上层盖一个透明的view,位置和大小精确对准中间按钮。这个透明层负责接收点击事件,触发遮罩显示。遮罩本身也是绝对定位的view,层级比透明层高。这条路的好处是原生 tabBar 的切换逻辑完全不动,只需要处理中间按钮这一个点。
我最后选的是路线二。原因很直接:项目里其他几个 tab 的切换逻辑已经很稳定了,不想为了一个中间按钮把整套导航推翻重做。而且路线二的改动范围小,出问题容易回滚。
2.3 路线二的核心难点
路线二听起来简单,但有几个坑必须提前想清楚:
- 透明层的位置怎么算?中间按钮在 tabBar 里的水平位置是居中的,垂直方向要盖住凸起部分。不同机型 tabBar 高度不一样,安全区也不一样,得动态计算。
- 事件穿透怎么处理?透明层盖在原生 tabBar 上,如果它不接收事件,点击就穿透到下面的 tab 切换了;如果它接收事件,那它覆盖区域内的其他 tab 按钮就点不到了。所以透明层的宽度必须精确等于中间按钮的宽度,不能多也不能少。
- 遮罩的层级够不够高?遮罩要盖住 tabBar 和页面内容,
z-index得给足。但在 App 端,原生 tabBar 的层级是高于普通view的,所以遮罩如果只盖页面内容,tabBar 还是会露出来。解决办法是把遮罩也做成覆盖全屏,包括 tabBar 区域。 - 页面切换时遮罩状态怎么同步?用户在遮罩显示的状态下切了 tab,遮罩应该自动隐藏,否则会残留在新页面上。
这些问题下面会逐个拆解。
3. 核心实现:从布局计算到事件绑定
3.1 获取 tabBar 和中间按钮的尺寸参数
要精确覆盖中间按钮,首先得知道它的位置和大小。uni-app 提供了uni.getSystemInfoSync()可以拿到屏幕宽高、状态栏高度、安全区等信息。tabBar 的高度在 App 端一般是固定的,但不同平台略有差异。
我实测下来,iOS 和 Android 的 tabBar 高度(不含安全区)大约是 50px,加上底部安全区就是50 + safeAreaBottom。中间按钮如果是凸起设计,通常会超出 tabBar 顶部一部分,超出量取决于你midButton配置里的height和width。
假设中间按钮配置为宽 80px、高 60px,tabBar 高度 50px,那么按钮超出 tabBar 顶部的部分是60 - 50 = 10px。透明层的高度就应该覆盖这 60px,宽度 80px。
水平位置的计算:屏幕宽度减去按钮宽度除以 2,就是左边距。
const systemInfo = uni.getSystemInfoSync(); const screenWidth = systemInfo.screenWidth; const safeAreaBottom = systemInfo.safeAreaInsets ? systemInfo.safeAreaInsets.bottom : 0; const tabBarHeight = 50 + safeAreaBottom; const midBtnWidth = 80; const midBtnHeight = 60; const midBtnLeft = (screenWidth - midBtnWidth) / 2; const midBtnBottom = safeAreaBottom;这里有个细节:safeAreaInsets在部分安卓机型上可能拿不到,得做兼容处理。我一般会判断一下,拿不到就默认 0,然后通过uni.getSystemInfoSync().safeArea再兜一层。
3.2 透明点击层的实现
透明层用position: fixed定位,z-index给一个比较高的值,比如 999。背景完全透明,但必须设置background-color: rgba(0,0,0,0)而不是transparent,因为某些安卓机型上transparent会导致元素不接收点击事件。
<view class="mid-btn-mask" :style="{ width: midBtnWidth + 'px', height: midBtnHeight + 'px', left: midBtnLeft + 'px', bottom: midBtnBottom + 'px' }" @click="toggleOverlay" ></view>.mid-btn-mask { position: fixed; z-index: 999; background-color: rgba(0, 0, 0, 0); }这个view只负责接收点击,不显示任何东西。它的位置和大小精确对应中间按钮,所以不会影响旁边 tab 的点击。
注意:透明层的
z-index不能太高,否则会盖住遮罩本身。遮罩的z-index要设得比它更高,比如 1000。
3.3 遮罩层的结构与交互
遮罩层分两部分:一层半透明背景,一层内容区域。背景负责拦截点击(点背景关闭遮罩),内容区域放功能入口。
<view v-if="overlayVisible" class="overlay" @click="hideOverlay"> <view class="overlay-content" @click.stop> <view class="overlay-item" v-for="item in menuItems" :key="item.id" @click="handleMenuClick(item)"> <image :src="item.icon" class="overlay-icon" /> <text class="overlay-text">{{ item.text }}</text> </view> </view> </view>.overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); z-index: 1000; display: flex; align-items: flex-end; justify-content: center; } .overlay-content { width: 100%; background-color: #fff; border-radius: 16px 16px 0 0; padding: 20px; padding-bottom: calc(20px + env(safe-area-inset-bottom)); display: flex; flex-wrap: wrap; justify-content: space-around; }这里用@click.stop阻止内容区域的点击冒泡到背景,否则点内容也会关闭遮罩。env(safe-area-inset-bottom)是 CSS 里处理安全区的标准写法,比 JS 计算更省事。
3.4 遮罩显示与隐藏的状态管理
遮罩的显示状态用一个变量控制,放在页面的data里。但这里有个问题:如果每个 tab 页面都放一份透明层和遮罩,状态就分散了。用户从首页打开遮罩,切到发现页,发现页的遮罩状态是独立的,不会自动隐藏。
我的做法是把遮罩状态提升到一个全局的 store 里,比如用Vuex或者简单的全局变量。每个 tab 页面都监听这个状态,切换页面时统一重置。
// store.js export default { state: { overlayVisible: false }, setOverlayVisible(val) { this.state.overlayVisible = val; } };页面里:
import store from '@/store.js'; export default { computed: { overlayVisible() { return store.state.overlayVisible; } }, methods: { toggleOverlay() { store.setOverlayVisible(!store.state.overlayVisible); }, hideOverlay() { store.setOverlayVisible(false); } }, onHide() { store.setOverlayVisible(false); } };onHide是 uni-app 页面生命周期,页面隐藏时触发。在这里重置遮罩状态,就能保证切 tab 时遮罩自动关闭。
4. 实操过程中的关键细节与参数计算
4.1 中间按钮凸起高度的精确计算
前面提到中间按钮会超出 tabBar 顶部,这个超出量直接决定了透明层的高度。但实际项目中,midButton的height配置和真实渲染高度可能不一致。比如你配了 60px,但图片本身有留白,实际视觉高度可能只有 50px。
我的经验是:不要完全依赖配置值,用真机调试看一眼实际效果,然后微调透明层的高度。一般透明层高度比按钮视觉高度多 4-6px,留一点容错空间,避免点击边缘时没反应。
如果中间按钮是圆形凸起,透明层用矩形覆盖就行,因为点击区域是矩形判断的,圆形边缘的空白区域点了也没关系,反而更容易点中。
4.2 不同机型的适配策略
安卓机型碎片化严重,tabBar 高度和安全区差异很大。我整理了一个适配表:
| 机型类型 | tabBar 高度 | 安全区底部 | 中间按钮底部偏移 |
|---|---|---|---|
| iPhone 带 Home 键 | 50px | 0 | 0 |
| iPhone 全面屏 | 50px | 34px | 34px |
| 安卓普通机型 | 50px | 0 | 0 |
| 安卓全面屏 | 50px | 16-24px | 动态获取 |
| 折叠屏展开态 | 50px | 动态 | 动态获取 |
关键点是安全区底部偏移必须动态获取,不能写死。uni.getSystemInfoSync().safeAreaInsets.bottom在大部分机型上可用,拿不到就用screenHeight - safeArea.bottom反推。
4.3 遮罩内容的布局技巧
遮罩内容区域通常是一排图标加文字,数量一般是 3 到 5 个。用flex布局,justify-content: space-around就能均匀分布。但如果数量是 4 个,space-around会让两边留白较多,视觉上不够饱满。这时候可以改用space-between加上左右 padding。
图标大小建议 48px 到 56px,文字 12px 到 14px。整个内容区域的高度控制在 120px 到 160px 之间,太高会显得笨重,太矮点起来费劲。
实操心得:遮罩内容区域的顶部圆角用 16px 比较自然,底部要加上安全区 padding,否则在全面屏上内容会被 Home 指示条挡住。
4.4 事件穿透的边界处理
透明层只覆盖中间按钮区域,但中间按钮如果是凸起的,它的顶部会超出 tabBar。透明层的顶部如果也超出 tabBar,那超出部分会盖在页面内容上。如果页面内容在那个位置有可点击元素,就会被透明层挡住。
解决办法是:透明层的顶部不要超出 tabBar 顶部太多,刚好覆盖按钮凸起部分即可。如果按钮凸起很高,比如超出 20px,那透明层顶部就超出 20px,这个范围内的页面内容点击会被拦截。这是不可避免的,只能通过设计上避免在 tabBar 正上方放可点击元素。
5. 常见问题与排查技巧实录
5.1 点击透明层没反应
这是最常见的问题,原因通常有三个:
- 背景色设成了
transparent。某些安卓机型上,完全透明的元素不接收点击事件。改成rgba(0,0,0,0)或者rgba(0,0,0,0.01)就能解决。 z-index不够高。原生 tabBar 的层级在 App 端比较高,透明层的z-index如果低于 tabBar,就被盖住了。实测z-index: 999基本够用,不够就往上加。- 透明层被其他元素盖住了。检查页面里有没有其他
position: fixed的元素,z-index比透明层高。
5.2 遮罩显示后 tabBar 还能点
这是因为遮罩的z-index虽然比透明层高,但原生 tabBar 的层级可能比遮罩还高。解决办法是把遮罩的bottom设为 0,覆盖整个屏幕包括 tabBar 区域,同时z-index给到 1000 以上。如果还是不行,就得用uni.hideTabBar()在遮罩显示时把原生 tabBar 藏掉,遮罩隐藏时再显示回来。
toggleOverlay() { const visible = !store.state.overlayVisible; store.setOverlayVisible(visible); if (visible) { uni.hideTabBar({ animation: false }); } else { uni.showTabBar({ animation: false }); } }注意:
hideTabBar和showTabBar在部分安卓机型上有闪烁,animation: false可以减少闪烁,但没法完全避免。如果项目对视觉要求高,建议用路线一完全自定义 tabBar。
5.3 切 tab 后遮罩残留
前面提到用onHide重置状态,但有些情况下onHide触发时机比较晚,遮罩会短暂残留。更稳妥的做法是在onShow里也检查一次,如果当前页面不是触发遮罩的页面,就强制隐藏。
onShow() { if (store.state.overlayVisible && store.state.overlaySource !== this.$page.path) { store.setOverlayVisible(false); uni.showTabBar({ animation: false }); } }5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 点中间按钮无反应 | 透明层背景色为 transparent | 改为 rgba(0,0,0,0) |
| 点中间按钮跳到了其他 tab | 透明层位置偏移,没盖住按钮 | 重新计算 left 和 bottom |
| 遮罩显示后 tabBar 仍可点击 | 遮罩层级不够 | 提高 z-index 或隐藏原生 tabBar |
| 遮罩内容被 Home 条挡住 | 没处理安全区 | 加 env(safe-area-inset-bottom) |
| 切 tab 后遮罩残留 | 状态未重置 | onHide/onShow 里重置状态 |
| 安卓上点击有延迟 | 300ms 点击延迟 | 用 @touchstart 替代 @click |
| 遮罩动画卡顿 | 使用了 v-if 频繁切换 | 改用 v-show 或 CSS 过渡 |
5.5 性能优化的几个点
遮罩的显示隐藏如果频繁操作,用v-if会反复创建销毁 DOM,在低端安卓机上可能卡顿。改用v-show或者用 CSStransform做过渡,性能会好很多。
.overlay { transition: opacity 0.2s ease; } .overlay.hidden { opacity: 0; pointer-events: none; }pointer-events: none让隐藏状态的遮罩不接收点击,避免误触。
另外,透明层和遮罩尽量放在页面根节点下,不要嵌套太深,减少重绘范围。
6. 一些延伸思考与替代方案
这套方案在大部分场景下够用,但如果你对交互要求更高,比如遮罩要有弹性动画、中间按钮要有旋转效果,那路线二会越来越吃力。因为透明层和原生 tabBar 是两层东西,动画很难同步。
这时候可以考虑路线一的变体:保留原生 tabBar 用于普通 tab 切换,但中间按钮区域用一个原生插件或者 plus 层的webview来渲染。这条路复杂度高,但自由度最大。不过对于大多数项目来说,没必要走到这一步。
还有一个取巧的办法:把中间按钮对应的 tab 页面做成一个空白页,点击后跳转到这个空白页,在onShow里立刻显示遮罩,然后uni.switchTab切回原来的页面。这样遮罩就显示在原来的页面上了。但这个方案有页面闪烁,体验不好,我不推荐。
我个人在实际操作中的体会是,能用简单方案解决的就别过度设计。透明层覆盖这个方案,代码量不大,维护成本低,适配问题也有成熟的排查路径。真正麻烦的是那些边角机型的适配,但这个只能靠真机测试一点点磨,没有捷径。
最后分享一个小技巧:透明层的调试可以临时给它加一个半透明背景色,比如rgba(255,0,0,0.3),这样在真机上能直观看到它的位置和大小对不对。调好之后再改回全透明。这个办法帮我省了很多猜位置的时间。