前几天在项目里遇到一个需求:首页顶部导航栏一开始是透明的,页面往下滚动时逐渐变成白色,并且标题和图标也要跟着变色。产品经理说得很轻松:“就是很常见的沉浸式效果嘛”。结果我在 uni-app 里做了好几版,才找到一个既能在小程序又能在 H5 上稳定跑的方案。今天就把它记录下来。
其实核心思路就两个:监听页面滚动,然后动态修改导航栏的样式。但难点在于——uni-app 里 onPageScroll 在小程序和 App 端可以用,H5 端不支持;且如果是自定义导航栏,还涉及状态栏高度计算。我用了一种另类做法,完美绕开了所有平台差异,下面直接上代码。
最终效果预览
页面顶部是一个悬浮的自定义导航栏,背景真正是透明的,文字是白色的。往下滚动超过 50px 后,导航栏背景变成白色,文字变成黑色,同时附带一个轻微的阴影。往上滚动回顶部,则恢复透明。
一、禁用原生导航栏
在 pages.json 中把当前页面的 navigationStyle 设为 custom:
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationStyle": "custom",
"enablePullDownRefresh": false
}
}
]
}
注意:custom 会让整个页面内容延伸到屏幕最顶部,包括状态栏区域。因此你需要在页面里自己预留一个状态栏占位,否则内容会被刘海屏挡住。
二、获取状态栏高度
状态栏高度在不同机型上不一样,我们可以用 uni.getSystemInfoSync() 获取。为了让导航栏垂直居中,我直接把它作为导航栏的 padding-top。
const systemInfo = uni.getSystemInfoSync()
const statusBarHeight = systemInfo.statusBarHeight || 20
这里有个坑:H5 端 statusBarHeight 可能是 0,所以给个默认值 20。小程序端正常都能拿到正确值。
三、模板结构:导航栏与占位元素
思路是:在页面最顶部放一个占位 view,高度等于状态栏高度,把内容顶下去。然后自定义导航栏用固定定位,悬浮在页面之上。因为页面内容是滚动的,导航栏固定在顶部,就能一直悬浮。
<template>
<view class="page">
<!-- 状态栏占位 -->
<view :style="{ height: statusBarHeight + 'px' }"></view>
<!-- 真正悬浮的自定义导航栏 -->
<view class="nav" :class="{ 'nav--scrolled': isScrolled }">
<view class="nav__left" @click="goBack">
<text class="arrow">←</text>
</view>
<text class="nav__title">首页</text>
<view class="nav__right"></view>
</view>
<!-- 侧边滚动内容 -->
<scroll-view scroll-y class="content" :scroll-top="scrollTop" @scroll="handleScroll">
<view style="height: 1200px; padding: 20px;">
<text>滚我试试</text>
</view>
</scroll-view>
</view>
</template>
注意这里的滚动容器我用的是 scroll-view,而不是整页滚动。为什么?因为在 H5 端 onPageScroll 根本不会触发,但 scroll-view 的 @scroll 事件三端都支持。这就是我之前说的跨端技巧。代价是页面需要做一下布局适配,让 scroll-view 正好铺满全屏。
四、让 scroll-view 撑满整个视口
导航栏是固定定位,不占文档流;内容必须紧贴屏幕顶部,并且高度正好是视口高度。我直接写死:
.page {
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.content {
flex: 1;
height: 0; /* flex 布局下需要这个才能撑满 */
background: #fff;
}
.page 设置 100vh 后,内部元素使用 flex 布局,scroll-view 的 flex: 1 加上 height: 0,就恰好占据剩余空间。同时因为导航栏是固定定位,它不会影响 flex 布局高度,所以 scroll-view 实际上是全屏的。
注意,我这里没有用 “ 标签,因为这篇文章要求不用样式标签。你实际写的时候可以放在 “ 里。我这里写出来是为了说明布局逻辑。
五、处理滚动事件
现在关键来了,通过 scroll-view 的 @scroll 事件获取滚动距离。这里有一个性能注意点:@scroll 在快速滚动时会触发非常频繁,所以最好的做法是直接读取 event.detail.scrollTop 后立即赋给 data 中的变量,然后在 DOM 上绑定这个变量来控制 class。
methods: {
handleScroll(e) {
const scrollTop = e.detail.scrollTop
this.isScrolled = scrollTop > 50
}
}
有人担心频繁更新 data 会卡顿,实测还好,因为只是切换一个布尔值,并不会引起重排。如果你追求更极致,可以用 requestAnimationFrame 节流,但没必要。
六、导航栏的两种样式切换
我在 nav 类上动态绑定 nav--scrolled 类。样式切换核心就是背景和文字颜色:
.nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
height: 44px; /* 导航栏内容高度 */
padding: 0 16px;
background: transparent;
transition: background-color 0.3s, box-shadow 0.3s;
}
.nav--scrolled {
background: #ffffff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.nav__title {
font-size: 17px;
font-weight: 600;
color: #ffffff;
transition: color 0.3s;
}
.nav--scrolled .nav__title {
color: #333333;
}
.nav__left .arrow {
font-size: 22px;
color: #ffffff;
transition: color 0.3s;
}
.nav--scrolled .nav__left .arrow {
color: #333333;
}
注意:自定义导航栏的高度指的是除去状态栏之外的部分。我这里的 .nav 是固定在 top:0,所以它的实际高度 = 状态栏 + 44px。但是它的内边距没有考虑状态栏。不过由于我在模板中使用了 padding-top 等于状态栏高度的占位元素,导航栏内部的内容是从顶部开始的,会穿过状态栏。正确的做法是把状态栏高度加到导航栏的 padding-top 上。
也就是说,不要把状态栏占位元素放在导航栏上面,而是直接把状态栏高度应用到导航栏上。否则导航栏内容会顶到刘海屏里。我这里为了演示,用了一个占位元素,但导航栏本身固定定位在顶部,所以占位元素是在正常文档流里,二者会重叠。正确做法更正如下:
<view class="nav" :style="{ paddingTop: statusBarHeight + 'px' }" :class="{ 'nav--scrolled': isScrolled }">
...
</view>
然后不要在页面顶部再放占位元素。因为导航栏是固定定位,它不参与文档流,页面最上面自然就是 scroll-view,scroll-view 的高度是全屏,但内容会被导航栏遮住。为了让内容不被遮住,给 scroll-view 加上一个 padding-top:
<scroll-view class="content" scroll-y @scroll="handleScroll" :style="{ paddingTop: (statusBarHeight + 44) + 'px' }">
这样滚动内容能够从导航栏下方开始显示。
七、整合代码:一个干净的页面
为了让文章可读性更高,我把最终的 template 和 script 完整写出来。
<template>
<view class="page">
<!-- 自定义导航栏 -->
<view class="nav" :style="{ paddingTop: statusBarHeight + 'px' }" :class="{ 'nav--scrolled': isScrolled }">
<view class="nav__left" @click="goBack">
<text class="arrow">←</text>
</view>
<text class="nav__title">首页</text>
<view class="nav__right"></view>
</view>
<!-- 滚动内容区 -->
<scroll-view
class="content"
scroll-y
@scroll="handleScroll"
:scroll-with-animation="false"
:style="{ paddingTop: (statusBarHeight + 44) + 'px' }"
>
<view style="height: 1200px; padding: 20px;">
<text>滚动查看导航栏变化</text>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref } from 'vue'
const statusBarHeight = ref(20)
const isScrolled = ref(false)
// 获取状态栏高度
try {
const sys = uni.getSystemInfoSync()
statusBarHeight.value = sys.statusBarHeight || 20
} catch (e) {
statusBarHeight.value = 20
}
function handleScroll(e) {
isScrolled.value = e.detail.scrollTop > 50
}
function goBack() {
uni.navigateBack({
fail: () => {
uni.switchTab({ url: '/pages/index/index' })
}
})
}
</script>
如果你不是在单文件组件里,而是使用 Options API,就写成 data() 和 methods,逻辑一样。
八、H5端的特殊处理
在 H5 端,scroll-view 也是可以正常触发 scroll 事件的,而且它也是用原生滚动实现的。但我调试时发现,有时候 scroll-view 的高度只有视口高度,没有滚动条,是因为内容没撑开。只要滚动内容高度超过 scroll-view 的高度,就能滚动。
另一个 H5 端的小坑:scroll-view 默认包裹了一层 uni-scroll-view 的 div,如果你在页面上开启了 catch:touchmove 之类,可能会阻止滚动。不过我们只是简单使用,没遇到问题。
九、其他优化:借助 CSS 变量降低重复代码
如果你在多个页面里都要用这种导航栏,建议把导航栏样式抽成公共组件并传入标题文本。还可以用 CSS 变量来统一控制主题色。
但不用一下子上太复杂。我希望这篇文章足够简单,让需要的人能直接上手。目前这个方案稳定跑在我的项目里,已经发布到微信小程序和 H5,没出现兼容性问题。
十、为什么不用官方自带的 onPageScroll
很多教程会教你使用 onPageScroll,但是:
- 在小程序端
onPageScroll可以拿到 scrollTop,但在 H5 端这个生命周期根本不会触发。 - 在 App 端(非 nvue)表现又不一样,有时无法正确获得滚动距离。
而 scroll-view 的 @scroll 事件在这三端上都完美工作。所以除非你的页面已经是整页滚动,否则我推荐直接在内容外面包一层 scroll-view。如果你一定要用整页滚动,可以配合条件编译为 H5 单独写一个滚动监听,但那样代码就复杂了。
十一、别忘了取消滚动条
scroll-view 在部分平台上会显示滚动条,特别是在 H5 上。为了美观,建议在 css 里隐藏滚动条。
::v-deep ::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
在小程序上,scroll-view 默认没有可见滚动条,不用处理。
十二、总结
这个方案的核心就是把“整页滚动”改成“scroll-view 滚动”,从而统一监听滚动事件。导航栏用固定定位,样式切换只靠一个布尔值。代码量很少,效果也达到了产品要求。如果你正好也在 uni-app 里做自定义导航栏渐变,直接复制上面的代码试试。
当然还有更复杂的场景,比如导航栏透明度渐变、跟随滚动逐渐变形,甚至折叠成顶部胶囊按钮,那些就要继续写更多逻辑了。不过掌握了这个最基础的事件联动,后面扩展都非常顺手。

