支付宝小程序实现「滚动收起、停顿展开」底部订阅提示
一块钉在屏幕下方的订阅提示,滚动时悄悄藏起来,松手后再悄悄露出来——需求很短,但藏着的细节不少。
一、需求场景
在支付宝小程序里,有一块固定在屏幕下方的订阅提示区域,希望达到这样的交互:
- 用户主动滑动页面时,提示区域隐藏;
- 用户停止滑动后,提示区域重新显示。
效果如下图所示(动图演示滚动与停顿时的状态切换):
二、思路拆解
把需求翻译成技术语言,其实要做的事情就是两件:
- 监听页面滚动,滚动发生时把区域隐藏;
- 监听滚动停止,停止后再把区域显示回来。
听起来很简单,但支付宝小程序(其实是各大小程序都一样)并没有原生提供"滚动结束"事件。翻官方文档,唯一与滚动相关的 API 是:
| 事件名 | 触发时机 | 回调参数 |
|---|---|---|
onPageScroll | 页面滚动时触发 | scrollHeight(页面总高度)、scrollTop(滚动距离顶部高度) |
官方文档示意图:
📌 这个回调只告诉你"现在滚到哪里了",并不会告诉你"用户什么时候停下来"。
所以核心问题变成了:怎么自己识别"用户停手了"?
业界主流方案有两种:
- 节流(throttle):滚动过程中反复触发,但保证每隔 N ms 至少执行一次。
- 防抖(debounce):等用户停止操作 N ms 后才触发一次。
我们要的恰好是防抖的语义——“停下来再显示”。所以基本思路就清晰了:
每次滚动先把区域藏起来,并清掉之前的定时器、开一个新的定时器;定时器在 N ms 后执行"显示"动作。因为只有真正停下来时定时器才不会被清掉,所以定时器触发的那一刻,就是用户停手的时刻。
三、代码实现
下面把 axml、js 一次给全,方便直接照着抄。
3.1 模板(axml)
只需要用a:if把这块区域包起来,控制显示与隐藏即可:
<!-- pages/demo/index.axml --><viewclass="page"><!-- 页面正文 --><viewclass="content">...</view><!-- 底部订阅提示区域 --><viewclass="subscribe-remind"a:if="{{subscribeRemindVisible}}"><text>打开通知,第一时间收到新内容</text></view></view>对应的 acss(可选):
.subscribe-remind{position:fixed;left:0;right:0;bottom:0;padding:20rpx 32rpx;background:#ffffff;box-shadow:0 -4rpx 12rpxrgba(0,0,0,0.06);}3.2 原作者的原始实现(js)
data 里准备三个状态:
Page({data:{subscribeRemindVisible:true,// 是否展示底部区域(布尔值)isScrolling:false,// 标记是否处于滚动中timer:null,// 定时器句柄},onPageScroll(){// 正在滚动 → 立刻隐藏区域,并把"滚动中"标记置为 truethis.setData({subscribeRemindVisible:false,isScrolling:true,});// 清掉上一轮可能还没执行的定时器if(this.data.timer){clearInterval(this.data.timer);}// 起一个 100ms 的轮询定时器this.data.timer=setInterval(()=>{// 第一次执行(100ms):把标记清回 false// 第二次执行(200ms):若标记依然为 false,说明这段时间没有新的 onPageScroll,// 即用户真的停了 → 显示区域if(!this.data.isScrolling){clearInterval(this.data.timer);this.setData({subscribeRemindVisible:true});}// 每次 tick 末尾都重置标记,给下一次比较用this.setData({isScrolling:false});},100);},});这段代码的核心思路就是**“两段式轮询”**:
- 第 100ms 那个 tick:把
isScrolling置成false; - 第 200ms 那个 tick:如果
isScrolling还是false(中间没有新事件进来),就显示区域。
只有真正停下来时,定时器才能跨过这两轮、把区域显示出来。
⚠️ 这版写法能 work,但有两个不优雅的地方:
- 每 100ms 都执行一次
setData({ isScrolling: false }),会反复触发脏检查和渲染,开销偏大;isScrolling其实只是个内部标记,没必要塞进data,挂在this上就好。下面给一版等价的、更干净的实现,推荐直接用这版。
3.3 推荐实现(更干净的等价写法)
Page({data:{subscribeRemindVisible:true,},onPageScroll(){// 滚动中:先清掉旧定时器,把区域藏起来if(this._tipTimer)clearTimeout(this._tipTimer);this.setData({subscribeRemindVisible:false});// 等用户停手 200ms 后再把区域展开// 注意:句柄挂在 this 上即可,不要写进 data,避免多余渲染this._tipTimer=setTimeout(()=>{this.setData({subscribeRemindVisible:true});this._tipTimer=null;},200);},});思路完全一样——“滚动 → 清定时器 + 隐藏 → 重新起一个定时器,停 N ms 后再显示”——但好处很明显:
- 只用了一个
setTimeout,没有isScrolling中间态,可读性更高; - 定时器句柄挂在
this._tipTimer,不进 data,不会触发额外渲染; - 状态变更只有"显示 / 隐藏"两种,对外接口更稳定。
四、几个容易踩的坑
onPageScroll触发频率非常高。实测滚动一次屏幕会触发几十到上百次,千万不要在里面做重活(请求、复杂计算)。本文的方案里只做clearTimeout+setData一个布尔值,是安全的。- 别用
setInterval,用setTimeout。setInterval一旦开启就会一直执行,需要手动清除;setTimeout触发一次就结束,跟"停手再显示"的语义更吻合。 - 停顿时间 100~200ms 比较合适。太短会把"滑动中短暂的停顿"误判为停止;太长会让用户觉得"卡"。
- data 里别存太多中间态。
isScrolling只是个标记,没必要存进 data(不进 data 就不会触发脏检查,渲染更轻)。上面"推荐实现"里就直接挂在this上。
五、小结
整个需求拆开来看就三步:
onPageScroll触发即隐藏—— 监听滚动;- 滚动时清掉旧的
setTimeout,起一个新的—— 防抖; - 新定时器到点了再显示—— 识别停手。
代码总共不到 20 行,关键是想清楚"什么时刻才算停手"——它就是"定时器能跑完"的那一刻。所有没有提供scrollEnd事件的小程序/Web 容器,都可以用这个套路解决。
如果本文对你有帮助,欢迎点赞 / 收藏 / 评论三连支持一下~有更好的实现方案也欢迎留言交流 🙌