uniapp 自定义导航栏组件从 0 到 1:状态栏、胶囊按钮、滚动渐变一次搞定

2026-08-10 0 226

页面需要一个顶部导航栏,背景图片从透明渐变到白色,标题在滚动过程中从隐藏变成了淡入,右边还有一个自定义按钮。我本来打算直接用 uniapp 自带的 navigationStyle,但发现需要深度定制。于是写了一个自定义导航栏组件,过程中遇到了状态栏高度、微信小程序胶囊按钮避让、滚动监听等一堆破事,今天把这些坑一个个填平。

这个组件已经在我的三个项目里用过了,兼容 H5、微信小程序和 App,今天把完整思路和代码拆开讲。

先梳理一下需求

要实现的导航栏大概长这样:

  • 顶部有一个返回按钮(左箭头),页面可配置是否需要。
  • 中间是标题,滚动页面时标题逐渐显示。
  • 右边可以放文字或icon,例如“分享”。
  • 背景颜色透明,随着页面 scroll 逐渐变成白色,同时文字颜色从白变黑。

最关键的是:在微信小程序里要避开右上角的胶囊按钮,不能让返回箭头和标题被胶囊盖住。其他平台没有胶囊,但也要保留状态栏高度。

第一步:先获取状态栏高度和胶囊按钮信息

网上很多帖子只说 uni.getSystemInfoSync(),但忽略了胶囊按钮的位置信息。在小程序里,胶囊按钮的位置可以通过 uni.getMenuButtonBoundingClientRect() 获取。这个函数在 H5 端不存在,所以要做条件编译。

我在组件里这样写:

<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'

const statusBarHeight = ref(20)
const navBarHeight = ref(44)
const capsule = ref(null)

onLoad(() => {
  const sys = uni.getSystemInfoSync()
  statusBarHeight.value = sys.statusBarHeight || 0

  // #ifdef MP-WEIXIN
  const menu = uni.getMenuButtonBoundingClientRect()
  capsule.value = {
    top: menu.top,
    height: menu.height,
    width: menu.width,
    right: menu.right,
    bottom: menu.bottom
  }
  // 根据胶囊位置动态计算导航栏高度
  navBarHeight.value = (menu.top - statusBarHeight.value) * 2 + menu.height
  // #endif

  // #ifndef MP-WEIXIN
  // 非小程序端没有胶囊,就用系统导航栏默认高度
  navBarHeight.value = 44
  // #endif
})
</script>

注意,在微信小程序里,状态栏到胶囊顶部有一段间隙,胶囊底部到导航栏底部也有一段间隙。所以总导航栏高度 = (胶囊top – 状态栏高度) * 2 + 胶囊height。这样底部对齐会比较协调。

第二步:画导航栏的布局

导航栏结构分为三个区域:左边(返回)、中间(标题)、右边(自定义按钮)。布局用 flex 即可,关键是要给左侧预留胶囊宽度。

模板大概长这样:

<template>
  <view class="nav-wrapper" :style="{ paddingTop: statusBarHeight + 'px', height: navBarHeight + 'px' }">
    <view class="nav-inner">
      <view class="nav-left">
        <view v-if="showBack" class="back-btn" @tap="handleBack">
          <text class="arrow">←</text>
        </view>
      </view>

      <view class="nav-center">
        <text class="nav-title" :style="{ opacity: titleOpacity }">{{ title }}</text>
      </view>

      <view class="nav-right">
        <slot name="right"></slot>
      </view>
    </view>
  </view>
</template>

这里我用了三个区域,中间区域通过绝对定位居中,左右两边用 flex 占位。这样中间标题真正居中,同时完全避开了左右两边的按钮。

接下来是关键样式:

<style scoped>
.nav-wrapper {
  width: 100%;
  box-sizing: border-box;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 999;
  background: transparent;
  transition: background-color 0.15s;
}

.nav-inner {
  height: 100%;
  display: flex;
  align-items: center;
  padding: 0 16px;
  position: relative;
}

.nav-left,
.nav-right {
  width: 80px;
  display: flex;
  align-items: center;
}

.nav-right {
  justify-content: flex-end;
}

.nav-center {
  position: absolute;
  left: 80px;
  right: 80px;
  display: flex;
  justify-content: center;
  align-items: center;
}

.nav-title {
  font-size: 17px;
  font-weight: 600;
  color: #fff;
  opacity: 0;
  transition: opacity 0.2s;
}

.back-btn {
  width: 32px;
  height: 32px;
  background: rgba(0,0,0,0.3);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #fff;
}

.arrow {
  font-size: 20px;
  line-height: 1;
}
</style>

