ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

小程序吸顶导航实现:从sticky踩坑到滚动监听完整方案

小程序吸顶导航实现:从sticky踩坑到滚动监听完整方案 做小程序开发遇到“导航栏滑到顶部后固定”这个需求算是家常便饭了。但就是这么个高频又看似简单的功能我见过很多人在群里问为什么吸顶之后会抖、会跳、会盖住内容甚至有人在sticky定位上卡了半天死活按不住。今天就把这个功能的完整实现思路、代码细节和踩坑记录一次性讲清楚。先说清楚这东西到底在解决什么问题。小程序页面内容一长用户往下滑的时候顶部导航或者筛选栏跟着飘走那体验是很糟的——想切Tab、想按条件筛选还得费劲滑回顶部重新操作。让导航栏在滑动到一定距离后固定在页面顶部本质上是为了让核心操作入口始终可见减少用户的操作成本。这个功能在电商首页、商品列表、分类页、资讯页这些场景里几乎是刚需。这类功能看着不难但真正做过一遍的人都知道里面有不少细节是文档里不会告诉你的。我把我个人在多个项目里用下来最稳的一套方案拆开揉碎了讲并且把那些只能在实战里踩出来的坑也一并说了希望对你有帮助。1. 吸顶导航的两条技术路线怎么选先说结论不要一上来就想着用CSS的position: sticky我在小程序里踩过几次它的坑后面详细解释。稳的做法是用滚动监听切换定位方式。1.1 为什么直接写position: sticky会有问题position: sticky这个属性在PC端浏览器上成熟得让人放心但在微信小程序里它并不是万能钥匙。原因是小程序的渲染层由原生组件和WebView混排组成sticky在很多老旧的基础库版本、以及嵌入了原生组件比如map、video、canvas的页面上表现都不稳定。最常见的两个问题一个是吸顶后有几率出现内容遮挡另一个是吸顶位置计算不准确——尤其是你页面里还有自定义导航栏、胶囊按钮、安全区适配这些因素的时候sticky自己算的定位基准往往跟你的预期对不上。还有一点容易被忽略position: sticky的定位依赖于父容器的高度。如果父容器的高度没有把吸顶元素之后的内容全部包住吸顶元素会在父容器结束的位置突然跟着滚走看起来就是这个导航栏“没固定住”、“滑到一半就掉了”。这个问题在页面结构稍微复杂一点的时候排查起来比写代码还费时间。1.2 滚动监听方案的核心思路更可控的思路是监听页面的滚动事件判断“导航栏原本所在的位置”是否已经滚出屏幕顶部一旦判断成立就立即切换导航栏的定位方式为fixed让它钉在顶部滚回去的时候再切换回正常的文档流定位。这个方案的优势非常明显第一逻辑完全掌握在自己手里触发距离可以精确到像素想怎么定阈值就怎么定阈值不需要迁就sticky的父容器约束。第二通用性极强。不管你的页面用的是页面级滚动还是嵌套了scroll-view或者是自定义导航栏只要你能拿到滚动条滚动的距离这个方案就能用。第三切换过程中的样式可控你可以加过渡动画、加阴影效果交互细腻度比sticky高很多。一句话总结sticky适合“正好能用、无欲无求”的情况而监听方案适合“不管什么场景都得稳定工作”的需求。做产品功能稳定永远是第一位的。2. 完整代码实现从结构到样式一次写好直接给你一套我实际项目里拆出来的简化版本代码不长但该有的细节都有你可以直接复用。2.1 页面结构设计与数据准备假设我们的页面结构是这样的顶部是一个轮播图轮播图下面是导航栏包含几个Tab切换再往下是内容列表。需要吸顶的对象就是位于轮播图和内容列表之间的这个导航栏。!-- index.wxml -- view classpage !-- 顶部轮播区域 -- view classbanner image src{{bannerImg}} modeaspectFill / /view !-- 导航栏吸顶目标 -- view classnav-wrap {{isFixed ? nav-fixed : }} style{{isFixed ? top: navTopPx px : }} view classnav-item {{currentTab 0 ? active : }}>// index.js Page({ data: { bannerImg: https://example.com/banner.jpg, currentTab: 0, isFixed: false, // 是否已吸顶 navTopPx: 0, // 吸顶时距离顶部的高度 navOffsetTop: 0 // 导航栏距离页面顶部的初始距离 }, onLoad() { // 在这里获取导航栏初始位置 this.getNavOffsetTop(); }, getNavOffsetTop() { const query wx.createSelectorQuery(); query.select(.nav-wrap).boundingClientRect((rect) { if (rect) { this.setData({ navOffsetTop: rect.top }); } }).exec(); }, onPageScroll(e) { this.handleScroll(e.scrollTop); }, handleScroll(scrollTop) { // 当滚动距离超过导航栏初始位置时触发吸顶 const shouldFixed scrollTop this.data.navOffsetTop; if (shouldFixed ! this.data.isFixed) { this.setData({ isFixed: shouldFixed }); } }, onTabChange(e) { const index e.currentTarget.dataset.index; this.setData({ currentTab: index }); } });这里你可能会问为什么不在onLoad里直接拿navOffsetTop而是单独封装了一个getNavOffsetTop方法因为在页面初次加载时图片可能还没渲染完成轮播图的高度可能还没撑开这时候拿到的navOffsetTop值是不准的——大概率偏大导致导航栏明明已经该吸顶了却还飘在上面。所以我一般会在onReady里再加一次获取或者等图片onload之后再重新测量一次。稳妥一点的做法是延迟200毫秒再加一次测量总之数值要等布局稳定之后拿才是最准的。还有一点如果你的导航栏高度是固定的也可以不用从DOM上测量直接写死一个常量比如500。但那样做一旦轮播图尺寸调整、字号变化就得跟着改维护成本高了许多。既然小程序提供了boundingClientRect这个API何必费那个劲呢。2.2 滚动事件监听与吸顶状态切换刚才的代码里onPageScroll每次滚动都会触发handleScroll这里有一个很关键的优化点高频事件里的操作一定要轻。看一下我的handleScroll方法的写法——它干了一件什么事它只是比较了两个值然后判断是否需要setData。而且只有当shouldFixed ! this.data.isFixed时才setData。这里面的逻辑是滚动过程中scrollTop是持续变化的但导航栏是否吸顶这个状态只有两种结果一是已经吸顶二是尚未吸顶。如果已经吸顶了那么后续所有滚动事件里shouldFixed都会是true跟isFixed一致所以setData不会触发第二次不会频繁更新UI。这个技巧叫“状态守卫”能有效减少小程序setData的调用次数。小程序的数据更新走的是前后端通信通道开销比Web端大得多高频无脑调用setData会引起页面卡顿、掉帧这个习惯得从一开始就养成。对应的样式部分/* index.wxss */ .page { width: 100%; } .banner { width: 100%; height: 300rpx; } .banner image { width: 100%; height: 100%; } .nav-wrap { display: flex; width: 100%; height: 88rpx; background: #ffffff; position: relative; z-index: 10; } .nav-fixed { position: fixed; left: 0; z-index: 100; box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08); } .nav-item { flex: 1; display: flex; align-items: center; justify-content: center; font-size: 28rpx; color: #333333; } .nav-item.active { color: #ff6600; font-weight: 600; }细心的读者会发现我.nav-fixed里只设置了left: 0和position: fixed但是没有设置width: 100%。为什么不设因为position: fixed的元素宽度如果设置为100%它实际上是相对于视口宽度计算的这在绝大多数场景下没问题。但如果你的页面在PC端微信打开、或者处于横屏状态视口宽度通常跟内容宽度对不齐。这里我用了left: 0配合width: 100%其实可以但更保险的方案是给nav-fixed同时设置left: 0; right: 0让宽度由定位自动撑开。等等上面代码里我又写了width: 100%又写了left: 0这就是冗余了。这里我给的WXSS你可以删掉width: 100%保留left: 0; right: 0;让宽度自动对齐视口。如果你想让吸顶后宽度跟原来的内容宽度一致就用left: 0; right: 0这比单独width: 100%更稳。还有一点导航栏的高度我这里写的是88rpx这是小程序页面导航栏比较常见的标准高度。如果你用的是自定义导航栏那个高度可能更多需要用wx.getMenuButtonBoundingClientRect()来动态测量后面的章节我会专门讲这个。2.3 吸顶后的阴影和过渡动画优化吸顶这个动作如果只是硬生生地从“跟随滚动”变成“固定在顶部”视觉上会有点突兀。一个小技巧是在吸顶状态下给导航栏加一层轻微的下阴影让用户直观感知到“这个东西定住了”。这个我在上面代码里已经写了就是box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08)。不用太深稍微有点层次感就行。另一个提升质感的东西是过渡动画。你可以在.nav-wrap上加一个过渡让吸顶切换的时候有轻微的光影过渡.nav-wrap { transition: box-shadow 0.2s ease-in-out; }注意这里千万不要给transform、top这类属性加过渡因为吸顶切换的瞬间定位方式发生了变化如果你对top加了过渡在吸顶那一瞬间导航栏会有一个从原来的滚动位置“飘”到顶部的动画过程看起来像在跳体验反而更差。3. 不同场景的适配与踩坑记录上面这套逻辑看起来简单但不同项目里的页面结构五花八门落地的时候会碰到各种状况。这一章把几个最常见的变体场景和对应的适配方法讲掉。3.1 页面滚动与scroll-view监听的取舍很多人会遇到一个情况页面的布局中不是整个页面都在滚动而是内容区域放在了一个scroll-view里只让中间那部分内容滚。这种情况下onPageScroll是根本不会触发的因为页面本身没有产生滚动。你需要在scroll-view上使用bindscroll事件来监听滚动距离。结构大概是这样的scroll-view scroll-y styleheight: 100vh; bindscrollonScrollViewScroll view classbanner.../view view classnav-wrap ....../view view classcontent.../view /scroll-view对应的JSonScrollViewScroll(e) { const scrollTop e.detail.scrollTop; this.handleScroll(scrollTop); }逻辑核心不变只是换了事件来源。但这里有个大坑当scroll-view的内容展开时你得到的scrollTop可能是正值也可能是负值视平台而定。所以handleScroll里用的那个阈值如果是用boundingClientRect测出来的要注意测量对象和滚动容器保持一致。如果你的滚动容器不是页面而是scroll-view测量navOffsetTop也应该在scroll-view内部进行不能在页面级去取否则数值差了十万八千里。另外使用scroll-view时滚动性能要比页面滚动差一截特别是列表很长的时候。能用页面滚动就尽量用页面滚动别为了布局方便强行套scroll-view。3.2 自定义导航栏的高度计算如果你用了自定义导航栏就是navigationStyle: custom那种吸顶的高度就需要额外考虑“胶囊按钮”的位置。自定义导航栏意味着你的页面顶部会有一段状态栏高度胶囊按钮区域这段区域通常不属于页面滚动内容的一部分它是覆盖在页面最顶层的。如果你把导航栏吸顶到top: 0它会被胶囊按钮区域挡住看着像顶进刘海屏里去了很丑。正确做法是在吸顶时给导航栏留出状态栏的高度这个高度可以用以下方式获取const systemInfo wx.getWindowInfo(); const menuButton wx.getMenuButtonBoundingClientRect(); // 状态栏高度 胶囊上下边距的一部分可以算出导航栏应该占用的top const statusBarHeight systemInfo.statusBarHeight; const navBarHeight (menuButton.top - statusBarHeight) * 2 menuButton.height;然后用navBarHeight作为吸顶时的top值this.setData({ navTopPx: statusBarHeight, isFixed: true });我这个项目里如果用到自定义导航栏吸顶的目标位置就是状态栏高度而不是0。这样导航栏固定时刚好在胶囊按钮下方整体看起来跟系统原生导航栏一样自然。3.3 吸顶时卡顿抖动的优化做法吸顶卡顿、抖动基本逃不出这三个原因。第一个原因是我前面讲过的“状态守卫”没有做好每次滚动都setData导致UI更新频繁。这个解法上面已经给了用状态变化判断只在状态变化时执行一次。第二个原因是boundingClientRect测量时机不对导致初始高度取值有偏差。吸顶判断的阈值不对会导致导航栏“该吸不吸”、“不该吸乱吸”表现出来就像抖动。解决办法就是在onReady、网络图片加载完成后再重新测量一两次确保初始值准确。第三个原因跟Z轴层级有关。fixed定位的元素在吸顶时如果没有一个合适的z-index滚动的内容区域可能会盖在导航栏上面看起来导航栏被内容“穿透”了。这时需要检查导航栏的z-index是否大于内容区域的z-index同时检查内容区域有没有创建新的层叠上下文。常见的坑是给内容区域设了opacity、transform、filter之类的属性无意中把这个区域变成了层叠上下文导致z-index的设置失效。我做这个功能时习惯把所有可能涉及吸顶的元素z-index统一到一个设计规范里管理比如吸顶导航统一用100弹层用1000Toast用2000。这样不管以后加什么组件层级关系一眼就能看明白不会出现莫名奇妙的遮挡问题。4. 常见问题与排查技巧实录这一章整理几个我在实际项目里反复碰到的典型问题这些案例基本都是从真机调试、线上反馈里捞出来的比单纯看文档有效得多。4.1 问题速查表现象可能原因解决方案吸顶后导航栏被内容盖住层级冲突或层叠上下文给导航栏提高z-index检查内容区是否创建了新的层叠上下文吸顶后导航栏位置偏上/偏下自定义导航栏场景下top值没算状态栏高度用wx.getMenuButtonBoundingClientRect()动态计算top值吸顶后页面跳动、元素错位原位置元素高度塌陷占位消失吸顶时保留一个与导航栏同等高度的占位视图导航栏滑到一半就固定住了navOffsetTop测量值小于实际值在页面图片加载完成或onReady后延迟重新测量scroll-view场景下监听不生效用了onPageScroll但页面无滚动改为在scroll-view上监听bindscroll事件iPhone上吸顶时顶部有白条未适配安全区吸顶时top值加上安全区高度吸顶切换瞬间有跳跃给top或transform加了过渡动画移除top的过渡只保留box-shadow等视觉过渡导航栏吸顶后按钮点不了有透明的遮罩层或脱离文档流的元素挡住检查页面z-index层级用wx.createSelectorQuery调试命中区域4.2 定位追踪用WXML节点信息排查层级问题排查层级问题的时候我一般会用开发者工具的WXML面板先点选那个“吸顶失败”的导航栏节点看它的z-index、position、占位情况。那里能直观看到元素的盒子模型、布局位置比单纯靠代码推断快得多。如果你在真机上排查可以用vconsole插件配合wx.createSelectorQuery打印实时节点位置信息。比如吸顶后按钮点不了我会先检查是不是内容区域某个未设z-index的元素“扩张”到了导航栏区域内。其实有个办法可以快速验证临时给导航栏加一个很高的z-index和红色背景再滚动测试。如果问题消失说明就是层级问题然后再排查具体是哪个元素盖住了。4.3 占位视图的巧用解决吸顶跳位前面提到一个问题吸顶后页面跳动、元素错位。这背后的原因很单纯——导航栏原本是文档流里的一个元素它占着位置一旦切换成fixed它就从文档流里被抽离了后面的内容会瞬间往上顶页面就会“跳”一下。解决方法是在导航栏的原位置放一个“隐形占位”的view高度跟导航栏一致只在吸顶时显示。实现上可以这样改!-- 占位视图只在吸顶时显示 -- view classnav-placeholder style{{isFixed ? height: 88rpx; : height: 0;}}/view或者更干净一点的做法给导航栏外面包一层始终占着位置view classnav-wrapper view classnav-wrap {{isFixed ? nav-fixed : }}.../view /view.nav-wrapper保持固定高度不变就是原导航栏的高度.nav-wrap在吸顶时设置position: fixed由于它的父级.nav-wrapper还是占着原来的位置所以内容不会跳动。这个方法在电商页面里很常用实测下来最稳定。5. 从能用做到好用封装一个可复用的吸顶导航组件如果你只是在一个页面里用上面那套方案复制粘贴就够了。但真实项目里往往会有多个页面需要类似的吸顶能力每个页面都复制一份代码后续维护起来是灾难。我的做法是把它封装成一个通用组件用起来跟开关一样简单。组件的核心接口就是两个一个触发阈值一个吸顶目标的选择器。默认情况下组件自己会测量位置但你也可以传一个初始高度值进来覆盖自动测量的结果用来应对一些图片延迟加载的特殊场景。// 组件内核心逻辑片段 Component({ properties: { threshold: { type: Number, value: 0 }, disabled: { type: Boolean, value: false } }, data: { isFixed: false, offsetTop: 0 }, lifetimes: { attached() { this.initObserver(); } }, methods: { initObserver() { const query this.createSelectorQuery(); query.select(.sticky-nav).boundingClientRect((rect) { if (rect) { this.setData({ offsetTop: rect.top }); } }).exec(); }, onScroll(scrollTop) { if (this.data.disabled) return; const threshold this.data.threshold || this.data.offsetTop; const shouldFixed scrollTop threshold; if (shouldFixed ! this.data.isFixed) { this.setData({ isFixed: shouldFixed }); } } }, pageLifetimes: { show() { // 页面每次显示时重新测量避免因为数据变化导致位置偏移 this.initObserver(); } } });组件化之后它在页面里的用法就很简单了sticky-nav bind:scrollonPageScroll !-- 这里是你的导航内容 -- view classcustom-nav.../view /sticky-nav父页面需要做的事情只有一个把滚动事件传给它。这样导航栏的实现细节全部被隔离在组件内部页面的业务代码干净多了。还有一个更进阶的思路小程序提供IntersectionObserver可以观察一个元素是否进入/离开视口其实也适合做吸顶。思路是观察导航栏后面一个占位元素与顶部的交叉情况一旦交叉边界触发就切换吸顶状态。这个API的优点是性能更好不用高频手动处理滚动事件。但是它的兼容性在旧版本基础库上有点顾虑而且真机上的触发频率不稳定我一般只在新项目里使用存量项目还是老老实实用滚动监听方案。从稳定性优先的角度我个人目前的主力方案仍然是滚动监听状态守卫因为它对基础库版本的容忍度最高逻辑也最直观出了问题排查起来不费劲。等哪天小程序基础库对IntersectionObserver的兼容性足够均匀了再切换也不迟。6. 最后再提醒几个容易翻车的小细节第一个细节是安全区适配。如果你的页面是全面屏机型吸顶到top: 0时状态栏是透明的内容会直接顶到“刘海”区域这时候导航栏的点击区域可能被系统手势区或者刘海遮挡。建议在吸顶时给top加上safe-area-inset-top的值CSS里可以直接用env(safe-area-inset-top)或者用小程序API动态传入。第二个细节是文字和背景的对比度。吸顶之后导航栏会浮在内容之上如果内容区域滚动到深色图片上你的导航栏如果文字是深色的、背景是白色的那怎么配都没问题。但如果你用了半透明背景就要特别小心文字和内容叠在一起会看不清这种时候把背景改成几乎不透明的实色更稳妥。第三个细节是吸顶动画的时机。我在前面的代码里写了用shouldFixed ! isFixed来状态守卫这能保证只在状态变化时执行一次setData。但如果你在吸顶的瞬间还需要动态计算阴影、边框这些样式注意把这些计算也放在这一层判断里别额外制造一次setData。第四个细节是关于“自定义导航栏”场景下的返回按钮。如果你做了自定义导航吸顶的导航栏上如果要放返回箭头它的点击区域要稍微大一点因为安卓系统的实体返回键在某些机型上会跟页面内的返回按钮产生冲突用户生理上会更容易误触。这个是从产品体验层面看的细节但很影响实际手感。我这些年做了不少小程序项目吸顶导航这个功能可以说是“看起来只是一个小交互做起来全是细节”。它不像复杂的数据处理、甚至不像图片上传压缩那样需要多少高深的技术但恰恰是这种基础交互最能拉开一个开发者对小程序平台特性的理解深浅。真正踩过坑和没踩过坑的人写出来的代码在真机上的表现就是不一样。如果你做这个功能时用的也是滚动监听的方案希望这篇总结能帮你在细节上少走几段弯路。如果你用的方案跟我的不一样也欢迎把你自己的经验沉淀下来分享出去。
返回列表