很多做跨端项目的朋友应该都有同感uni-app 里列表页的头部渐变是一个出现频率极高的需求。尤其首页、详情页上面通常是一张大图或者品牌色背景顶部导航栏一开始要透出背景等用户往下滚一段距离后再慢慢变成纯色底、压住内容。这类“下拉渐变头部”准确的叫法是滚动渐变头部配合下拉刷新的场景时联动更自然在原生小程序里写起来是一套东西在 uni-app 里又要兼顾 App、H5、小程序三个平台如果不提前把方案定好写出来的代码很容易出现“一个端能用另一个端样式崩掉”的情况。这篇文章就把我实际做过的方案直接拆开来分享从渐变算法怎么算到组件怎么写再到滚动监听、下拉刷新联动、真机踩坑全部走一遍。目标是你看完就能拿过去改改用不需要再到处拼代码。内容按实操顺序展开代码可以直接复制到项目里跑参数也在代码边上做了说明。1. 效果拆解与实现方案怎么选1.1 先搞清楚头部的核心组成先把问题拆开。日常开发里说的“渐变头部”大多数是指下面这个效果页面处于顶部时导航栏完全透明背景图或背景色可以直接从导航栏下面透出来随着页面往上滚动导航栏的背景色从透明逐渐过渡到不透明最后变成实色同时导航栏里的文字、图标颜色也要跟着变不然实色背景出现后文字还在“隐形”状态会非常丑。有的项目还会在这个基础上加上阴影和下边框让导航栏从背景中分离出来。这里就涉及两个需要控制的核心对象第一是导航栏容器本身的背景色准确说是带 alpha 通道的颜色第二是导航栏内容标题文字、返回箭头、右侧按钮的颜色。这两者的变化不是孤立的必须联动调整。否则你只把背景色加深了文字却还是白色等滚到临界点之后白底白字用户直接看不清。另外标题里写的“下拉渐变头部”还有一种理解是页面可以下拉刷新用户往下拉的时候头部背景色或背景图会跟着下拉距离产生一个渐变的反馈。这个需求也有但本质和滚动渐变的计算逻辑是一样的只是把“滚动距离”换成了“下拉偏移距离”所以我会把两种场景都覆盖到共用一套渐变算法。1.2 两条实现路线对比原生导航栏 vs 自定义导航栏方案怎么选直接决定了后面所有代码怎么写。uni-app 里实现导航栏渐变一般有两条路第一条是用原生导航栏配合onPageScroll拿到滚动距离然后调用uni.setNavigationBarColor修改导航栏背景色和文字颜色。这个方案的优势是页面顶部不用自己写导航栏组件也不用处理状态栏高度代码量少。但它的问题也很明显setNavigationBarColor的动画是瞬变的没法做到逐帧平滑过渡你需要在滚动回调里按一定步长不停调用视觉上会有明显“卡顿感”而且在小程序端、App 端的表现不一致部分平台尤其是某些小程序环境对接口的调用频次和时机还有限制。第二条是把页面设置成自定义导航栏也就是在pages.json里给这个页面配navigationStyle: custom然后自己在页面模板里写一个导航栏组件通过绑定样式的方式每帧实时计算渐变后的背景色和文字颜色。这个方案代码量确实大一些需要对状态栏高度做适配但换来的是完全可控的效果渐变可以是平滑的阴影可以自定义文字颜色可以中途切换甚至还能在渐变过程中让标题栏做位置偏移的动画。对于“拿来即用”的组件化需求来说第二条路显然更合适。两条路线的对比我整理了一张表方案实现成本渐变平滑度跨端表现可扩展性适用场景原生导航栏 setNavigationBarColor低一般有跳变感部分端不一致低只能改颜色和文字简单页面、对视觉要求不高自定义导航栏 样式绑定中偏高平滑逐帧可控一致性好高可做阴影、透明、偏移动画首页、详情页、品牌感强的页面我实际做过的项目里两种方案都用过。早期为了赶进度用原生导航栏结果在真机调试时发现 Android 端颜色过渡跟预期差很多后来改成自定义导航栏虽然前期多花一两个小时但后续接新页面基本就是复制粘贴改参数省心很多。这篇文章后面的内容就按自定义导航栏方案展开。2. 渐变算法透明度和颜色怎么算才自然2.1 滚动距离与透明度的映射关系渐变的核心就是一个公式透明度 当前滚动距离 / 临界距离然后把这个结果限制在 0 到 1 之间。用代码写出来就是const threshold 160; // 临界距离单位px意思是滚过160px后导航栏完全变成实色 const ratio Math.min(scrollTop / threshold, 1); const opacity ratio; // 背景色透明度这个公式不难难在临界距离怎么取。如果阈值太小用户刚滚一点点导航栏就“唰”一下变实色渐变过程太短、太突兀如果阈值太大滚动好多屏导航栏还是透明的背景图区域已经滚出视野了透明导航栏盖在列表内容上会很难看。我自己的经验是有背景图的页面阈值取“背景视觉高度”的 1/2 到 2/3比如背景图区域固定 160px 高那阈值 100~120px 比较合适没有背景图、单纯是白色导航栏渐变的页面阈值可以小一点80~120px 即可。这里有个视觉细节值得注意人眼对透明度变化的感觉不是线性的从 0 到 0.5 的变化感知很强从 0.5 到 1 的变化感知较弱。如果直接用线性公式整体渐变的节奏会让人觉得“前面变化太快后面好像停住了”。解决办法是引入缓动函数把 ratio 先做一次曲线映射再赋给透明度// easeOutCubic渐变先快后慢视觉上更柔和 const easedRatio 1 - Math.pow(1 - ratio, 3); const opacity easedRatio;easeOutCubic的效果是滚动刚发生时透明度提升较快让用户很快感知到导航栏在变化接近实色时速度放缓避免在临界点附近出现“突然完全变实”的生硬感。这个缓动函数在 CSS 的 transition 里也很常见属于标准的cubic-bezier(0.215, 0.61, 0.355, 1)即 easeOut 曲线。2.2 颜色插值从透明到实色不是简单的一层遮罩导航栏背景从一个带透明度的颜色过渡到另一个实色很多同学第一反应是背景色固定用rgba(255,255,255,0)到rgba(255,255,255,1)只改 alpha 值就够了。这种做法在“透明色和实色同色系”时确实可以但一旦涉及跨色系渐变比如从透明状态下的深色背景渐变成白色导航栏背景颜色本身也要跟着过渡。举个实际场景页面顶部是一张大图或深色主视觉区导航栏在顶部时需要透出深色背景所以标题文字要显示为白色往下滚到列表区后导航栏背景变成白色此时标题文字如果还是白色就完全看不见了需要变成深色。如果只改背景色的 alpha文字颜色突然从白变黑会非常突兀。正确的做法是让文字颜色也从白色平滑过渡到深色即对颜色做插值。颜色插值就是把颜色的 RGB 三个通道分别做线性插值。我封装了一个工具函数传入起始颜色、结束颜色、插值比例返回插值后的颜色字符串function mixColor(color1, color2, ratio) { // 把 #ffffff 或 #000000 转成 [r, g, b] const parse (hex) { const value hex.replace(#, ); return [ parseInt(value.substring(0, 2), 16), parseInt(value.substring(2, 4), 16), parseInt(value.substring(4, 6), 16) ]; }; const c1 parse(color1); const c2 parse(color2); const r Math.round(c1[0] (c2[0] - c1[0]) * ratio); const g Math.round(c1[1] (c2[1] - c1[1]) * ratio); const b Math.round(c1[2] (c2[2] - c1[2]) * ratio); return rgb(${r}, ${g}, ${b}); }导航栏背景色和文字颜色都基于同一个easedRatio做插值这样无论滚动到哪个位置背景和文字的对比度都能保持一个合理的关系不会出现背景已经变白、文字还是浅灰的情况。项目里我习惯把“起始文字色”“结束文字色”“起始背景色”“结束背景色”全部定义成组件的 props不同页面直接传入不同配置即可。2.3 几个关键参数怎么调参数名和默认值想清楚后面复用时能省很多事。我的组件里核心参数如下参数名默认值作用bgStart透明色导航栏滚动前的背景色bgEnd#ffffff导航栏滚动到位后的背景色textStart#ffffff滚动前导航栏文字颜色textEnd#222222滚动到位后的导航栏文字颜色threshold120渐变临界滚动距离单位pxshadowOpacity0滚动到位后导航栏底部阴影的透明度关于阴影这是个经常被遗忘的细节。导航栏从透明变成实色之后如果下面没有阴影导航栏和列表内容之间没有一个清晰的视觉分隔用户滑动时会觉得内容“钻到”导航栏下面很乱。所以在滚动到位后应该给导航栏加一个轻微的下阴影或一条分割线。阴影透明度同样用easedRatio来控制从零渐变到目标值这样视觉衔接才自然。3. 完整实现一个拿来自用的渐变头部组件3.1 目录结构与组件设计实操之前先规划组件结构。我的做法是把渐变导航栏封装成一个独立组件生命周期和滚动监听都内置在组件里页面只需要引入组件、把背景内容放进去、传几个颜色参数不需要关心渐变逻辑。组件内部结构分三层背景层存放页面顶部的背景图、主视觉内容可通过插槽让页面自定义导航层真正的导航栏包含返回按钮、标题、右侧操作区是渐变效果的作用对象状态栏占位层根据设备状态栏高度动态撑起高度避免内容顶到系统状态栏目录结构如下components/ gradient-nav/ gradient-nav.vue // 导航栏组件 mix-color.js // 颜色插值工具组件里接收两个最重要的 propscrollTop当前滚动距离和prepared刷新准备状态后面讲下拉刷新联动时会用到。为什么要让父页面传scrollTop而不是组件自己监听滚动因为在 uni-app 里页面级滚动和 scroll-view 内部滚动的获取方式不一样组件内部很难统一处理把监听逻辑放在页面里、把计算结果传给组件反而更清晰。3.2 页面配置与模板代码先设置页面为自定义导航模式。在pages.json中{ path: pages/index/index, style: { navigationStyle: custom, enablePullDownRefresh: true } }navigationStyle设置为 custom 后页面顶部不再显示系统导航栏状态栏区域也需要自己处理。enablePullDownRefresh开启下拉刷新这个配置在 App 端和小程序端都有作用后面联动会用到。组件的模板结构大致如下template view classgradient-nav :style{ backgroundColor: mixBg, color: mixText } !-- 状态栏占位 -- view :style{ height: statusBarHeight px }/view !-- 导航栏主体 -- view classnav-bar view classnav-left taphandleBack text :style{ color: mixText }返回/text /view view classnav-title :style{ color: mixText, opacity: titleOpacity } {{ title }} /view view classnav-right slot nameright/slot /view /view /view /template这里有几个细节需要说明。首先是状态栏高度很多人直接用uni.getSystemInfoSync().statusBarHeight获取这个接口在 App 端和小程序端都能拿到数值单位是 px。其次是导航栏本身的高度小程序里一般默认 44pxiOS 和 Android 略有差异但 44px 是一个很稳的默认值如果你项目里需要更高的导航栏可以做成 prop。最后是标题的显示方式我建议标题文字有一个独立的 opacity 控制滚动距离较小时标题淡出接近临界距离时才显示出来。因为标题一直显示在透明导航栏上叠在背景图上容易干扰主视觉淡入淡出反而更有层次感。3.3 滚动监听与数据联动页面模板里要有一个可滚动的容器。两种常用方式一种是使用页面原生滚动也就是直接让页面 body 滚动另一种是使用 scroll-view 包裹内容。两者获取滚动距离的方式不同页面原生滚动时在页面生命周期函数里这样监听onPageScroll(e) { this.scrollTop e.scrollTop; }使用 scroll-view 时在 scroll-view 上绑定scrollscroll-view scroll-y scrollhandleScroll !-- 页面内容 -- /scroll-viewhandleScroll(e) { const scrollTop e.detail.scrollTop; this.scrollTop scrollTop; }这两种方式的区别值得说清楚。页面原生滚动不需要额外写 scroll-view滚动性能和手感最好但在 App 端尤其是 vue 页面滚动事件触发的时机和频率在某些 Android 机型上会有些微妙差异。scroll-view 的优点是滚动容器可控适合页面内还有独立滚动区域的情况缺点是多包了一层在嵌套较深时可能出现滚动穿透问题。我的建议是尽量用页面原生滚动 onPageScroll少用 scroll-view。对于绝大多数列表页这个方案最简单、兼容性最好代码也最少。拿到scrollTop后传给组件gradient-nav :scrollTopscrollTop title首页 /组件内部通过 computed 计算背景色、文字色、阴影透明度等Vue 的响应式系统会自动刷新。这里要注意的是滚动事件触发的频率很高computed 里尽量不要做太复杂的计算颜色插值本身是纯数学运算性能没问题但如果你在 computed 里做大量数组处理或 DOM 查询页面滚动会卡。关于滚动频率实际性能优化还有一个更稳的做法使用requestAnimationFrame合并渲染。部分低端 Android 机型在快速滚动时onPageScroll可能一次触发多次每次触发都重新渲染组件容易造成掉帧。可以加一个简单的节流onPageScroll(e) { const current e.scrollTop; if (this.ticking) return; this.ticking true; this.scrollTop current; this.$nextTick(() { this.ticking false; }); }这个节流方案能保证同一帧内最多只更新一次scrollTop实际体验会顺畅很多。它本质上是把多次同步赋值合并到一次渲染周期里不会牺牲渐变连续感。3.4 下拉刷新与渐变头部的联动enablePullDownRefresh: true打开后用户下拉时页面顶部会有一段黑色背景的 loading 动画取决于平台但这段动画样式比较丑而且和我们的自定义导航栏没有视觉关联。更好的做法是自定义下拉刷新样式或者控制刷新动画颜色。在 uni-app 里通过uni.startPullDownRefresh可以在代码里触发下拉刷新但我们这里更关心的是在下拉过程中头部有什么反馈。一个常见做法是利用onPullDownRefresh生命周期函数配合一个“刷新中转态”让导航栏在用户下拉时先渐变成一个“刷新中”的颜色刷新结束后再恢复onPullDownRefresh() { this.refreshing true; // 模拟刷新请求 setTimeout(() { this.refreshing false; uni.stopPullDownRefresh(); }, 1500); }组件里把refreshing作为 prop 接收后可以让背景色在“正常透明渐变”和“刷新中的高亮色”之间做一个额外插值。不过要注意的是onPullDownRefresh触发的时机是下拉到规定距离后松手而不是下拉过程中。如果你需要在下拉过程中就做渐变动画比如背景图跟着下拉位移、变暗那就不能依赖这个生命周期需要自行监听触摸事件或者使用某些平台提供的自定义刷新能力这里不展开因为整体复杂度会高出一个量级。就大部分业务需求而言把刷新状态接入导航栏渐变做一个“刷新后导航栏短暂变色”的效果就够了用户下拉松手后导航栏背景快速过渡到刷新色图标旋转数据加载完再恢复原状。这个操作的实现成本不高但对整体体验的提升很明显。组件内可以使用透传插槽把刷新图标放在导航栏右侧view classnav-right slot nameright view v-ifrefreshing classrefresh-icon/view /slot /view刷新图标的颜色同样用mixText来控制保证和导航栏文字颜色始终一致。4. 常见问题与排查技巧4.1 页面没法滚动监听这是被问过最多的问题。最常见的现象是写了onPageScroll但断点打不上、滚动距离始终是 0或者 App 端有效小程序端无效。排查方向如下先确认页面是不是自定义导航栏模式如果还是原生导航栏onPageScroll也能触发但要确认页面是页面级滚动而不是 scroll-view 内部滚动。其次onPageScroll需要在页面的options或script里直接声明不能写在自定义方法里也不能放在组件中除非你自己封装了 mixin。第三确认页面没有设置disableScroll: true这个配置会直接关掉页面滚动onPageScroll自然不触发。还有一个 App 端需要注意的点onPageScroll在 app-vue 页面中表现正常但在 app-nvue 页面里触发机制完全不同nvue 页面要用scroll事件或者 bindingX 来处理两种模式的代码不能直接复制通用。如果你的项目是 nvue 和 vue 混用组件需要做平台差异适配或者干脆把该页面统一成 vue 页面。4.2 状态栏与安全区适配自定义导航栏之后状态栏高度适配是绕不开的问题。不同机型的状态栏高度不一样iPhone 的刘海屏约 44px安卓常见 24px 到 48px还有挖孔屏、折叠屏等奇异尺寸。代码里用uni.getSystemInfoSync()获取状态栏高度并作为动态样式应用到占位层即可这个方案实测覆盖绝大多数设备。但是有一个坑在某些小程序平台uni.getSystemInfoSync()在页面刚加载时拿到的状态栏高度可能是 0 或者在真机上才准确。建议放到onLoad里获取并且用try包一下避免在部分旧型号上接口报错导致整个页面渲染异常。另外如果页面里还用了cover-view或者原生组件如地图、视频这些原生组件会覆盖在普通 view 层之上导致导航栏的背景色被原生组件“穿透”或遮挡。这类场景下要么避免在导航栏附近放原生组件要么使用cover-view来重写导航栏没有第三种捷径。4.3 滚动时颜色跳变或卡顿颜色跳变通常不是插值算法的问题而是数据更新频率和渲染频率不一致导致的。我踩过一次很典型的坑在handleScroll里直接循环调用this.navColor rgb(...)因为scrollTop有时会连续相同用户手指停顿但数据还是被重复赋值Vue 会认为数据没变化而不触发渲染这没问题真正的问题在于滚动距离出现反向变化回弹时透明度突然从 1 跳回 0视觉上就是“颜色跳变”。回弹通常发生在下拉刷新和滚动到顶部再继续上拉时。iOS 上的橡皮筋效果会导致scrollTop短暂变成负数此时你的插值公式里scrollTop / threshold为负透明度取到 0如果页面已经滚到 160px 以上再瞬间回到顶部颜色会骤变。解决方法是把透明度计算统一经过 clamp 处理让所有负数都归零、大于 1 都归 1同时对“滚动方向正在回弹”的情况不做颜色更新。具体实现只需要在计算函数开头加一行if (scrollTop 0) return;这一行在实际使用中能挡掉大半边界情况引发的视觉抖动。卡顿问题则要从计算量入手。颜色插值函数内部涉及字符串解析和Math.round每次滚动都执行一次没问题但如果在一个页面里引用了多个渐变组件比如顶部导航和底部工具栏同时渐变每个组件都做一次插值计算累计起来就可能在低端机上明显掉帧。我建议把颜色插值做好缓存相同的起始色、结束色、ratio 组合直接返回上次结果能省掉 30%~50% 的重复计算。当然最好的优化还是控制组件数量过度设计也要避免。4.4 内容不足一屏时下拉刷新和渐变的冲突一个小而常见的场景首页内容很少不足一屏页面滚动距离永远达不到临界距离导航栏一直停留在透明状态。此时用户如果下拉刷新onPullDownRefresh会触发但页面本身不会产生滚动渐变头部没有任何反馈。针对这个场景我一般在组件里加一个“兜底逻辑”当刷新开始时手动把导航栏背景过渡到实色刷新结束再恢复。由于内容不足一屏时透明导航栏的功能仅仅是展示背景短暂变实色不会让用户觉得突兀反而让人觉得状态有反馈。实现方式是在组件中监听refreshing的变化watch: { refreshing(current) { // 刷新启动时让导航栏过渡到目标颜色 if (current) { this.forceOpacity 1; } else { this.forceOpacity 0; this.$nextTick(() { // 清除强制状态 }); } } }这里forceOpacity和easedRatio做一个最大值合并刷新状态下确保颜色是实色。实测下来这个方案在 App 端和小程序端表现都比较稳定。5. 经验总结做成配置化后续直接搬做完这个组件之后我最大的体会是这类 UI 效果的难点从来不在单点技术而在于把参数抽出来、把边界情况罩住、把不同端的小差异抹平。渐变头部本身原理不复杂但在 uni-app 里要同时照顾页面级滚动、自定义导航栏、状态栏适配、下拉刷新联动这几个维度不做配置化设计的话每接一个新页面都要重新踩一遍坑。我现在的做法是把渐变头部作为一个标准组件放到项目公共组件库里页面上引入后只通过 props 调整颜色和阈值背景图通过插槽传入刷新图标和右侧按钮用插槽定制。大约 90% 的页面不需要改动组件源码就能直接用剩下 10% 的特殊需求比如导航栏高度比较高、需要中途切换背景图再用扩展冒口解决。最后额外分享一个小技巧如果你希望渐变头部的背景色跟页面主题联动可以把这个组件的props 直接绑定到 getters 里的主题色这样换肤、多主题切换的时候渐变颜色也会自动跟着变化。我目前维护的几个项目都是这样处理的改主题色只需要改一个配置源头不用每个页面都去翻。这篇文章给出的代码都是我在真实项目里验证过的配置参数也是常用默认值。你可以先复制一份跑起来再根据自己页面的视觉风格调阈值和颜色。如果实践中遇到新的问题欢迎在评论区把现象和端类型发出来我按经验帮你定位方向——这类问题通常查起来并不难难的是第一次遇到时不知道从哪入手。
阅读完成 · 觉得有帮助?