页面需要一个顶部导航栏,背景图片从透明渐变到白色,标题在滚动过程中从隐藏变成了淡入,右边还有一个自定义按钮。我本来打算直接用 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 开发里绕不开的一个难点,也是很多新手崩溃的地方。但把状态栏高度和胶囊位置弄明白,后面就好办了。这篇文章里的组件可以作为一个基础模板,直接拷过去改成自己的样式,能帮你省半天时间。
如果还有不清楚的,建议你下载一个小程序开发工具,用微信的“真机调试”功能在真机上跑一遍,会比单纯看文章深入人心。毕竟代码这东西,自己动手改一遍比看十遍有效。

