UI 动效 004:Rubber Band,拖到边界为什么会「弹」回来
June 26, 2026 · 8:09 AM

UI 动效 004:Rubber Band,拖到边界为什么会「弹」回来

这期拆解 Rubber Band / Elastic Overscroll 回弹边界动效:它适合列表、抽屉和拖拽面板的边界反馈,关键是先压缩越界位移,再用带速度的 spring 回到 0。

Rubber Band 最容易被误解成「把列表往下多拉一点」。真正让它舒服的,是两段映射:手指还没松开时,内容位移被压缩;松手后,再带着释放速度,用弹簧回到边界。
Rubber Band 回弹边界动效示意
Rubber Band 回弹边界动效示意
自制 GIF 示意:列表被向下拖过顶部边界时,视觉位移先被压缩,松手后用 spring 回到 0;非真实产品截图。

动效档案

项目说明
中文叫法回弹边界、橡皮筋回弹、弹性越界
英文叫法Rubber Band、Elastic Overscroll、Bounce
典型位置列表顶部 / 底部、抽屉面板边界、可拖拽卡片、轮播末端
适合传达「已经到头了,但你的手势被系统接住了」
不适合表单提交、危险操作确认、需要稳定阅读的长文本区域
在 UIKit 里,UIScrollView.bounces 控制滚动视图是否能越过内容边缘再回到边缘;Apple 对这个属性的描述就是「bounces past the edge of content and back again」。1 Flutter 也把同类行为做成 BouncingScrollPhysics:滚动偏移可以超过内容边界,然后再弹回边界,文档还说明这是 iOS 上常见的滚动行为。2
所以 Rubber Band 不是装饰动画。它是一种边界反馈:系统告诉你「这里已经没有更多内容」,但不粗暴地把手势截断。

视觉上发生了什么

一次完整的 Rubber Band 可以拆成 4 个状态:
  1. 正常滚动:内容跟着手指移动,滚动偏移还在合法范围内。
  2. 撞到边界:顶部或底部已经到头,继续拖拽只进入 overscroll 区域。
  3. 位移压缩:手指继续走 100px,内容可能只多移动 40px;越往后越难拉开。
  4. 松手回弹:手指离开后,内容不直接闪回,而是按弹簧曲线回到边界。
Flutter 文档里的 frictionFactor 说得很直白:它会把 overscroll 时的输出位移相对于手势输入位移压小,让「拖过边界」看起来更难。2 这就是橡皮筋感的来源。不是列表真的被拉长,而是视觉位移被做了非线性压缩。

什么时候该用它

场景为什么适合注意点
列表到顶 / 到底用户还在拖,系统用回弹承接手势不要把回弹做得太远,否则像页面坏了
下拉刷新前段先用橡皮筋感提示「还没触发」触发刷新后要换成明确的 loading 状态
可拖拽面板面板撞到最大 / 最小高度时,需要软边界边界必须稳定,不能松手后停在奇怪位置
轮播末端告诉用户已经到第一张或最后一张如果有循环轮播,就不要再做末端回弹
网页里还要先想清楚一件事:你要保留浏览器原生 overscroll,还是自己做一层效果。overscroll-behavior 这个 CSS 属性就是用来控制滚动区域到达边界时浏览器该怎么处理;MDN 明确写到,它会影响 scroll chaining、bounce 以及 pull-to-refresh 这类边界行为。3 如果你在弹窗、抽屉、嵌套列表里做自定义 Rubber Band,先处理好滚动传递,否则手指一拉,背后的页面也跟着跑。

实现关键:先压缩,再弹回

1. 边界检测

先判断滚动是否已经到顶或到底。只有越界那一段才进入 Rubber Band;还在合法范围内时,继续使用原生滚动。
const atTop = scrollTop <= 0;
const atBottom = scrollTop + viewportHeight >= scrollHeight;
const enteringOverscroll = (atTop && dragY > 0) || (atBottom && dragY < 0);

2. 把手指位移映射成视觉位移

最简单的做法是用一个递减收益函数。手指刚开始越界时,内容还能明显动;越拖越远,新增视觉位移越来越小。
function rubber(distance, limit = 120) {
  const sign = Math.sign(distance);
  const x = Math.abs(distance);
  return sign * limit * (1 - Math.exp(-x / limit));
}

// 手指拉了 140px,视觉上可能只移动 82px
content.style.transform = `translateY(${rubber(dragY)}px)`;
这类函数比直接 distance * 0.5 更像橡皮筋,因为它有一个软上限:用户能感觉到「还能拉」,但不会无限拉开。

3. 松手后用 spring 回到 0

回弹不要只写 transition: transform .3s ease-out。Rubber Band 的手感来自速度和阻尼:松手速度越大,回弹的第一段越有力;阻尼越大,来回抖动越少。
Motion 的 spring 动画支持 stiffnessdampingmass,并且物理型 spring 会把已有手势或动画的 velocity 纳入计算。4 Android 的 SpringForce 也把 stiffness、damping ratio 和 final position 当作弹簧动画的核心参数。5
伪代码可以写成这样:
const y = motionValue(0);

onDrag((dragY) => {
  if (!enteringOverscroll) return;
  y.set(rubber(dragY));
});

onRelease(({ velocityY }) => {
  animate(y, 0, {
    type: "spring",
    stiffness: 420,
    damping: 32,
    velocity: velocityY,
  });
});
参数不用迷信某个固定值。列表回弹通常需要高 stiffness、较高 damping,让它「快回去、少抖两下」;游戏化卡片可以稍微软一点,让弹性更明显。

CSS 曲线能不能凑一个?

可以,但只适合很轻的假回弹。MDN 在 cubic-bezier() 文档里说明,控制点的 y 值超出 [0, 1] 时,动画值可能超过最终状态再返回,因此会产生类似 bounce 的效果。6
.overscroll-release {
  transition: transform 360ms cubic-bezier(.18, 1.28, .22, 1);
  transform: translateY(0);
}
但 CSS 曲线有一个明显短板:它不知道用户松手时的速度。你只能做「看起来像弹了一下」;要做真正跟手的 Rubber Band,还是要在拖拽阶段记录位移和速度,再交给 spring。

设计时别踩这 4 个坑

  • 回弹距离太大:边界反馈会变成页面漂移。手机列表一般让视觉位移停在一个有限范围内就够了。
  • 松手后停不回原位:Rubber Band 的最终状态必须是边界,不要让内容卡在 overscroll 区。
  • 嵌套滚动没处理:弹窗里的列表到顶后,背后页面继续滚,会让用户觉得手势被抢走。Web 场景优先检查 overscroll-behavior3
  • 把它用在确认动作上:删除、支付、提交这类动作需要明确反馈,不适合用软绵绵的回弹来表达结果。

一句话记住它

Rubber Band 的核心不是「弹一下」,而是 越界时把手势变钝,松手后用弹簧回到边界。做对了,用户会觉得界面有韧性;做过头,就会像内容被拽坏了。

References

  1. 1bounces
  2. 2BouncingScrollPhysics class - Flutter API
  3. 3overscroll-behavior CSS property - MDN
  4. 4React transitions
  5. 5Animate movement using spring physics
  6. 6cubic-bezier() CSS function - MDN

Related content

Add more perspectives or context around this Post.

  • Sign in to comment.