uni-app 自定义导航栏实战:状态栏高度与微信胶囊按钮对齐

2026-10-11 0 770

自定义导航栏看起来只是把系统那条白底黑字换成品牌色,真动手才知道第一道坎跟样式没关系,是高度。状态栏多高、胶囊按钮落在哪个位置、标题到底该相对屏幕居中还是居中在胶囊左边那块空白里——这几个数字错一个,界面就歪给你看。

我上一个项目切自定义导航栏的时候,首页在开发者工具里怎么调都是正的,发到真机上标题往右偏了小半个字。查了半天发现是胶囊按钮的坐标在工具里和真机上差了 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 强太多。

兼容性上就别指望一套代码零改动跑遍四端了。把差异集中在一个文件里,其他地方当黑盒用,后面遇到新机型要调,也只改那一处。

uni-app 自定义导航栏实战:状态栏高度与微信胶囊按钮对齐
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp uni-app 自定义导航栏实战:状态栏高度与微信胶囊按钮对齐 https://www.taomawang.com/web/uniapp/2922.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务