这里我写死了左右区域宽度 80px。但微信小程序里胶囊宽度大约是 87px,加上右边距大约 10px,所以 80px 可能不够,需要动态算出左侧占位宽度。更好的方式是:右侧占位宽度 = windowWidth – capsule.left + 10(胶囊和屏幕右侧的空隙)。这样返回按钮永远在胶囊左边,不会被盖住。

我在模板里用计算属性:

const sideWidth = computed(() => {
  // #ifdef MP-WEIXIN
  if (capsule.value) {
    // 屏幕宽度
    const winW = uni.getSystemInfoSync().windowWidth
    return (winW - capsule.value.right + 10) + 'px'
  }
  // #endif
  return '80px'
})

然后在 nav-left 和 nav-right 上绑定这个宽度:

<view class="nav-left" :style="{ width: sideWidth }">

这样在非小程序端就是固定80px,在小程序端就是动态的胶囊宽度,保证标题居中且不会被盖住。

第三步:滚动渐变的核心逻辑

自定义导航栏是 fixed 定位,不跟随页面滚动。我们需要监听页面滚动,然后改变导航栏背景色和文字颜色。

通常的做法是使用 onPageScroll 页面生命周期函数,但你不可能在每个页面里都写一遍。所以我把滚动监听封装在一个自定义事件里:在组件内通过 uni.$on 接收页面传过来的滚动数据。或者让父页面在 onPageScroll 里调用组件的方法。

最简单的方式是:组件内部使用 uni.createSelectorQuery 监听某个滚动容器?但这样过于复杂。我采用的是在组件中暴露一个 handleScroll 方法,页面在 onPageScroll 里直接 this.$refs.customNav.handleScroll(scrollTop)

不过 Vue3 组合式下 $refs 需要结合 defineExpose 使用。

组件里定义:

const scrollTop = ref(0)
const titleOpacity = ref(0)
const navBg = ref('transparent')

function handleScroll(scrollTop) {
  // 滚动超过200px后,标题完全显示
  titleOpacity.value = Math.min(scrollTop / 200, 1)
  
  // 滚动超过100px后背景开始变白,250px完全变白
  const bgOpacity = Math.min(scrollTop / 250, 1)
  navBg.value = `rgba(255,255,255,${bgOpacity})`
  
  // 文字颜色也能动态计算
  textColor.value = bgOpacity > 0.8 ? '#000' : '#fff'
}

然后通过 defineExpose 暴露出去:

defineExpose({ handleScroll })

在页面中使用这个组件时,这样写:

<template>
  <view>
    <custom-nav ref="customNav" title="商品详情" :show-back="true"></custom-nav>
    <view class="page-content">...</view>
  </view>
</template>

<script setup>
import CustomNav from '@/components/custom-nav.vue'
import { ref } from 'vue'
const customNav = ref(null)

// 页面滚动事件
function onPageScroll(e) {
  customNav.value?.handleScroll(e.scrollTop)
}
</script>

但问题来了:Vue3 组合式中,onPageScroll 需要从 @dcloudio/uni-app 中导入:

import { onPageScroll } from '@dcloudio/uni-app'
onPageScroll((e) => {
  customNav.value?.handleScroll(e.scrollTop)
})

这样就能实现滚动渐变,而且不用把滚动逻辑塞到组件里。

组件内背景色也要动态绑定,所以把 nav-wrapper 的 style 修改:

<view class="nav-wrapper" :style="{ paddingTop: statusBarHeight + 'px', height: navBarHeight + 'px', backgroundColor: navBg }">

同时标题和返回箭头的颜色也需要根据背景深浅变化。我在组件里定义了一个 textColor 计算值,然后绑定到元素上:

const textColor = ref('#fff')

function handleScroll(scrollTop) {
  // ...
  textColor.value = bgOpacity > 0.7 ? '#000' : '#fff'
}

模板里:

<text class="nav-title" :style="{ color: textColor, opacity: titleOpacity }">{{ title }}</text>
<view class="back-btn" :style="{ backgroundColor: showBackBg ? 'rgba(0,0,0,0.3)' : 'transparent', color: textColor }">

我为了简单,没有将返回按钮的背景也动态变色,因为通常返回按钮在透明背景下用白色箭头,在白色背景下用黑色箭头,这就够了。

第四步:处理返回动作和插槽

返回按钮事件很简单:

function handleBack() {
  uni.navigateBack({
    fail() {
      uni.switchTab({
        url: '/pages/index/index'
      })
    }
  })
}

如果页面是第一个页面,navigateBack 会失败,那就跳转到首页。当然你也可以用 getCurrentPages() 判断页面栈深度。

