自定义导航栏看起来只是把系统那条白底黑字换成品牌色,真动手才知道第一道坎跟样式没关系,是高度。状态栏多高、胶囊按钮落在哪个位置、标题到底该相对屏幕居中还是居中在胶囊左边那块空白里——这几个数字错一个,界面就歪给你看。
我上一个项目切自定义导航栏的时候,首页在开发者工具里怎么调都是正的,发到真机上标题往右偏了小半个字。查了半天发现是胶囊按钮的坐标在工具里和真机上差了 4px,而我的高度是写死的。那之后我把整套逻辑重写成动态计算的,这篇文章就是那次的整理。
先想清楚为什么要自定义
官方导航栏其实不差,Vue3 版本还支持了 titleNView 的一些配置。真正需要自己动手的场景大概有这几类:
- 头部要做渐变透明,随着页面滚动慢慢显形
- 导航栏里要塞搜索框、或者左右两侧按钮不止一个
- 要控制胶囊按钮和标题之间的距离,官方的间距不满意
- 多端统一视觉,但各端官方导航栏长得都不一样
如果只是改个背景色,pages.json 里的 navigationBarBackgroundColor 就够了,别折腾。一旦开始自定义,后面所有高度适配的事都得自己扛。
三个高度,别搞混
先把概念拆开,这是后面所有计算的基础。
状态栏高度:屏幕最顶上显示时间、信号、电量那一条。iOS 的刘海屏大概是 44 到 47px,老一点的机型是 20px,Android 各家不一样。在浏览器里这个概念根本不存在,值为 0。
胶囊高度:微信小程序右上角那个”···”加”○”的组合,高度固定是 32px(不同机型可能差 1px)。App 端没有真正的胶囊,但 uni-app 会给你一个等效的预留区域。
导航栏高度:状态栏下面那条放标题的横条。这个值不是固定的,得算。
三者的关系是:状态栏 + 导航栏 = 整个头部占位高度。页面内容得从这个总高度往下排。
别再用 getSystemInfoSync 了
网上大部分教程还在写 uni.getSystemInfoSync()。这个 API 现在有几个问题。
一是微信官方早就把它标成了”不推荐使用”,原因是它一次性返回几十个字段,每次调用都要跨线程通信,启动阶段频繁调用会拖慢首屏。二是它返回的 statusBarHeight 在部分平台的返回值受调用时机影响。三是新版 uni-app 已经把它拆成了四个更轻的 API。
拆完之后是这样:
// 一次性拿窗口相关的尺寸
const win = uni.getWindowInfo()
// 返回:pixelRatio / screenWidth / screenHeight / windowWidth / windowHeight
// / statusBarHeight / safeArea / screenTop
// 设备信息
const device = uni.getDeviceInfo()
// App 的基础信息
const app = uni.getAppBaseInfo()
// 小程序的基础信息(需要传参数)
const mp = uni.getAppBaseInfo({ needPluginInfo: false })
自定义导航栏只关心窗口尺寸,所以 getWindowInfo() 一个就够。它的开销比老 API 小很多,可以放心在页面里调用。
导航栏高度是怎么推出来的
微信设计胶囊按钮的时候,让它到状态栏底部的距离,和到导航栏底部的距离相等。也就是说胶囊在导航栏这个横条里是垂直居中的。
设状态栏底部为 y0,胶囊顶部为 y1,胶囊底部为 y2,导航栏底部为 y3。因为居中,所以:
y1 - y0 = y3 - y2
移项得到导航栏底部的位置:
y3 = y2 + (y1 - y0)
而导航栏高度 = y3 – y0,代入:
navBarHeight = y2 + y1 - y0 - y0
= (y1 - y0) * 2 + (y2 - y1)
= (胶囊top - 状态栏高度) * 2 + 胶囊高度
这就是那个到处都能看到的公式。知道它是怎么来的,比抄下来管用——比如哪天你要把标题对齐到胶囊左侧那块区域的中心,就知道该用 menu.left 而不是 windowWidth / 2。
封装成组合式函数
新建 composables/useNavbar.js。核心是读取上面那几个值,算好之后缓存起来,多个组件共用一份。
import { computed, ref } from 'vue'
const FALLBACK_NAV_HEIGHT = 44
const FALLBACK_SIDE_GAP = 10
function readMetrics() {
try {
const win = uni.getWindowInfo()
const windowWidth = win.windowWidth || 375
const statusBarHeight = win.statusBarHeight || 0
let menu = null
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-TOUTIAO || APP-PLUS
try {
menu = uni.getMenuButtonBoundingClientRect()
} catch (e) {
menu = null
}
// #endif
// 胶囊高度为 0 或位置异常时,说明当前平台没给出可用数据
const usable = !!(menu && menu.height > 0 && menu.top >= statusBarHeight)
const navBarHeight = usable
? Math.round((menu.top - statusBarHeight) * 2 + menu.height)
: FALLBACK_NAV_HEIGHT
// 胶囊右边距,用来给左右两侧的按钮留出对等宽度
const sideGap = usable
? Math.round(Math.max(FALLBACK_SIDE_GAP, windowWidth - menu.right))
: FALLBACK_SIDE_GAP
return { statusBarHeight, navBarHeight, sideGap, windowWidth }
} catch (e) {
return {
statusBarHeight: 0,
navBarHeight: FALLBACK_NAV_HEIGHT,
sideGap: FALLBACK_SIDE_GAP,
windowWidth: 375
}
}
}
const metrics = ref(readMetrics())
export function refreshNavbar() {
metrics.value = readMetrics()
}
export function useNavbar() {
const totalHeight = computed(
() => metrics.value.statusBarHeight + metrics.value.navBarHeight
)
const cssVars = computed(() => ({
'--status-bar-height': metrics.value.statusBarHeight + 'px',
'--nav-bar-height': metrics.value.navBarHeight + 'px',
'--nav-total-height': totalHeight.value + 'px',
'--nav-side-gap': metrics.value.sideGap + 'px'
}))
return { metrics, totalHeight, cssVars, refresh: refreshNavbar }
}
几个设计上的取舍说一下。
#ifdef 条件编译那段把 getMenuButtonBoundingClientRect 圈了起来。H5 端根本没有这个方法,直接调用会抛异常,虽然包在 try 里也能跑,但每次执行都要走一遍异常处理,不如编译期就裁掉。
外侧的 try 包着 getWindowInfo,是因为模块顶层的 ref(readMetrics()) 在 import 阶段就执行了,那时候 uni 对象可能还没初始化完。给它一个兜底值,页面挂载后再 refresh 一次。
用 CSS 变量而不是给每个元素绑 :style,是为了少写重复的对象字面量。模板里十几个位置都要用这几个值,逐个绑定既啰嗦又容易漏。
最后做成模块级单例:多个页面共用一份数据,不需要每个组件都重新算一遍。
组件怎么写
新建 components/NavBar.vue。
<template>
<view class="nav-root" :style="cssVars">
<view class="nav-fixed">
<view class="nav-status"></view>
<view class="nav-bar">
<view class="nav-slot-left">
<slot name="left"></slot>
</view>
<view class="nav-title">
<slot>{{ title }}</slot>
</view>
<view class="nav-slot-right"></view>
</view>
</view>
<view class="nav-placeholder"></view>
</view>
</template>
<script setup>
import { onMounted } from 'vue'
import { useNavbar } from '@/composables/useNavbar'
defineProps({
title: { type: String, default: '' }
})
const { cssVars, refresh } = useNavbar()
onMounted(refresh)
</script>
<style lang="scss" scoped>
.nav-root {
position: relative;
z-index: 100;
}
.nav-fixed {
position: fixed;
top: 0;
left: 0;
right: 0;
background: #ffffff;
z-index: 100;
}
.nav-status {
height: var(--status-bar-height);
}
.nav-bar {
height: var(--nav-bar-height);
display: flex;
align-items: center;
}
.nav-slot-left,
.nav-slot-right {
flex: none;
width: var(--nav-side-gap);
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.nav-title {
flex: 1;
min-width: 0;
text-align: center;
font-size: 34rpx;
font-weight: 600;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.nav-placeholder {
height: var(--nav-total-height);
}
</style>
这里有一个我踩过的坑:CSS 变量定义在哪个元素上,只有那个元素和它的后代能读到。
一开始我把 :style="cssVars" 绑在 .nav-fixed 上,结果下面的 .nav-placeholder 是兄弟节点,读不到 --nav-total-height,高度直接变成 0,页面内容被导航栏盖住一大块。开发者工具里看着像没生效,其实变量根本没继承过来。
解决办法就是现在这样,外面再套一层 .nav-root,变量绑在这一层,固定条和占位块都在它里面。多一层 view 在 uni-app 里几乎没有成本。
另外为什么不用 position: sticky?小程序和 App 端对 sticky 的支持一直不太稳,尤其是配合 scroll-view 用的时候。固定定位加占位块虽然土,但四端表现一致,不会在某台机器上翻车。
页面配置别忘了
在 pages.json 里给对应页面加上:
{
"path": "pages/index/index",
"style": {
"navigationStyle": "custom",
"navigationBarTextStyle": "black"
}
}
如果整个应用都要这种风格,可以写到 globalStyle 里。但我一般只在需要的页面单独加,因为设了 custom 之后,navigationBarTitleText 就完全失效了,得靠组件传 title。全局开的话,那些原本靠配置就能搞定的页面也得跟着改,不划算。
四个端的表现差别
这块是实际做下来差异最大的地方。
微信小程序:数据最全。状态栏高度、胶囊坐标都能拿到,公式算出来的结果和官方导航栏几乎完全一致。开发者工具和真机偶有 1 到 2px 的差异,可以忽略。
App 端:getMenuButtonBoundingClientRect 在 App 下也能返回数据,但 App 本身没有”胶囊按钮”这个东西,uni-app 给的是一块预留区域的等效值。实测下来大部分机型算出来的高度是 44 左右,个别 Android 机型会有偏差。建议在项目的三台主力测试机上各量一遍,看看算出来的值是不是都落在合理区间,不对的话就在兜底逻辑里按平台再调一次。
H5:statusBarHeight 是 0,getMenuButtonBoundingClientRect 不存在,全部走兜底值 44px。浏览器顶部本来就没有系统状态栏,这个结果是对的。但要注意在微信内置浏览器里打开时,顶部会多出一条微信自己的导航栏,那是宿主环境的,跟你的页面没关系,别再往上加高度。
nvue:最容易出问题的一个。nvue 的 flex 容器默认 flex-direction 是 column,而普通页面里是 row。所以直接把上面那段 Vue 代码搬到 nvue 页面,导航栏会变成竖着排。得显式写一遍 flex-direction: row。另外 nvue 不支持 CSS 变量,得改成把计算出来的像素值直接绑到 :style 上。
延伸:滚动渐变的透明导航栏
这套结构稍微改一下就能做渐变头部。思路是让导航栏背景色的透明度跟着滚动距离走。
<script setup>
import { ref } from 'vue'
import { onPageScroll } from '@dcloudio/uni-app'
const progress = ref(0)
onPageScroll((e) => {
progress.value = Math.min(1, e.scrollTop / 120)
})
</script>
<template>
<view class="nav-root" :style="cssVars">
<view
class="nav-fixed"
:style="{ backgroundColor: `rgba(255, 255, 255, ${progress})` }"
>
<view class="nav-status"></view>
<view class="nav-bar">
<view class="nav-slot-left"><slot name="left"></slot></view>
<view
class="nav-title"
:style="{ opacity: progress }"
>{{ title }}</view>
<view class="nav-slot-right"></view>
</view>
</view>
<view class="nav-placeholder"></view>
</view>
</template>
onPageScroll 是从 @dcloudio/uni-app 导入的,别从 vue 里找。120 这个数字是经验值,对应大概一屏高度的十分之一,滚过这个距离导航栏就完全显形。调太快会显得突兀,太慢又像卡住了。
有个细节:onPageScroll 触发很频繁,绑在上面的回调里只做一次减法运算和一次赋值就够,别在里面做任何请求或者复杂计算,否则滚动会掉帧。真要复杂处理,用节流函数包一层。
几个实打实的坑
横竖屏切换后数值没更新。 statusBarHeight 在竖屏和横屏下不一样,胶囊的坐标也变了。挂上 uni.onWindowResize(refreshNavbar) 重新读一次就好,记得在页面卸载时 offWindowResize,不然切几个页面之后回调会越积越多。
标题被胶囊压住。 如果标题设成了相对屏幕居中,在窄屏上可能会和胶囊重叠。稳妥的做法是给标题容器加 padding-right,值等于 --nav-side-gap,让它的可用宽度和左侧按钮区对齐。更严格一点的话,把标题容器限制在 menu.left 的范围内。
下拉刷新和自定义导航栏一起用。 在 pages.json 里开 enablePullDownRefresh 之后,下拉的转圈会出现在导航栏下面,视觉上是正常的。但如果你把导航栏做成了透明渐变,下拉时那片区域是系统默认背景色,会露出一个突兀的色块。这种情况建议关掉系统下拉刷新,用 scroll-view 自己实现。
占位块忘记加。 固定定位的元素脱离文档流,页面第一屏的内容会直接顶到导航栏下面去。占位块必须紧跟其后,高度和整个头部一致。这个问题在首屏内容少的时候看不出来,一旦列表变长就会露馅。
最后
整套方案的核心就一句话:不要在代码里写死高度,让它自己算。
状态栏高度是变量,胶囊位置是变量,可用宽度也是变量,那就用 getWindowInfo 把它们读出来,算一次,缓存起来,通过 CSS 变量分发下去。算错了某些机型还能兜底,比写死一个 44 强太多。
兼容性上就别指望一套代码零改动跑遍四端了。把差异集中在一个文件里,其他地方当黑盒用,后面遇到新机型要调,也只改那一处。

