1. 为什么放着原生导航栏不用,偏要自己造一个轮子
做小程序的人应该都有一个共同的经历:UI设计稿上的顶部导航栏,跟微信原生导航栏放在一起,怎么看怎么别扭。设计稿里导航栏是品牌色的渐变背景,标题加了字重和字间距,左边是自定义的返回箭头,右边还有一个分享按钮——这些需求用原生导航栏全做不了,或者做出来丑得没法看。
我第一次真正下决心做自定义导航栏,是因为一个商城项目。UI给的设计稿里,首页顶部是半透明的毛玻璃效果,背景图要穿透到导航栏底下,滚动之后慢慢变成实色。原生导航栏只能设置纯色背景,最多配个导航栏渐变,半透明、毛玻璃这种效果想都不用想。另一个刺激点是右上角胶囊按钮,微信那个黑色的胶囊在一堆浅色UI中间特别突兀,如果能把导航栏整体刷成品牌色,胶囊按钮就跟"长"在导航栏里一样,视觉上统一很多。
"自定义顶部导航栏状态栏标题栏"这个需求,在很多uniapp项目里都出现过,尤其是那些对UI还原度要求高的项目。说白了,我们就是把微信或者App系统默认的那条导航栏区域收回来,自己控制状态栏下面的整块空间:状态栏高度留给系统,导航栏标题栏的布局、背景、元素全部由自己的代码决定。
适合做自定义导航栏的场景也很明确:需要品牌色贯穿到顶部、需要背景图延伸到状态栏、需要在导航栏上放超过两个可点击元素(原生最多一个胶囊加一个胶囊左侧按钮,样式还受限)、需要导航栏背景随页面滚动变化,或者干脆就想让标题居中而不是左对齐。如果你只是做一个内部工具类小程序,对UI没有执念,那完全没必要折腾,原生导航栏省事得多。
这个方案的代价也是真实的:你要自己处理状态栏高度、胶囊按钮位置、不同机型的刘海和挖孔,甚至还要考虑安卓虚拟按键对底部的影响。导航栏一旦做错,不是丑的问题,是页面内容会被状态栏遮挡,按钮点不到,甚至直接掉到屏幕外面去。所以这篇文章里我会把整套实现思路、代码、踩过的坑都讲清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 自定义导航栏前必须搞懂的两个关键参数:状态栏高度与胶囊按钮位置
2.1 状态栏高度:不是玄学,是系统参数
状态栏就是手机屏幕最顶部显示时间、电量、信号的那一条细长区域。它在iOS和安卓上的表现完全不同,同一系统的不同机型也不一样。
iPhone这边,老机型是20pt,iPhone X之后的刘海屏是44pt,到了iPhone 14 Pro的灵动岛直接变成54pt甚至更高一点;安卓更麻烦,状态栏高度通常是24dp,但不同厂商定制系统会改,有的还做成沉浸式状态栏,让状态栏背景和导航栏背景融为一体。如果你写死一个高度,比如永远取44px,那在老iPhone上页面顶部会多出来24px的空白,在灵动岛上则会发现标题被状态栏文字压住。
在uniapp里获取状态栏高度很简单,核心API是uni.getSystemInfoSync(),它返回一个对象,里面有statusBarHeight字段,单位是px。我一般会在App.vue的onLaunch里拿一次,存到globalData里,后面所有页面直接用,避免每个页面重复调用。
javascript复制// App.vue
onLaunch() {
const systemInfo = uni.getSystemInfoSync()
this.globalData.statusBarHeight = systemInfo.statusBarHeight || 20
this.globalData.systemInfo = systemInfo
}
注意这个值的单位是px,不是rpx。你在写自定义导航栏组件的样式时,要么把px直接用在行内样式上,要么转成rpx。我个人的习惯是状态栏高度和导航栏高度全部用px行内样式控制,因为这是系统返回的物理像素值,直接相加更靠谱,转成rpx反而可能在换算中丢失精度。
2.2 胶囊按钮的几何信息:导航栏高度的核心依据
微信小程序右上角那个胶囊按钮,就是标题栏高度计算的关键。很多人不知道,小程序提供了一个API可以拿到胶囊按钮的精确位置和尺寸:wx.getMenuButtonBoundingClientRect()。
这个API只在微信小程序端存在,它在大部分手机上返回的数据长这样:
json复制{
"width": 87,
"height": 32,
"top": 26,
"right": 365,
"bottom": 58,
"left": 278
}
top是胶囊按钮顶部到屏幕顶部的距离,height是胶囊自身的高度。原生导航栏的标题栏部分,视觉上就是让胶囊按钮上下居中在标题栏中间,所以标题栏高度的计算公式是:
code复制导航栏标题栏高度 = (胶囊按钮.top - 状态栏高度) * 2 + 胶囊按钮.height
这个公式怎么理解?胶囊按钮顶部到状态栏底部的距离是(胶囊.top - 状态栏高度),胶囊按钮底部到标题栏底部的距离如果也等于这个值,那胶囊就正好垂直居中。整个标题栏高度就是上面那段间距加上胶囊高度再加上下面那段间距,也就是(胶囊.top - 状态栏高度) * 2 + 胶囊.height。
我再举个例子。假设一台iPhone 14 Pro,状态栏高度是54px,胶囊的top是68px,height是34px,那标题栏高度就是(68 - 54) * 2 + 34 = 62px。整条自定义导航栏的总高度就是状态栏加标题栏:54 + 62 = 116px。
2.3 没有胶囊按钮的端怎么兜底
问题来了:小程序端有胶囊,但uniapp还会编译到H5和App端,这两个端没有wx.getMenuButtonBoundingClientRect,也没法调用。H5端连状态栏都没有,App端虽然有状态栏,但导航栏的行为和小程序不一样,App端有原生titleNView可以控制。
我的兜底方案很简单,用条件编译区分:
javascript复制const systemInfo = uni.getSystemInfoSync()
this.statusBarHeight = systemInfo.statusBarHeight || 20
// #ifdef MP-WEIXIN
const capsule = wx.getMenuButtonBoundingClientRect()
if (capsule && capsule.height) {
this.navTitleHeight = (capsule.top - this.statusBarHeight) * 2 + capsule.height
} else {
this.navTitleHeight = 44
}
// #endif
// #ifndef MP-WEIXIN
this.navTitleHeight = 44
// #endif
this.navBarHeight = this.statusBarHeight + this.navTitleHeight
44px的兜底值不是乱写的,原生导航栏标题栏在大部分安卓机型上的高度基本就是44px,H5端也沿用44px作为标准导航栏高度。这样在非微信小程序端,导航栏总高就是状态栏高度加44px,视觉上也能接受。
还有一个重要细节:新的基础库建议用uni.getWindowInfo()替代uni.getSystemInfoSync()。官方文档里标注getSystemInfoSync在部分场景下可能不推荐,返回的字段也会慢慢收敛。我在新项目里已经全部切到uni.getWindowInfo(),它同样有statusBarHeight字段。如果你用的是Vue 3版本的uniapp,建议直接用新API。
3. 在uniapp里落地自定义导航栏的完整实现
3.1 页面配置:让原生导航栏让位
在写组件之前,先要让页面禁用原生导航栏。在pages.json里,给对应页面的style加上"navigationStyle": "custom"就行:
json复制{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationStyle": "custom",
"navigationBarTextStyle": "black"
}
}
]
}
加了custom之后,这个页面就不会渲染微信原生导航栏了,状态栏底下全空出来给我们自己发挥。注意navigationBarTextStyle在custom模式下不生效,状态栏字体的颜色要另想办法,这个后面会专门讲。
如果你的项目是globalStyle里统一配置了navigationBarTitleText之类的内容,需要在单页面覆盖或者直接改成custom。还有一种情况是tabBar页面,tabBar本身不受navigationStyle影响,自定义导航栏只作用于页面顶部区域,tabBar还是在底部,两者不冲突,可以放心用。
3.2 获取系统信息与计算导航栏高度
我建议把导航栏封装成一个公共组件,组件里自己完成所有计算,页面只需要传标题和背景色。组件内部在created生命周期里去拿系统信息,因为created阶段组件实例已经创建,此时拿数据再渲染模板不会出现高度跳变。
这里有一个时序问题:如果组件mounted之后才获取系统信息,初始渲染时导航栏高度可能是0,页面内容位置会先错一下再弹回来,体验很糟糕。所以必须在created里同步获取。uni.getSystemInfoSync()是同步方法,正好适合。
同时建议加一个缓存机制,组件第一次创建时算好的高度存在模块级变量里,后续其他页面的导航栏复用同一个值,不用每次重新调API。因为在小程序里,多次调用wx.getMenuButtonBoundingClientRect()虽然性能影响不大,但没必要,同一个机器上这些参数不会变。
javascript复制let cachedNavBarInfo = null
function getNavBarInfo() {
if (cachedNavBarInfo) return cachedNavBarInfo
const systemInfo = uni.getSystemInfoSync()
const statusBarHeight = systemInfo.statusBarHeight || 20
let navTitleHeight = 44
// #ifdef MP-WEIXIN
const capsule = wx.getMenuButtonBoundingClientRect()
if (capsule && capsule.height) {
navTitleHeight = (capsule.top - statusBarHeight) * 2 + capsule.height
}
// #endif
const navBarHeight = statusBarHeight + navTitleHeight
cachedNavBarInfo = { statusBarHeight, navTitleHeight, navBarHeight }
return cachedNavBarInfo
}
3.3 组件化封装:一个可复用标题栏组件
一个完整的导航栏组件,至少要有这几个能力:设置标题、设置背景色、控制左侧返回按钮是否显示、支持右侧插槽放自定义按钮。我写的组件结构大概是这样:
vue复制<template>
<view class="custom-nav" :style="{ height: navBarHeight + 'px', backgroundColor: bgColor }">
<view class="custom-nav__status" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="custom-nav__bar" :style="{ height: navTitleHeight + 'px' }">
<view class="custom-nav__left" @tap="handleLeft">
<view v-if="showBack" class="custom-nav__back-icon"></view>
<slot name="left"></slot>
</view>
<view class="custom-nav__title" :style="{ color: titleColor }">{{ title }}</view>
<view class="custom-nav__right">
<slot name="right"></slot>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'CustomNav',
props: {
title: { type: String, default: '' },
bgColor: { type: String, default: '#ffffff' },
titleColor: { type: String, default: '#333333' },
showBack: { type: Boolean, default: true }
},
data() {
return {
statusBarHeight: 20,
navTitleHeight: 44,
navBarHeight: 64
}
},
created() {
const info = getNavBarInfo()
this.statusBarHeight = info.statusBarHeight
this.navTitleHeight = info.navTitleHeight
this.navBarHeight = info.navBarHeight
},
methods: {
handleLeft() {
if (this.showBack) {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
uni.switchTab({ url: '/pages/index/index' })
}
}
this.$emit('clickLeft')
}
}
}
</script>
布局上我用了三段式:左侧区域绝对定位或者flex布局在左边,放返回按钮和自定义左插槽;中间标题绝对居中,用绝对定位保证不受左右区域宽度挤压;右侧区域在右边,放自定义分享按钮之类。整个导航栏分两层,第一层是状态栏高度的占位透明块,第二层才是真正放标题内容的标题栏。
在页面里使用的时候,组件放最外层,页面内容放到组件下面后需要手动加上一个等于导航栏总高的padding-top:
vue复制<template>
<view>
<custom-nav title="首页" bg-color="#ffffff"></custom-nav>
<view class="page-content" :style="{ paddingTop: navBarHeight + 'px' }">
<!-- 页面内容 -->
</view>
</view>
</template>
如果页面内容需要背景图延伸到状态栏,那就把背景图放到一个绝对定位的view里,导航栏背景色设为透明,让背景图在导航栏后面透出来。
4. 机型适配路上那些躲不开的坑
4.1 刘海屏、灵动岛与安卓挖孔屏
自定义导航栏最常见的翻车现场就是屏幕上多出来一条黑边或者内容穿到状态栏里。根本原因是状态栏高度没有正确获取,或者获取的时机太晚,页面已经渲染完了。
我按照状态栏高度把常见机型分了一下:
| 机型/系统 | 状态栏高度(px) | 说明 |
|---|---|---|
| 安卓通用 | 24dp转换后约24~48px | 不同dpr下转换值不同 |
| iPhone 8及以下 | 20 | 非全面屏 |
| iPhone X/XR/XS | 44 | 第一代刘海屏 |
| iPhone 12/13 Pro Max | 47 | 更大刘海 |
| iPhone 14 Pro/灵动岛 | 54~59 | 灵动岛区域更宽 |
| 折叠屏/平板 | 不固定 | 分屏时状态栏高度会变化 |
安卓挖孔屏也是重灾区,尤其挖孔在中间的机型,状态栏高度会比普通机型高出一截。反正记住一个原则:永远不要自己猜测状态栏高度,一定要用API实时获取。
还有一个进阶情况是分屏模式。安卓分屏时,应用窗口高度变小,状态栏可能仍然存在但尺寸有变化,小程序事件里没有专门的rpx变化通知,但可以通过uni.onWindowResize监听窗口尺寸变化,在回调里重新计算导航栏高度。这个我实际项目中遇到过,用户在分屏模式打开小程序,导航栏变形了,后来加了resize监听才解决。
4.2 底部安全区域与虚拟按键
自定义导航栏把顶部占了之后,其实还会连带影响底部。为什么?因为很多页面为了视觉统一会连带处理底部安全区,尤其iPhone X系列底部有home indicator,页面内容如果不避开,按钮会被那条横线挡住。
在uniapp里处理底部安全区有一个简单方案,用CSS的env(safe-area-inset-bottom)和constant(safe-area-inset-bottom):
css复制.page-footer {
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
constant()是iOS 11.0到11.2的写法,env()是iOS 11.2以后的写法,两个都要写上。安卓没有safe-area-inset-bottom这个概念,默认就是0,不影响。
安卓虚拟按键是另一回事。很多安卓手机底部有一条虚拟导航栏,它会占据一部分屏幕高度。在uniapp里,这个需要注意页面根节点使用page标签默认是全屏的,如果页面内容被虚拟按键遮挡,可以考虑给根节点加overflow: hidden或者用uni.getSystemInfoSync()里的safeAreaInsets字段做适配。
4.3 状态栏字体颜色在自定义后失效的问题
自定义导航栏后,pages.json里的navigationBarTextStyle: "black"就不再生效了。这意味着状态栏的时间、电量、信号这些图标,可能是白色的,但你的导航栏背景也是浅色,一眼看过去就是灰蒙蒙一片看不清。
在微信小程序端可以用wx.setNavigationBarColor来设置状态栏前景色,但这个API在custom模式下表现不完全一致。更通用的做法是:
javascript复制// #ifdef MP-WEIXIN
wx.setNavigationBarColor({
frontColor: '#ffffff', // 必须是 #ffffff 或 #000000
backgroundColor: '#ffffff',
animation: { duration: 0 }
})
// #endif
注意frontColor只支持黑白两色。背景色在custom模式下其实由你自己的组件背景控制,API里的backgroundColor参数实际不生效,但传上去能保证某些机型不会出bug。
App端的H5和App端又不一样。App端可以用plus.navigator.setStatusBarStyle('light')或者'dark'来控制状态栏文字颜色,H5端则直接是一个全屏页面,状态栏根本不存在,所以不需要处理。
我在组件里做了一个statusBarStyle属性,页面通过prop传入是黑色文字还是白色文字,组件内部根据这个值去调用不同端的API。
4.4 动态隐藏和显示导航栏时的闪烁问题
有些场景需要导航栏动态隐藏,比如图片预览页面,或者H5里嵌入一个全屏视频播放器。直接给导航栏外层view加display: none或者v-if控制会导致页面内容顶上去,弹下来的时候会有闪烁。
我的做法是导航栏始终渲染,用transform: translateY(-100%)把它移出屏幕,同时给页面内容动态设置paddingTop,这样过度更平滑。如果你遇到的是滚动渐变的导航栏,比如首页列表往下滚时导航栏从透明变成白色,那思路就不一样了——导航栏本身高度保持不变,只是背景色透明度变化,这个用onPageScroll监听滚动距离即可。
5. 滚动吸顶与渐变导航栏的实战扩展
自定义导航栏的最终目标不是"把标题放中间"就完事了,而是让屏幕顶部变成一个可以被业务自由控制的舞台。我用得最多的两个扩展是吸顶导航栏和滚动渐变导航栏,实现思路都不复杂,但能极大提升页面的精致度。
滚动渐变的核心是监听页面滚动距离,然后让导航栏的背景色alpha值从0变到1,标题和返回按钮的透明度跟着变。我用的是uniapp的onPageScroll:
javascript复制onPageScroll(e) {
const scrollTop = e.scrollTop
const threshold = 80 // 滚动超过80px后导航栏完全不透明
const opacity = scrollTop > threshold ? 1 : scrollTop / threshold
this.navBgColor = `rgba(255, 255, 255, ${opacity})`
}
吸顶效果则简单一点,自定义导航栏组件本身就是固定定位在页面顶部的,不需要额外处理。但要小心一个事情:如果页面是普通文档流,滚动时导航栏固定住了,内容从导航栏底下穿过去就乱了。所以吸顶导航栏的页面,根节点必须加padding-top占位,这样滚动时内容才不会跑到导航栏底下。
还有一个容易忽略的细节:在微信小程序里,导航栏右侧胶囊按钮周围有一个"安全操作区",官方建议不要在这个区域放任何可点击元素,否则可能被压制或者点击穿透。但胶囊左侧还是可以放内容的,很多电商小程序的分享按钮就放在胶囊左边。
多页面复用时,导航栏的关键参数可以放globalData,也可以直接用vuex管理。每个页面都可以通过props覆盖默认值,比如有的页面导航栏背景是主题色,有的是白色,有的干脆透明。组件内部做好computed样式绑定就好。
6. 我在实际项目里积累的几条硬经验
讲完实现和避坑,再分享几条我自己在实际项目里沉淀的经验,都是文档里不容易看到的。
第一,微信小程序右上角胶囊按钮上方有一个交互禁区,官方明确说不允许在这个区域放任何可点击元素。我试过在胶囊上方塞一个自定义按钮,结果在部分安卓机型上点击无效,iOS上则是被微信的胶囊菜单遮挡。实际情况是,微信会把胶囊所在的矩形区域整个拦住,你放的元素会被微信的点击拦截优先吃掉。所以设计导航栏右侧空间时,务必给微信胶囊留出一条至少80px宽的安全通道,只在胶囊左侧靠内的位置放自定义按钮。
第二,如果你做的是一个需要上架App Store的应用,审核时有一个隐藏的雷区。App端状态栏和导航栏区域如果处理不当,比如状态栏文字颜色和背景颜色相同导致看不见,审核人员可能以"界面显示异常"为由拒绝。我在一个版本里就是因为状态栏文字在深色导航栏上也用了深色字体,导致审核被拒一次。后来在App端统一用plus.navigator.setStatusBarStyle('light')强制白字,问题才解决。
第三,不要在导航栏上用太大尺寸的自定义字体。iOS系统默认的导航栏标题字号是17pt,如果你为了突出标题用了24pt或者更大,在iPhone SE这类小屏设备上长标题会被截断得很厉害,而且微信的胶囊还是会占据右侧空间。最好做一下标题长度限制,超过一定字符就自动缩小字号或者省略号处理。
第四,关于小程序端性能。自定义导航栏组件本身不会带来明显性能问题,但如果你在每个页面的导航栏里都放了很多复杂的插槽内容,比如一个自定义搜索框、主题切换按钮、消息铃铛,这些组件都会参与渲染。我做过一个项目,首页导航栏插槽里放了三个图标加一个搜索框,在低端安卓机上冷启动进入页面有明显卡顿。解决方案是插槽内容尽量精简,搜索框用原生input而不是自定义样式过多的view组件,减少不必要的视图层级。
第五,缓存导航栏参数的时候要小心小程序的热更新机制。小程序基础库升级后,getMenuButtonBoundingClientRect的返回值在某些机型上可能有细微变化,如果你把参数做成模块级缓存写在文件顶部,那就永远不会重新计算了。稳妥点的做法是缓存放到storage里并且存一个版本号,每次发布新版本时清理一次缓存。或者干脆不缓存,小程序端多次调用这个API的成本其实非常低,我的一个日活十万级的小程序直接每次页面创建时都重新获取,性能上完全没压力。
7. 后续还能往哪些方向扩展
如果你已经能驾驭静态的自定义导航栏,下一步可以试试针对不同页面动态切换导航栏风格,比如首页是沉浸式透明背景,详情页是白色实底,这种切换通常通过给组件传不同的prop实现。
还有一种做法是把导航栏高度和状态栏信息放到mixins或者composables里,避免每个页面重复计算。Vue 3的uniapp项目可以直接用setup函数加computed,把高度计算抽成一个hook:
javascript复制export function useNavBar() {
const info = getNavBarInfo()
const navBarHeight = computed(() => info.navBarHeight)
const statusBarHeight = computed(() => info.statusBarHeight)
const navTitleHeight = computed(() => info.navTitleHeight)
return { navBarHeight, statusBarHeight, navTitleHeight }
}
这样页面里只需要const { navBarHeight } = useNavBar(),就能拿到准确高度去做内容占位。
像"小程序顶部导航栏高度""自定义顶部导航栏"这类需求,其实在uniapp生态里很常见,很多人都是遇到一个页面需要自定义,临时复制一段代码,没有做统一封装。我的建议是既然做了就做成组件,放到项目的公共组件目录里,后续新页面直接引用,效率高不少。
一个导航栏看起来不起眼,但它承载了整页最顶部的所有界面元素,是用户第一个看见的东西。把这块做稳定、做细腻,对整体体验的提升非常明显。
