防止微信内出现全文翻译提示。因此在这里放一个充满中文的分区。 防止微信内出现全文翻译提示。因此在这里放一个充满中文的分区。 防止微信内出现全文翻译提示。因此在这里放一个充满中文的分区。 防止微信内出现全文翻译提示。因此在这里放一个充满中文的分区。
2024年8月27日@Aragakey.

如何依靠 transform 模拟 iOS 原生滚动体验

为什么要模拟滚动

最近做了一些移动端的交互 Demo,需要在用户一边滑动容器时,一边做一些 UI 上的变化。传统的做法是依靠 scroll 事件,很简单:

const handleScroll = () => {
  const { scrollLeft, scrollWidth } = element
}
const handleScroll = () => {
  const { scrollLeft, scrollWidth } = element
}

但会遇到以下几个问题:

  1. 在 iOS 设备上,scroll 事件的触发频率没有非常高,无法做顺畅的 UI 变化;
  2. 在 Android 设备上,没有 iOS 的“橡皮筋效果”,对此我确实一直都觉得很无语,难不成这是 iOS 的专利?
  3. 在桌面端设备中,无法通过鼠标拖拽的方式来横向滚动容器,必须通过横向的滚动条来操作;
  4. 无法拿到滚动开始、滚动结束的时机。有时候这个问题非常关键;
  5. 无法调整滚动的属性,如 scroll-snap 相关的属性。从我用过原生 CSS 的 scroll-snap 的经验来看,这个属性虽然很方便,但是和最舒服的体验总是不一样。

实际上,模拟一个移动端滚动,无非是以 transform 属性为核心,通过弹簧动画来模拟。这类似于我在 转盘交互动画:以关键参数,细化我们的感受 这篇文章中所做的事。

为什么不直接用 framer-motion 的 dragConstraints 属性

framer-motion 提供了一个 dragConstraints 属性,可以用来限制拖拽的范围。但是我的需求是需要在滚动的过程中实时变换 dragConstraints 的值,而这是 framer-motion 做不到的。至于是什么需求,暂时还不能说。

技术实现:use-gesture + inertia

状态:未开始滚动
00
01
02
03

橡皮筋公式

framer-motion 的做法是直接将超出的部分乘以 0.35,以达到橡皮筋的效果。这种简单的线性做法实际上并不是 iOS 的做法。下面的公式才是 iOS 的做法:

x = (1.0 - (1.0 / ((x * c / d) + 1.0))) * d

其中:

  • x: 超出边界的距离
  • c: 常数,用于调整橡皮筋的"紧度"(通常在 0.55 到 0.85 之间)
  • d: 可滚动区域的尺寸
const applyRubberBand = (x: number, edge: number, dimension: number) => {
  const c = 0.55
  return (
    (1.0 - 1.0 / ((Math.abs(x - edge) * c) / dimension + 1.0)) *
      dimension *
      Math.sign(x - edge) +
    edge
  )
}
const applyRubberBand = (x: number, edge: number, dimension: number) => {
  const c = 0.55
  return (
    (1.0 - 1.0 / ((Math.abs(x - edge) * c) / dimension + 1.0)) *
      dimension *
      Math.sign(x - edge) +
    edge
  )
}

这个公式有意思的地方就在于:

  1. 更接近真实的物理效果,滚动感更自然;
  2. 随着超出边界的距离增加,阻力会逐渐增大;
  3. 可以通过调整常数 c 来微调橡皮筋的紧度。

模拟存在的问题

  1. iOS 省电模式下,帧率会降低,导致动画卡顿,老问题了;
  2. 虽然 transform 已经是 web 端的最优解了,但性能一定不如 iOS 原生。虽然差别没有那么大,但只要横向对比着体验,还是能感受到差别。