ARTICLE · 1318296
uni-app 滚动时导航栏变色:onPageScroll 实战(附完整代码和踩坑记录)
引言
uni-app: 一个用 Vue 语法写跨端应用的框架,一套代码可以编译到微信小程序、H5、App 等多个端。本文用它演示页面级原生滚动的监听与配色派生。
效果

上图是同一个页面往下滚的过程:
没滚动:导航栏底色 #1969f0,与头图的纯蓝段完全同色 —— 顶栏和头图连成一整片,画面上只看得见白色的标题和返回箭头;
一滚动:导航栏在 0.2s 内变白,标题和返回箭头同时转深色(#2c2c2a);头图往上走,浅色内容顶到导航栏底下,白底一路连下去;
滚回顶部:又变回蓝底白字。
注意这里不是「按滚动距离做透明度渐变」,而是「一滚动就整条切换」,判据只有 scrollTop > 0 一个条件。
效果图里导航栏下面是 30rpx 处的一块卡片,这块卡片是有意留的,不是装饰。 白条出现的那一刻,它正好接在下面,白条下沿才不会露出硬边;去掉它、只留一条纯渐变,下沿会立刻出现一道分界。 量测数据见第五节第 1 条。
正文
01
—
介绍
1、这套交互解决什么问题
先划一下适用范围:不是所有详情页都有头图。很多详情页一进来就是标题加信息卡片,这种页面根本用不上本文的效果。它真正适用的,是「顶部有一条视觉带」的页面 —— 商品详情的主图、个人主页的背景图、活动页的 Banner,或者像本文示例这样只有一段品牌色渐变。
下文把这条视觉带统称「头图」。它有几个共同点:占满屏宽、颜色偏深、第一屏就该完整露出来。
如果导航栏始终是一条白底横条压在最上面,会带来两个问题:
所以主流做法是让导航栏先隐身后现身:
未滚动:导航栏底色与头图顶部同色(或干脆透明),视觉上「不存在」;
滚动后:头图已经滚走了,导航栏转成白色实底 + 深色标题,保证在任何内容上都读得清。
微信小程序与 uni-app 的原生导航栏都是固定的一条、改不了样式,所以这套一般配自定义导航栏做(本文示例就是)。
2、核心思路:渐变交给页面,配色交给一个纯函数
实现上只做两个决定,剩下都是顺理成章。
决定一:渐变铺在页面根容器上,导航栏涂成与纯蓝段同色。
导航栏如果始终白底,头图就被切掉一截;所以静止时得让它「消失」。做法很省事:让导航栏底色等于头图顶部那段纯蓝。纯蓝段 180rpx,比导航栏总高 128rpx 还高,两者重叠后完全看不出边界 —— 不用 transparent,也不用把背景图搬到导航栏上。
而且这个色值只有一处:NAV_BAR_TOP_COLOR 直接取渐变起点色,改渐变它跟着变,不会出现「导航栏一种蓝、头图另一种蓝」的接缝。
也见过写成 transparent 的,让头图整个从导航栏底下透出来。静止时两种写法看起来一样, 差别在滚动之后:透明方案没有「底色」这个概念,标题会短暂压在半透明的浅色上; 涂同色方案一滚就切白,标题始终落在实底上。本文用后者。
决定二:配色由一个纯函数派生。
resolveNavBarColor(scrollTop) → { background, dark }页面里只做一件事:把滚动位置喂进去,把结果贴到导航栏上。这样判据、色值都只写一处,将来要改成「滚过 100px 才变色」也只动这个函数。
3、和 Taro 那篇的关系
这个效果同系列之前用 Taro 实现过:《Taro 滚动时导航栏变色:usePageScroll 实战》(Taro 滚动时导航栏变色:usePageScroll 实战(附完整代码和踩坑记录));这次用 uni-app 把同一套交互重做一遍。两篇解决的是同一类问题,但落脚点不同,顺手做个对照:
usePageScroll@tarojs/taro) | onPageScroll@dcloudio/uni-app) | |
ScrollDetaile.scrollTop | e.scrollTop | |
transparentwhite | #1969f0white | |
scrollTop > 0 |
两篇的滚动监听写法几乎一样,都是「页面原生滚动 → 回调拿 scrollTop」;换了框架,变的只是 import 来源。
不一样的是头图该怎么处理:
Taro 那篇的头图是一张照片,导航栏做成 transparent 最省事 —— 照片本身就是背景,透出来最自然,阈值也能按手感随便挑。
本篇的头图是一条纯色渐变,顶部有 180rpx 的纯蓝,比导航栏还高。于是直接涂成同一个蓝更简单:不用透明、不用把渐变搬到导航栏上,静止时它自己就「不存在」了。
代价是判据只能取 scrollTop > 0:纯蓝段只比导航栏高 26px,阈值本来就给不出空间;而且这个判据本身会在白条下沿留一道边 —— 实际页面里看不见它,是因为导航栏下面紧接着就是正文,取舍见第五节第 1 条。
02
—
准备工作
1、项目环境
uni-app 3.x(Vue 3)项目,HBuilderX 建的、或者 CLI 拉的都行,不依赖任何第三方库。
示例是在 vue 3.4 + @dcloudio/uni-app 3.0.0-4020420240722002 + TypeScript 4.7 这个组合下写的 —— 就是 uni-app 3 的常规环境,不用为了这个效果升降级。
2、路由与自定义导航栏
pages.json 里注册页面,并且必须加 "navigationStyle": "custom"。少了这一句,系统导航栏会和你的自定义导航栏叠在一起,顶上凭空多出一条:
{"path": "pages/detail/index","style": {"navigationBarTitleText": "详情","navigationStyle": "custom"}}
开了 custom 之后 navigationBarTitleText 其实不生效了(标题由你自己的 .nav-title 渲染),留着只是让路由配置读起来完整,删掉也不影响。关键是页面里那个标题要和这里写的一致,不然改起来容易漏。
03
—
使用步骤
1、页面顶部铺一条渐变
先定义渐变的两个关键点位。这里用 rpx(uni-app 的响应式单位,750rpx = 屏幕宽),因为它要跟着屏幕宽度缩放:
/* 页面顶部渐变:纯蓝段 + 一段渐隐 */.page {/* 纯蓝段结束位置(rpx)—— 必须 ≥ 导航栏总高,原因见第五节第 1 条 */$fade-start: 180;/* 渐隐终点(rpx) */$fade-end: 590;min-height: 100vh;background-color: #f5fbff;background-image: linear-gradient(to bottom,#1969f0 0rpx,#1969f0 #{$fade-start}rpx,#f5fbff #{$fade-end}rpx);background-repeat: no-repeat;}
这一段的意思是:从顶部到 180rpx 是纯色,180rpx 到 590rpx 之间渐变到近乎白,之后就是页面底色。
先说清楚一件事:这段 SCSS 只是把两个点位讲明白。实际接入时它会变成 pageGradient.ts 里的 PAGE_TOP_GRADIENT 常量(第四节),因为导航栏的配色要跟这条渐变对齐,颜色必须进 JS 才派生得出来。
为什么不直接 to bottom, #1969f0, #f5fbff 一路渐变到底?因为顶部这一整段是白字标题的活动区,必须有一段纯色兜底,否则白字落在渐隐的浅色区域就糊了。这段纯蓝顺便还是「导航栏总高」的下限 —— 它必须 ≥ 状态栏 + 导航栏,两者才能同色无缝,见第五节第 1 条。
两句 background-* 分开写是有意的:只写 background: linear-gradient(...) 会把底色重置成透明, 渐变范围之外(background-repeat: no-repeat 的空白区)就没有底色了。
2、把配色收敛成一个纯函数
/*** 页面顶部渐变 & 由它派生出的导航栏配色*//** 纯蓝段结束位置(rpx)—— 必须 ≥ 导航栏总高,否则静止时导航栏底边就会跳色 */export const FADE_START_RPX = 180;/** 渐隐终点(rpx) */export const FADE_END_RPX = 590;/** 渐变起点色 */const COLOR_FROM = '#1969f0';/** 页面顶部渐变,挂在根容器上随内容滚动 */export const PAGE_TOP_GRADIENT =`linear-gradient(to bottom, ${COLOR_FROM} 0rpx, ${COLOR_FROM} ${FADE_START_RPX}rpx, #f5fbff ${FADE_END_RPX}rpx)`;/** 静止在顶部(未滚动)时的导航栏底色:**与纯蓝段同色** */export const NAV_BAR_TOP_COLOR = COLOR_FROM;/** 页面一旦滚动后的导航栏底色 */export const NAV_BAR_SOLID_COLOR = '#ffffff';/** 导航栏配色 */export interface NavBarColor {/** 导航栏底色 */background: string;/** 标题是否使用深色 */dark: boolean;}/*** 计算指定滚动位置下的导航栏配色** @param scrollTop 页面滚动距离(px),来自 onPageScroll* @returns 导航栏底色 + 是否深色文字*/export function resolveNavBarColor(scrollTop: number): NavBarColor {const solid = scrollTop > 0;return {background: solid ? NAV_BAR_SOLID_COLOR : NAV_BAR_TOP_COLOR,dark: solid,};}
这里刻意把 dark 也一起返回:导航栏底色变了,标题也得跟着换色,否则白底上顶着白字、一样看不见。把「这一组配色」当成一个整体返回,页面侧就不会只改一半。
另外注意 NAV_BAR_TOP_COLOR 没有另写一个色值,而是直接引用 COLOR_FROM。这一点比看着重要:导航栏和头图静止时之所以无缝,全靠两个色值严格相等,分开写两份,迟早会改歪其中一份。
3、用 onPageScroll 把滚动位置喂进去
模板部分,导航栏与内容平级(这一点很关键,原因见第五节第 5 条):
<template><viewclass="page":style="{ backgroundImage: PAGE_TOP_GRADIENT }"><!-- 自定义导航栏(与内容平级,不要塞进正文里) --><viewclass="nav-bar":style="{ paddingTop: statusBarHeight + 'px', backgroundColor: navBackground }"><viewclass="nav-content":style="{ height: navBarHeight + 'px', paddingLeft: navRightReserve + 'px', paddingRight: navRightReserve + 'px' }"><viewclass="nav-back" @click="onBack"><uni-iconstype="back"size="22":color="navDark ? '#2c2c2a' : '#ffffff'"></uni-icons></view><viewclass="nav-title":class="{ 'is-solid': navDark }">详情</view></view></view><!-- 等高占位:正文从导航栏下面开始,而不是被它压住 --><viewclass="nav-placeholder":style="{ height: statusBarHeight + navBarHeight + 'px' }"></view><!-- 正文(示例只放一块空卡片,用来接住白条) --><viewclass="content"><viewclass="card"></view></view></view></template>
返回键 .nav-back 用的是绝对定位(left: 16rpx,宽 64rpx),没有参与 flex。这一点是有意的:.nav-title 靠 flex: 1 + text-align: center 居中,如果返回键也当 flex 成员占一格,标题就会被挤得往右偏。绝对定位的图标浮在上面,不改变标题的中轴。
顺带说,图标的颜色不能写在 CSS 里 —— uni-icons 是字体图标,颜色得走 :color prop,也就是必须跟着 navDark 一起变:
<uni-icons type="back" size="22" :color="navDark ? '#2c2c2a' : '#ffffff'"></uni-icons>逻辑部分两个钩子:onPageScroll 管配色,onBack 管返回。
import { ref } from 'vue';import { onLoad, onPageScroll } from '@dcloudio/uni-app';import {PAGE_TOP_GRADIENT,NAV_BAR_TOP_COLOR,resolveNavBarColor,} from '@/utils/pageGradient';/* 自定义导航栏尺寸 */const statusBarHeight = ref<number>(20);const navBarHeight = ref<number>(44);/* 右侧为胶囊按钮预留的宽度(左右各留一份,标题才是真正居中的;左侧返回键是绝对定位,不占这一格) */const navRightReserve = ref<number>(0);const navBackground = ref<string>(NAV_BAR_TOP_COLOR);const navDark = ref<boolean>(false);/* 页面原生滚动 → 派生导航栏配色 */onPageScroll((e) => {const color = resolveNavBarColor(e.scrollTop);if (color.background !== navBackground.value) navBackground.value = color.background;if (color.dark !== navDark.value) navDark.value = color.dark;});/* 返回:有上一页就退回去;从分享/扫码直接进来的详情页没有上一页,退回列表 */const onBack = () => {const pages = getCurrentPages();if (pages.length > 1) {uni.navigateBack();} else {uni.reLaunch({ url: '/pages/index/index' }); // 换成你项目的列表页}};
两行赋值前面的 if 判断不是洁癖,是有用的:onPageScroll 一秒能触发几十次,如果每次都无脑赋值,Vue 会按「新值 ≠ 旧值」来决定要不要更新 —— 这里先比一次,风格切换只在真正跨越边界的那一帧发生,省掉大量无意义的重渲染。
尺寸都在 onLoad 里取,导航栏高度不写死,用胶囊按钮位置算:
onLoad(() => {const sys = uni.getSystemInfoSync();/* 状态栏高度:不要写死,刘海屏与安卓差异很大 */statusBarHeight.value = sys.statusBarHeight ?? 20;// #ifdef MP-WEIXINconst menuBtn = uni.getMenuButtonBoundingClientRect();// 胶囊上下留白对称 → 导航栏高 = (胶囊顶 - 状态栏高) * 2 + 胶囊高navBarHeight.value = (menuBtn.top - statusBarHeight.value) * 2 + menuBtn.height;// #endif// #ifndef MP-WEIXINnavBarHeight.value = 44;// #endif});
04
—
完整示例
整个效果就两个文件:配色派生收在 src/utils/pageGradient.ts,页面本身在 src/pages/detail/index.vue。
1、src/utils/pageGradient.ts(配色派生)
/*** 页面顶部渐变 & 派生出的导航栏配色*//** 纯蓝段结束位置(rpx)—— 必须 ≥ 导航栏总高,否则静止时导航栏底边就会跳色 */export const FADE_START_RPX = 180;/** 渐隐终点(rpx) */export const FADE_END_RPX = 590;/** 渐变起点色 #1969f0 */const COLOR_FROM = '#1969f0';/** 页面顶部渐变,挂在根容器上随内容滚动 */export const PAGE_TOP_GRADIENT =`linear-gradient(to bottom, ${COLOR_FROM} 0rpx, ${COLOR_FROM} ${FADE_START_RPX}rpx, #f5fbff ${FADE_END_RPX}rpx)`;/** 静止在顶部(未滚动)时的导航栏底色:与头图纯蓝段同色 */export const NAV_BAR_TOP_COLOR = COLOR_FROM;/** 页面一旦滚动后的导航栏底色 */export const NAV_BAR_SOLID_COLOR = '#ffffff';/** 导航栏配色 */export interface NavBarColor {/** 导航栏底色 */background: string;/** 标题是否使用深色 */dark: boolean;}/*** 计算指定滚动位置下的导航栏配色** @param scrollTop 页面滚动距离(px),来自 onPageScroll* @returns 导航栏底色 + 是否深色文字*/export function resolveNavBarColor(scrollTop: number): NavBarColor {const solid = scrollTop > 0;return {background: solid ? NAV_BAR_SOLID_COLOR : NAV_BAR_TOP_COLOR,dark: solid,};}
2、src/pages/detail/index.vue(页面)
<template><viewclass="page":style="{ backgroundImage: PAGE_TOP_GRADIENT }"><!-- 自定义导航栏(与内容平级,不要塞进正文里) --><viewclass="nav-bar":style="{ paddingTop: statusBarHeight + 'px', backgroundColor: navBackground }"><viewclass="nav-content":style="{ height: navBarHeight + 'px', paddingLeft: navRightReserve + 'px', paddingRight: navRightReserve + 'px' }"><viewclass="nav-back" @click="onBack"><uni-iconstype="back"size="22":color="navDark ? '#2c2c2a' : '#ffffff'"></uni-icons></view><viewclass="nav-title":class="{ 'is-solid': navDark }">详情</view></view></view><!-- 等高占位:正文从导航栏下面开始,而不是被它压住 --><viewclass="nav-placeholder":style="{ height: statusBarHeight + navBarHeight + 'px' }"></view><!-- 正文(示例只放一块空卡片) --><viewclass="content"><viewclass="card"></view></view></view></template><scriptsetuplang="ts">import { ref } from 'vue';import { onLoad, onPageScroll } from '@dcloudio/uni-app';import { PAGE_TOP_GRADIENT, NAV_BAR_TOP_COLOR, resolveNavBarColor } from '@/utils/pageGradient';/* 自定义导航栏尺寸 */const statusBarHeight = ref<number>(20);const navBarHeight = ref<number>(44);/* 右侧为胶囊按钮预留的宽度(左右各留一份,标题才真正居中) */const navRightReserve = ref<number>(0);const navBackground = ref<string>(NAV_BAR_TOP_COLOR);const navDark = ref<boolean>(false);/* 页面原生滚动 → 派生导航栏配色 */onPageScroll((e) => {const color = resolveNavBarColor(e.scrollTop);if (color.background !== navBackground.value) navBackground.value = color.background;if (color.dark !== navDark.value) navDark.value = color.dark;});/* 返回:有上一页就退回去;从分享/扫码直接进来的详情页没有上一页,退回列表 */const onBack = () => {const pages = getCurrentPages();if (pages.length > 1) {uni.navigateBack();} else {uni.reLaunch({ url: '/pages/index/index' }); // 换成你项目的列表页}};onLoad(() => {const sys = uni.getSystemInfoSync();/* 状态栏高度:不要写死,刘海屏与安卓差异很大 */statusBarHeight.value = sys.statusBarHeight ?? 20;// #ifdef MP-WEIXIN/* 胶囊上下留白对称:导航栏高 = (胶囊顶 - 状态栏高) * 2 + 胶囊高 */const menuBtn = uni.getMenuButtonBoundingClientRect();navBarHeight.value = (menuBtn.top - statusBarHeight.value) * 2 + menuBtn.height;navRightReserve.value = sys.windowWidth - menuBtn.left;// #endif// #ifndef MP-WEIXINnavBarHeight.value = 44;navRightReserve.value = 0;// #endif});</script><stylelang="scss"scoped>.page {min-height: 100vh;background-color: #f5fbff;background-repeat: no-repeat;}/* 自定义导航栏:静止时与头图纯蓝段同色,一滚动就切白(底色由行内 style 给,这里只是兜底) */.nav-bar {position: fixed;top: 0;left: 0;width: 100%;z-index: 999;background-color: #1969f0;transition: background-color 0.2s;}.nav-placeholder {width: 100%;}.nav-content {position: relative;display: flex;align-items: center;box-sizing: border-box;}/* 返回键:绝对定位,不参与 flex,所以标题仍按整条居中 */.nav-back {position: absolute;left: 16rpx;top: 0;bottom: 0;display: flex;align-items: center;justify-content: center;width: 64rpx;}.nav-title {flex: 1;text-align: center;font-size: 34rpx;font-weight: 600;color: #ffffff;transition: color 0.2s;&.is-solid {color: #2c2c2a;}}/* 正文:示例只放一块空卡片,用来接住白条 */.content {padding: 30rpx;}.card {height: 1200rpx;border-radius: 12rpx;background: linear-gradient(-160deg, #e6f0ff 0%, #ffffff 35%, #ffffff 100%);box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);}</style>
运行方式
文件放置:pageGradient.ts 放进 src/utils/,页面放进 src/pages/detail/index.vue,路由按二·2 配;别名 @ 指向 src。
图标依赖:uni-icons 不是 uni-app 内置组件,来自 uni-ui。HBuilderX 项目直接在插件市场导入到 src/uni_modules/uni-icons;CLI 项目 npm i @dcloudio/uni-ui 后在 pages.json 的 easycom 里加 "^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"。不装的话 <uni-icons> 会解析不到,报「Failed to resolve component」。
预览:H5 端 npm run dev:h5,浏览器里滚一下就生效(最快);小程序端 npm run dev:mp-weixin,再用微信开发者工具打开 dist/dev/mp-weixin。H5 端没有状态栏,导航栏会比小程序端矮一截,属正常现象。
改配色:只改 pageGradient.ts 里的 COLOR_FROM —— 导航栏底色直接引用它(NAV_BAR_TOP_COLOR = COLOR_FROM),所以渐变和导航栏永远同色,不会改漏一处。滚动后的白底在 NAV_BAR_SOLID_COLOR。
改渐变长度:只改 FADE_START_RPX(纯蓝段结束)和 FADE_END_RPX(渐隐终点)。前者必须 ≥ 导航栏总高(statusBarHeight + navBarHeight),否则静止时导航栏底边就会跳色。
想换判据(比如「滚过 50px 才变白」):改 resolveNavBarColor 里那句 scrollTop > 0。但那会引入白条下沿的硬边,先看第五节第 1 条再动手。
如果你的项目里已经有一份同名工具文件,覆盖前先确认导出签名一致:resolveNavBarColor 只收一个 scrollTop 参数。签名对不上时传进去的是 undefined,比较恒为 false,导航栏永远不变色,而且不报错。
05
—
可能出现的问题(踩坑记录)
1、白条下面是什么,决定了它下沿有没有一条边
先说结论:静止时导航栏要和头图同色,判据取 scrollTop > 0(一滚就白)—— 这两条是照着手感定的。真正决定「看不看得见一条边」的不是阈值,而是白条出现的那一刻,它下面压着什么。
同一份代码,只换导航栏下面那一块,量出来差十几倍:
| 17 | ||
| 230 |
静止时这两行都是 0 —— 导航栏本身就是纯蓝,和下面的纯蓝段同色,量不出边界。差别只在切换之后。
原因很直白:渐变前 180rpx 是纯色,scrollTop 才 1px 的时候,导航栏下沿外面那一行还是 25,105,240 的纯蓝,白条已经贴上去了;255 对 25,就是一条划痕。把阈值往后挪一点点也没用 —— 纯蓝段只比导航栏高 52rpx(375 视口下 26px),挪到头,下沿也还卡在纯蓝的最后一像素上。
所以正常页面里看不见这道边,不是代码里做了什么处理,而是导航栏下面 30rpx 就是第一块内容,白条一出现,下面已经不是饱和蓝了。示例里那块空卡片留着,作用就是这个 —— 它不是装饰。
如果确实要「纯渐变 + 一滚就白」,这道边消不掉,只能选一边:
保观感:让阈值后移,等头图淡下去再变白。算下来是 195.5rpx —— 导航栏下沿要落在渐变 35% 处,180 + (590 − 180) × 0.35 = 323.5rpx,减去导航栏的 128rpx,得 s = 195.5rpx;那时栏下第一行是 102,156,245,色差降到 153。代价是要滚 98px 才变色,手滑一下没反应,和「一滚就白」的手感不是一回事。
保手感:判据留在 scrollTop > 0,认了这道边。实际页面在正文顶上来之前那 30rpx 里也是这样,只是没人会停在那儿看。
这两条我都在浏览器里量过,数据在上面那张表里。
顺带记一下怎么量:截图后沿竖直方向逐行取色,算相邻行的最大色差,别目测 —— ±1 像素的东西肉眼判断非常不靠谱。我一开始以为静止时导航栏和头图之间有渲染接缝,量完才发现 y=100..150 整片同色,边只会在「白条出现」之后才有。
2、页面有正文时,第一行会被导航栏盖住
position: fixed 的元素脱离文档流,正文从页面顶部开始排,于是被导航栏压住。
示例里那块卡片如果直接写在页面顶部,第一行就会被导航栏盖掉;必须自己让开。推荐用等高占位元素,并且占位高度和导航栏内边距用同一个表达式:
<viewclass="nav-placeholder":style="{ height: statusBarHeight + navBarHeight + 'px' }"></view>
不要改成在 CSS 里给正文写 padding-top: 88rpx —— 一旦有人调了导航栏高度,两处就对不上了。占位元素的好处是可以直接吃 JS 变量。
顺带一提:style 里两个数字相加,单位只能写在最后(statusBarHeight + navBarHeight + 'px')。写成 statusBarHeight + 'px' + navBarHeight + 'px' 拼出来是 "20px44px",整条样式直接失效 —— 这个坑很隐蔽,因为页面看起来「只是没生效」,不报错。
3、状态栏高度写死 20px,换个机型就错位
20px 只对部分 iPhone 成立。安卓普遍更高,刘海屏 / 灵动岛机型也不一样。
两个取值来源:
状态栏高度:uni.getSystemInfoSync().statusBarHeight;
导航栏高度:别用固定 44px,用胶囊按钮反推:
const menuBtn = uni.getMenuButtonBoundingClientRect();navBarHeight = (menuBtn.top - statusBarHeight) * 2 + menuBtn.height;
原理是「胶囊按钮距状态栏的间隙,和距导航栏底部的间隙相等」,所以导航栏高 = 间隙 × 2 + 胶囊高。这样算出来的高度在微信各机型上都对齐。
注意 getMenuButtonBoundingClientRect 只在微信小程序端可用,H5 端要走条件编译(本文示例里用 #ifdef MP-WEIXIN 处理了),否则 H5 直接报错。
Taro 那篇用的是 Taro.getSystemInfoSync(),名字不同、拿到的字段一样;胶囊位置则是 Taro.getMenuButtonBoundingClientRect()。换框架时这两个 API 一一对应。
4、底色过渡了、标题没过渡,切换那一下是硬跳
.nav-bar 上写了 transition: background-color 0.2s,.nav-title 上的 transition: color 0.2s 很容易漏。漏了会怎样:底色用 0.2s 慢慢变白,标题却在同一帧由 #ffffff 直接跳到 #2c2c2a,中间那几帧是「白底浅字」和「蓝底黑字」都不像的状态,观感比不做过渡还差。
两处的时长要一致:
.nav-bar { transition: background-color 0.2s; }.nav-title { transition: color 0.2s; }
顺带一提,写 background-color 比写 all 稳:all 会把 padding、height 这类由行内 style 频繁改的属性也带上,一旦它们有变化,导航栏就开始补间,看起来像在抖。
5、导航栏跟着内容一起滚走了
position: fixed 的定位基准是最近的「包含块」—— 如果祖先元素上有 transform、filter、will-change 这类会创建包含块的属性,fixed 就会改成相对那个祖先定位,于是它跟着内容一起滚。
这个坑在一个不太起眼的地方也会踩到:给页面或外层容器做整体缩放(transform: scale())时,导航栏会被一起卷走(实测 navRect.top = -scrollTop × scale)。
排查方法很直接 —— 在控制台里问一句:
const nav = document.querySelector('.nav-bar');console.log(nav.getBoundingClientRect().top, window.scrollY);
滚动之后 top 变成负数,基本就是包含块被改了。解法是把带 transform 的祖先从导航栏到根容器这条链上剔掉。
6、页面里用了 scroll-view 整页滚动,onPageScroll 不触发
onPageScroll 监听的是页面级原生滚动。如果你为了让「固定头部不跟着滚」而把内容包进 scroll-view,那滚的就是 scroll-view 内部了,onPageScroll 收不到任何回调,导航栏自然一直不变色。
两个选择:
本文这条:整页交给原生滚动,固定头部用 position: fixed,正文用占位元素避让;
或者保留 scroll-view,改听它的 @scroll 事件(e.detail.scrollTop),此时 onPageScroll 删掉。
两种别混用 —— 一个页面里既监听 onPageScroll 又监听 scroll-view 的 @scroll,看起来能跑,实际是两个数据源在抢同一个状态。
7、加了返回键之后:图标不跟着变色,返回还可能点不动
.nav-title 的深色是靠 :class="{ 'is-solid': navDark }" 切出来的,图标没有这个 class 可挂 —— 它必须靠 :color 绑同一个 navDark。只给标题加了样式、图标没动,就会出现「白底 + 白箭头」,等于把返回键藏了。
这一处还有个细节:标题和图标不是同时变的。标题走 CSS transition: color 0.2s,是渐变;图标走 uni-icons 的 :color prop,属性变更不经 CSS 过渡,是瞬时切换。实际跑起来就是这样,0.2s 内的一点点不同步肉眼基本看不出来,不用特意去「修」—— 想让它俩完全一致,只能把标题的 transition 去掉,或者换成自己画的 SVG 图标(用 currentColor 上色,那就能吃 CSS 过渡)。
返回动作也别只写一行 uni.navigateBack():
const pages = getCurrentPages();if(pages.length > 1) {uni.navigateBack();} else {uni.reLaunch({ url: '/pages/index/index' }); // 从分享/扫码直接进来的情况}
从分享卡片、扫码、订阅消息点进来的详情页,页面栈里只有它自己,navigateBack() 会静默失败 —— 不报错,也不动,点起来像按钮坏了。加个 pages.length 判断,没有上一页就退回列表。
结语
这套写法一共就三件事:
渐变挂在页面根容器上,随内容滚动;
导航栏配色由一个纯函数从 scrollTop 派生;
onPageScroll 把滚动位置喂进去。
Taro 那篇与本篇的差别,说到底还是头图性质决定的:照片上白字一直读得清,导航栏做成透明的、阈值按手感挑 50px 都行;本篇是一条品牌色渐变,顶部 180rpx 是纯蓝,导航栏直接涂成同一个蓝最省事,判据也就只能取 scrollTop > 0。两篇真正一样的,是「页面原生滚动 + 一个纯函数派生配色」这条主线。
最容易忽略的不是逻辑,是三个「看起来无关」的细节:白条下面压着什么,决定了它下沿有没有那道边;状态栏高度写死会错位;祖先带 transform 会让 fixed 失效。这三条都属于「不报错但难看」,出了问题先去量数值,别凭感觉调。
欢迎大家关注我的CSDN账号Web - Nancy哦!每周一早八点固定更新一篇文章,其余时间不定时更新哦!















































