右边按钮通过插槽暴露给父级,让父级自由定制。比如这里的分享按钮:

<custom-nav title="商品详情" :show-back="true">
  <template #right>
    <view class="share-btn" @click="share">分享</view>
  </template>
</custom-nav>

这样组件只负责框架,具体内容父级决定,复用性很高。

第五步:兼容 H5 和 App 端的细节

H5 端没有胶囊,状态栏高度为 0,所以导航栏高度就是 44px,和普通网页导航栏一致。

App 端在非刘海屏手机状态栏高度一般是 20 或 24,在 iPhone X 以后是 44 或 47。这些 statusBarHeight 都能通过系统 API 获取到,所以问题不大。唯一需要注意的是,在 App 端如果使用原生子窗体或 nvue,这个组件可能会失效,但如果你全用 vue 页面,这个方法完全没问题。

还有一个坑:某些安卓机返回的 statusBarHeight 可能包含导航手势条,导致导航栏整体偏高。我的解决办法是:在 App 端利用 plus.navigator.getStatusbarHeight 获取精确值,但通常 uni.getSystemInfoSync() 已经足够用了。

完整组件的代码骨架

把以上细节整合成一份核心代码,你可以直接拿去改。下面是一份精简可用的组件 custom-nav.vue

<template>
  <view class="nav-wrapper" :style="{ paddingTop: statusBarHeight + 'px', height: navBarHeight + 'px', backgroundColor: navBg }">
    <view class="nav-inner">
      <view class="side left" :style="{ width: sideWidth }">
        <view v-if="showBack" class="back-btn" @tap="handleBack">
          <text class="arrow" :style="{ color: textColor }">←</text>
        </view>
      </view>

      <view class="nav-center">
        <text class="nav-title" :style="{ opacity: titleOpacity, color: textColor }">{{ title }}</text>
      </view>

      <view class="side right" :style="{ width: sideWidth }">
        <slot name="right"></slot>
      </view>
    </view>
  </view>
</template>

<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'

const props = defineProps({
  title: String,
  showBack: Boolean
})

const statusBarHeight = ref(20)
const navBarHeight = ref(44)
const capsule = ref(null)
const sideWidth = ref('80px')
const navBg = ref('transparent')
const titleOpacity = ref(0)
const textColor = ref('#ffffff')

onLoad(() => {
  const sys = uni.getSystemInfoSync()
  statusBarHeight.value = sys.statusBarHeight || 0

  // #ifdef MP-WEIXIN
  const menu = uni.getMenuButtonBoundingClientRect()
  capsule.value = menu
  navBarHeight.value = (menu.top - statusBarHeight.value) * 2 + menu.height
  const winW = sys.windowWidth
  sideWidth.value = (winW - menu.right + 8) + 'px'
  // #endif

  // #ifndef MP-WEIXIN
  navBarHeight.value = 44
  sideWidth.value = '80px'
  // #endif
})

function handleScroll(scrollTop) {
  const bgOpacity = Math.min(scrollTop / 250, 1)
  navBg.value = `rgba(255,255,255,${bgOpacity})`
  titleOpacity.value = Math.min(scrollTop / 200, 1)
  textColor.value = bgOpacity > 0.7 ? '#000000' : '#ffffff'
}

function handleBack() {
  uni.navigateBack({
    fail() {
      uni.reLaunch({ url: '/pages/index/index' })
    }
  })
}

defineExpose({ handleScroll })
</script>

<style scoped>
.nav-wrapper {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 999;
  box-sizing: border-box;
  border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}

.nav-inner {
  height: 100%;
  display: flex;
  align-items: center;
  position: relative;
  padding: 0;
}

.side {
  display: flex;
  align-items: center;
  height: 100%;
}

.left {
  justify-content: flex-start;
  padding-left: 8px;
}

.right {
  justify-content: flex-end;
  padding-right: 8px;
}

.nav-center {
  position: absolute;
  left: 0;
  right: 0;
  display: flex;
  justify-content: center;
  align-items: center;
  pointer-events: none;
}

.nav-title {
  font-size: 17px;
  font-weight: 500;
  white-space: nowrap;
}

.back-btn {
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.2);
}

.arrow {
  font-size: 20px;
  line-height: 1;
  color: #fff;
}
</style>

注意在组件中我没有写 transition,因为滚动渐变是每一帧变化的,不需要 CSS transition,否则反而会显得卡顿。直接绑 style 就能实时变化。

在页面里如何使用

使用的时候很简洁。以商品详情页为例:

