只要产品图里出现了渐变背景、透明导航栏、或者标题旁边要挂个搜索框,系统导航栏基本就撑不住了。uni-app 里把 navigationStyle 改成 custom 是一行配置的事,真正的麻烦在后面:状态栏多高?小程序右上角胶囊的位置怎么对齐?H5 端和 App 端又该怎么填?
这篇文章从尺寸数据出发,一步步把自定义导航栏组件写出来,最后附上一份我踩过的坑清单。代码用的是 Vue 3 的 <script setup> 写法,Vue 2 的读者把 ref/computed 换成 data/computed 即可。
一、先想清楚:为什么非换不可
系统导航栏的问题不在于丑,而在于它「不可控」。三个典型场景:
- 首页要做沉浸式的渐变头部,导航栏背景要跟着滚动从透明变成实色,系统导航栏做不到;
- 商品详情页需要在标题右侧挂一个分享按钮,同时还要保证标题视觉居中;
- 海报分享页要求顶部完全无遮挡,连状态栏的文字都要是浅色的。
这些都得自己接管。而一旦接管,你就得自己回答一个问题:导航栏到底应该多高?
二、三个尺寸,先捋清楚
整个适配的核心就是三组数字:
- statusBarHeight:状态栏高度。iOS 刘海屏一般是 44px(或者 47、54,视机型),普通 Android 一般是 24px 左右,H5 端为 0。
- navHeight:状态栏下方那条「标题栏」的高度。iOS 上是 44px,Android 上按 Material 规范是 48px,但小程序里真正的参照物是右上角的胶囊。
- totalHeight:两者的和,也就是从屏幕顶端到导航栏底部的总高度。
其中前两个都能从 API 拿到,第三个是算出来的:
totalHeight = statusBarHeight + navHeight
麻烦的地方在 navHeight。iOS 上写死 44 没问题,但小程序端如果也写死 44,微信右上角的胶囊会跟你的标题栏对不齐——胶囊有时候偏高,有时候偏低,机型一换就露馅。所以小程序端的 navHeight 必须动态算。
三、把胶囊的位置拿到手
微信小程序提供了一个专门的 API:
const rect = uni.getMenuButtonBoundingClientRect()
返回的东西长这样:
{
width: 87,
height: 32,
top: 52,
right: 365,
bottom: 84,
left: 278
}
这些值全是 px,不是 rpx。这一点非常关键,后面会专门说。
胶囊的 top 是它上沿到屏幕顶部的距离。而状态栏的下沿就是 statusBarHeight。所以胶囊上沿到状态栏下沿的间距是:
gap = rect.top - statusBarHeight
微信设计上,胶囊在标题栏里是垂直居中的,也就是说胶囊下方的留白和上方一样多。于是标题栏的总高度就是:
navHeight = gap * 2 + rect.height
把 gap 代进去,就是最终的计算式:
navHeight = (rect.top - statusBarHeight) * 2 + rect.height
举个例子:statusBarHeight 是 44,胶囊 top 是 52、height 是 32,那么 navHeight = (52 - 44) * 2 + 32 = 48,总高度 92px。这个数字挺常见,iPhone 14 Pro 上算出来就是这样。
四、把尺寸计算封装起来
别在每个页面里重复算这些。抽一个工具函数,顺便做个缓存——getSystemInfoSync 在小程序里是有成本的,每次调用都会跨一次逻辑层和渲染层的桥。
// utils/nav.js
let cached = null
/**
* 获取导航栏相关的尺寸数据(单位统一为 px)
*/
export function getNavMetrics() {
if (cached) return cached
const sys = uni.getSystemInfoSync()
// 部分安卓机型在小程序里返回 0,给个兜底
const statusBarHeight = sys.statusBarHeight || 20
let navHeight = 44 // iOS / App / H5 的默认值
let rightGap = 10 // 标题栏右侧留给胶囊的安全距离
// #ifdef MP-WEIXIN
// 只有微信端才有「胶囊」这个概念
const rect = uni.getMenuButtonBoundingClientRect()
if (rect && rect.height > 0) {
navHeight = (rect.top - statusBarHeight) * 2 + rect.height
}
// #endif
cached = {
statusBarHeight,
navHeight,
totalHeight: statusBarHeight + navHeight,
screenWidth: sys.screenWidth,
safeAreaBottom: sys.safeAreaInsets
? sys.safeAreaInsets.bottom
: 0
}
return cached
}
/**
* 页面滚动结束后,如果发生过横竖屏切换,需要清缓存重算
*/
export function clearNavCache() {
cached = null
}
注意 #ifdef 这几行。在 H5 和 App 端,getMenuButtonBoundingClientRect 要么不存在,要么返回一个无意义的值,用条件编译直接绕开会干净很多。写在编译后的代码里,这些分支根本不会被打包进去。
五、把组件写出来
思路是:组件渲染两段。上面一段是占位块,撑出和导航栏一样的高度,把页面内容往下顶;下面一段是真正固定在顶部的导航栏本体。这样页面内容就不会被盖住,也不用在每个页面手写 padding-top。
先看模板部分:
<!-- components/NavBar/NavBar.vue -->
<template>
<view>
<!-- 占位块:撑出导航栏的高度,防止内容被遮挡 -->
<view :style="{ height: totalHeight + 'px' }"></view>
<!-- 固定定位的导航栏本体 -->
<view
class="nav"
:style="{
height: totalHeight + 'px',
paddingTop: statusBarHeight + 'px',
background: bgColor,
boxShadow: shadow
}"
>
<view class="nav__body" :style="{ height: navHeight + 'px' }">
<!-- 左侧:返回按钮 -->
<view class="nav__side" :style="{ width: sideWidth + 'px' }">
<view v-if="showBack" class="nav__back" @click="handleBack">
<text class="nav__arrow">‹</text>
</view>
</view>
<!-- 中间:标题 -->
<view class="nav__title" :style="{ opacity: titleOpacity }">
<slot>{{ title }}</slot>
</view>
<!-- 右侧:预留出和左侧等宽的空间,保证标题真居中 -->
<view class="nav__side" :style="{ width: sideWidth + 'px' }">
<slot name="right"></slot>
</view>
</view>
</view>
</view>
</template>
脚本部分:
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getNavMetrics } from '@/utils/nav.js'
const props = defineProps({
title: { type: String, default: '' },
// 页面当前的滚动距离,由页面通过 onPageScroll 传进来
scrollTop: { type: Number, default: 0 },
// 滚动多少 px 后标题完全显示
threshold: { type: Number, default: 80 },
// 导航栏完全显现时的背景色
bgColor: { type: String, default: '#ffffff' },
showBack: { type: Boolean, default: true }
})
const statusBarHeight = ref(20)
const navHeight = ref(44)
const sideWidth = ref(80)
const titleOpacity = ref(0)
const totalHeight = computed(() => statusBarHeight.value + navHeight.value)
const shadow = computed(() => {
const o = props.scrollTop > 10
? '0 2px 8px rgba(0,0,0,0.06)'
: 'none'
return o
})
onMounted(() => {
const m = getNavMetrics()
statusBarHeight.value = m.statusBarHeight
navHeight.value = m.navHeight
// 左侧宽度 = 屏幕宽度 - 胶囊左边缘,正好和右侧胶囊区域对称
// #ifdef MP-WEIXIN
const rect = uni.getMenuButtonBoundingClientRect()
if (rect && rect.left) {
sideWidth.value = m.screenWidth - rect.left
}
// #endif
})
// 标题透明度跟着滚动位置走
watch(() => props.scrollTop, (v) => {
if (props.threshold <= 0) {
titleOpacity.value = 1
return
}
titleOpacity.value = Math.min(v / props.threshold, 1)
}, { immediate: true })
function handleBack() {
const pages = getCurrentPages()
// 页面栈里还有上一页,正常返回;否则回首页
if (pages.length > 1) {
uni.navigateBack({ delta: 1 })
} else {
uni.reLaunch({ url: '/pages/index/index' })
}
}
</script>
这里有个细节值得停一下:标题为什么能真居中。
如果左右两侧宽度不一样,用 flex: 1 布局出来的标题会偏向窄的那一边。所以我让左侧固定宽度等于 screenWidth - rect.left,右侧也用同样的宽度——这个宽度刚好就是右上角胶囊占据的那块区域。两边一对称,中间的标题自然落在屏幕正中,跟系统导航栏的视觉重心一致。
样式部分(放在组件的 <style> 里,这里只列关键点):
.nav用position: fixed; top: 0; left: 0; right: 0; z-index: 999;;.nav__body用display: flex; align-items: center;,标题用flex: 1; text-align: center; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;;box-sizing: border-box要加上,否则paddingTop会把高度撑出去。
六、页面里怎么用
先在 pages.json 里把导航栏关掉:
{
"path": "pages/order/detail",
"style": {
"navigationStyle": "custom",
"navigationBarTextStyle": "black",
"enablePullDownRefresh": false
}
}
注意 enablePullDownRefresh 我设成了 false。原因下一节说。
页面代码:
<template>
<view class="page">
<NavBar
title="订单详情"
:scroll-top="scrollTop"
:threshold="80"
bg-color="#ffffff"
/>
<!-- 页面正文 -->
<view class="content">
<!-- ... -->
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onPageScroll } from '@dcloudio/uni-app'
import NavBar from '@/components/NavBar/NavBar.vue'
const scrollTop = ref(0)
onPageScroll((e) => {
scrollTop.value = e.scrollTop
})
</script>
onPageScroll 必须从 @dcloudio/uni-app 里导入,不能像 Vue 2 那样直接写在 methods 同级。这是 Vue 3 版本里很多人第一次会卡住的地方。
七、三端的差异怎么填
组件里的条件编译只处理了微信端,另外两端默认走 navHeight = 44。这个默认值对大部分情况够用,但有两个地方需要补:
App 端
App 端拿不到胶囊,44px 是安全的。但如果你用 nvue 写页面,布局引擎完全不同,flex 的行为、不支持的选择器都跟 vue 页面有差异,导航栏这种强依赖布局的组件建议只在 vue 页面里用。
另外 App 端的沉浸式状态栏需要在 manifest.json 里打开:
{
"app-plus": {
"statusbar": {
"immersed": "supportedDevice"
}
}
}
不开这个的话,statusBarHeight 拿到的值是 0,导航栏会直接顶到屏幕最上方。
H5 端
H5 没有系统状态栏,statusBarHeight 天然是 0,navHeight 用 44 就行。但要在浏览器里做吸顶,记得给 .nav 加 position: sticky 的兼容分支——H5 端用 sticky 比 fixed 更稳,因为 fixed 在移动端浏览器的地址栏收起/展开时会有跳变。
可以在组件样式里这样写:
/* #ifdef H5 */
.nav {
position: sticky;
top: 0;
}
/* #endif */
八、踩过的坑,一条条列出来
1. rpx 和 px 千万别混着用
getMenuButtonBoundingClientRect 返回的是 px,getSystemInfoSync 里的 statusBarHeight 也是 px。如果你在计算过程中掺进了 rpx,结果就会被设计稿宽度缩放,在 375 宽的机器上看着正常,换成 414 宽的机器直接错位。整套计算统一用 px,只在最后输出给 CSS 时也保持 px。
2. getMenuButtonBoundingClientRect 偶尔返回 undefined
低版本基础库、或者在某些鸿蒙设备上,这个 API 可能拿不到值。所以上面代码里写了 if (rect && rect.height > 0) 的兜底,拿不到就退回 44px。不加这个判断,页面会直接白屏。
3. 自定义导航栏 + 原生下拉刷新 = 灾难
开启 enablePullDownRefresh 之后,下拉时系统会整块下移页面,包括你固定在顶部的导航栏。结果就是下拉过程中导航栏跟着往下跑,露出后面的一截白。
两种处理方式:pages.json 里关掉下拉刷新,改用 scroll-view 自己做;或者用 onPullDownRefresh 时手动 uni.setNavigationBarColor 之类的补偿手段,但效果都不如前一种干净。
4. tabBar 页面不要显示返回箭头
在 tabBar 页面调用 getCurrentPages(),返回的数组长度永远是 1。如果忘了传 showBack=false,用户会看到一个点了没反应的返回按钮。更好的做法是在 handleBack 里也判断一下,页面栈长度不大于 1 时走 reLaunch 回首页,这样即使误传了也不会卡死。
5. 标题被右侧插槽挤歪
右侧插槽里放的东西如果宽度超过 sideWidth,会把标题往左顶。解决办法是给 .nav__side 加 overflow: hidden; flex-shrink: 0;,限制住它的宽度,同时在设计上控制右侧内容的数量。
6. 页面滚动监听在组件里失效
onPageScroll 只能写在页面级组件里,写在子组件里是收不到的。所以滚动值必须由页面接住,再通过 props 传下去。这也是上面组件设计成接收 scrollTop 而不是内部监听的原因。
7. 安全区底部别忘了
导航栏只管顶部,底部还有一截是 iPhone X 之后才有的。页面最底部的固定按钮、或者 scroll-view 的 padding-bottom,都要加上 env(safe-area-inset-bottom)。虽然和导航栏不是同一件事,但在同一个页面里经常一起出现,容易顾此失彼。
九、给滚动加一点渐变
上面组件里的 titleOpacity 只是控制标题淡入。如果想做出「背景从透明逐渐变白」的效果,思路是一样的,换成绑定背景色即可。可以在页面的 onPageScroll 里算好一个 0 到 1 的进度,然后拼成 rgba():
const progress = Math.min(scrollTop / 120, 1)
// 注意:这里算的是背景不透明度,不是颜色本身
const navBg = `rgba(255, 255, 255, ${progress})`
传给组件的 bgColor,滚动到 120px 之后导航栏就是纯白。这个 120 可以根据页面首屏的高度来调,通常取首屏高度的三分之一左右比较顺眼。
还有个更省事的做法:不用 JS 参与,直接在组件里用 CSS 的 animation-timeline: scroll() 把背景色和滚动绑定。不过这个特性目前在 iOS 的 WebView 和部分小程序基础库里支持得还不够,跨端项目里还是老老实实用 JS 传值更保险。
十、最后说两句取舍
自定义导航栏本质上是用「可维护性」换「视觉自由度」。一旦用了,就意味着尺寸计算、返回逻辑、滚动联动、三端差异这四件事都得自己扛。如果你的页面里只有一两个位置需要透明头部,其实可以用 navigationStyle 保持默认,只把那几个页面单独处理,而不是全站铺开。
上面的组件代码不大,但每一行都对应着一个具体的跨端问题。直接抄进项目里能跑起来,不过更建议你按自己的产品形态删掉用不上的分支——比如纯 H5 项目,整个 MP-WEIXIN 那一段都可以不要。

