支付宝原生小程序实现「滚动消失、停顿出现」底部订阅提示
一块钉在屏幕下方的订阅提示,滚动时悄悄藏起来,松手后再悄悄露出来——需求很短,但藏着的细节不少。
一、需求场景
在支付宝小程序里,有一块固定在屏幕下方的订阅提示区域,希望达到这样的交互:
- 用户主动滑动页面时,提示区域隐藏;
- 用户停止滑动后,提示区域重新显示。
效果如下图所示(动图演示滚动与停顿时的状态切换):

二、思路拆解
把需求翻译成技术语言,其实要做的事情就是两件:
- 监听页面滚动,滚动发生时把区域隐藏;
- 监听滚动停止,停止后再把区域显示回来。
听起来很简单,但支付宝小程序(其实是各大小程序都一样)并没有原生提供"滚动结束"事件。翻官方文档,唯一与滚动相关的 API 是:
| 事件名 | 触发时机 | 回调参数 |
|---|---|---|
onPageScroll |
页面滚动时触发 | scrollHeight(页面总高度)、scrollTop(滚动距离顶部高度) |
官方文档示意图:

📌 这个回调只告诉你"现在滚到哪里了",并不会告诉你"用户什么时候停下来"。
所以核心问题变成了:怎么自己识别"用户停手了"?
业界主流方案有两种:
- 节流(throttle):滚动过程中反复触发,但保证每隔 N ms 至少执行一次。
- 防抖(debounce):等用户停止操作 N ms 后才触发一次。
我们要的恰好是防抖的语义——"停下来再显示"。所以基本思路就清晰了:
每次滚动先把区域藏起来,并清掉之前的定时器、开一个新的定时器;定时器在 N ms 后执行"显示"动作。因为只有真正停下来时定时器才不会被清掉,所以定时器触发的那一刻,就是用户停手的时刻。
三、代码实现
下面把 axml、js 一次给全,方便直接照着抄。
3.1 模板(axml)
只需要用 a:if 把这块区域包起来,控制显示与隐藏即可:
<!-- pages/demo/index.axml -->
<view class="page">
<!-- 页面正文 -->
<view class="content">...</view>
<!-- 底部订阅提示区域 -->
<view class="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 12rpx rgba(0, 0, 0, 0.06);
}
3.2 原作者的原始实现(js)
data 里准备三个状态:
Page({
data: {
subscribeRemindVisible: true, // 是否展示底部区域(布尔值)
isScrolling: false, // 标记是否处于滚动中
timer: null, // 定时器句柄
},
onPageScroll() {
// 正在滚动 → 立刻隐藏区域,并把"滚动中"标记置为 true
this.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 容器,都可以用这个套路解决。
如果本文对你有帮助,欢迎点赞 / 收藏 / 评论三连支持一下~有更好的实现方案也欢迎留言交流 🙌

浙公网安备 33010602011771号