<template>
  <view class="page">
    <custom-nav ref="navRef" title="商品详情" :show-back="true">
      <template #right>
        <text class="share" @tap="onShare">分享</text>
      </template>
    </custom-nav>

    <view class="banner">...</view>
    <view class="info">...</view>
  </view>
</template>

<script setup>
import CustomNav from '@/components/custom-nav.vue'
import { ref } from 'vue'
import { onPageScroll } from '@dcloudio/uni-app'

const navRef = ref(null)
onPageScroll((e) => {
  navRef.value?.handleScroll(e.scrollTop)
})

function onShare() {
  uni.showToast({ title: '分享逻辑', icon: 'none' })
}
</script>

页面顶部记得要留出导航栏的位置,否则内容会被遮挡。可以在页面根节点加一个占位 view,高度等于状态栏+导航栏高度,或者使用 unicloud 的 safe-area 样式。我用的是 CSS 变量,但为了组件通用,直接在页面里写一个 padding-top,数值等于状态栏 + 导航栏高度。这里我们可以使用组件的 :style 让页面动态设置,也可以简单点,在页面 onLoad 里拼接一个高度字符串,然后绑定到占位 view 上。

<view :style="{ height: navHeight, background: '#fff' }"></view>

不过更好的方式是在组件内部通过 emit 把高度告诉父级,不过这样会麻烦一些。我的做法是在页面中写一个固定值,比如应用内所有页面都是同样的状态栏高度,直接用一个全局的 CSS 变量:

page {
  --status-bar-height: 44px;
  --nav-bar-height: 44px;
}

有点啰嗦,但你可以根据实际情况调整。在实际项目里,我通常会在 App.vue 的 onLaunch 里计算一次,然后存到全局变量,组件和页面都能共享。

踩过的几个特殊坑

第一个坑是微信小程序的菜单按钮高度不是固定的。不同机型,甚至不同微信版本,菜单按钮的位置会略有变化,所以我每次都在 onLoad 里动态获取,而不是写死 87px。

第二个坑是滚动事件的节流。onPageScroll 在部分手机上触发频率非常高,如果你在 handleScroll 里做复杂的 DOM 操作,可能造成掉帧。我的 handleScroll 里只是给几个 ref 赋值,Vue3 的响应式系统会自动优化,但如果你的页面很复杂,可以考虑用 requestAnimationFrame 做节流。

第三个坑是当页面使用自定义导航栏时,微信小程序的页面配置要加 navigationStyle: custom,否则系统自带的导航栏还会出来,导致双导航栏。App 端也要在 pages.json 里设置 navigationStyle: custom,H5 端同样需要。

第四个坑是如果页面包含原生下拉刷新,onPageScroll 的数值不受影响。但如果使用了 scroll-view 滚动,就得监听 scroll-view 的 scroll 事件,而不是 onPageScroll。

如何让组件更通用

我在组件里只实现了返回按钮和标题,但在实际项目中,经常需要左边不只是返回,还能放自定义图标。右边也不仅仅是插槽,可能还要支持搜索框。因此你可以把左右 slot 都暴露出来,左边也加一个 slot name="left",这样当 needBack 为 false 时,左边可以完全由外部控制。

还有,导航栏背景不一定只是白色,可能品牌色渐变。那就可以在 handleScroll 中控制一个背景层,用 opacity 控制一个固定颜色的渐变层。或者直接使用插槽传入背景图,这些都能自由扩展。

如果你想封装一个带搜索框的导航栏,也可以在这个基础样式上加一个 input 区域。反正组件只负责布局和高度计算,具体内容完全由父组件决定。

终稿之前:真机效果与总结

我在 iPhone XR、小米 10、微信开发者工具中分别测试了这个组件,表现基本一致。滚动时背景和标题渐入渐出,没有出现闪烁或延迟。关键是胶囊按钮的位置永远不会被覆盖。

自定义导航栏是 uniapp 开发里绕不开的一个难点,也是很多新手崩溃的地方。但把状态栏高度和胶囊位置弄明白,后面就好办了。这篇文章里的组件可以作为一个基础模板,直接拷过去改成自己的样式,能帮你省半天时间。

如果还有不清楚的,建议你下载一个小程序开发工具,用微信的“真机调试”功能在真机上跑一遍,会比单纯看文章深入人心。毕竟代码这东西,自己动手改一遍比看十遍有效。

uniapp 自定义导航栏组件从 0 到 1:状态栏、胶囊按钮、滚动渐变一次搞定
收藏 (0) 打赏

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

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

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

淘吗网 uniapp uniapp 自定义导航栏组件从 0 到 1:状态栏、胶囊按钮、滚动渐变一次搞定 https://www.taomawang.com/web/uniapp/2517.html

常见问题

相关文章

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

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