uni-app 自定义导航栏跨端适配实战:胶囊对齐、状态栏与安全区一次讲透

2026-09-28 0 437

只要产品图里出现了渐变背景、透明导航栏、或者标题旁边要挂个搜索框,系统导航栏基本就撑不住了。uni-app 里把 navigationStyle 改成 custom 是一行配置的事,真正的麻烦在后面:状态栏多高?小程序右上角胶囊的位置怎么对齐?H5 端和 App 端又该怎么填?

这篇文章从尺寸数据出发,一步步把自定义导航栏组件写出来,最后附上一份我踩过的坑清单。代码用的是 Vue 3 的 <script setup> 写法,Vue 2 的读者把 ref/computed 换成 data/computed 即可。

一、先想清楚:为什么非换不可

系统导航栏的问题不在于丑,而在于它「不可控」。三个典型场景:

  • 首页要做沉浸式的渐变头部,导航栏背景要跟着滚动从透明变成实色,系统导航栏做不到;
  • 商品详情页需要在标题右侧挂一个分享按钮,同时还要保证标题视觉居中;
  • 海报分享页要求顶部完全无遮挡,连状态栏的文字都要是浅色的。

这些都得自己接管。而一旦接管,你就得自己回答一个问题:导航栏到底应该多高?

二、三个尺寸,先捋清楚

整个适配的核心就是三组数字:

  1. statusBarHeight:状态栏高度。iOS 刘海屏一般是 44px(或者 47、54,视机型),普通 Android 一般是 24px 左右,H5 端为 0。
  2. navHeight:状态栏下方那条「标题栏」的高度。iOS 上是 44px,Android 上按 Material 规范是 48px,但小程序里真正的参照物是右上角的胶囊。
  3. 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 那一段都可以不要。

uni-app 自定义导航栏跨端适配实战:胶囊对齐、状态栏与安全区一次讲透
收藏 (0) 打赏

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

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

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

淘吗网 uniapp uni-app 自定义导航栏跨端适配实战:胶囊对齐、状态栏与安全区一次讲透 https://www.taomawang.com/web/uniapp/2830.html

常见问题

相关文章

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

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