封面信息图

很多跨端前端开发者经常感叹:为什么在移动端网页里手写的卡片拖拽、抽屉拉伸或者横向轮播图,不论怎么调参数,摸起来总有一种“塑料假肢”般的滞涩感?而苹果 iOS 原生系统里的任何一个列表滑动,手指甩出去的瞬间,列表能够带有一种沉稳而丝滑的物理惯性向前滑行,并在摩擦力耗尽前极为优雅地缓缓减速停住?

差距的根本不在于编程语言本身,而在于底层物理数学模型的保真度。

绝大多数前端实现的滑动逻辑极其粗糙:拖拽时直接将手指位移 $1:1$ 赋值给 DOM;拖拽越界时要么生硬卡死,要么直接用线性乘法打折;松手时要么毫无惯性当场定格,要么套用一个固定的 CSS ease-out 动画假装有速度。

要打造真正具备原生级丝滑质感的手势交互,必须完整推导并实现两套核心物理方程:橡皮筋非线性越界阻尼函数(Rubber-banding Curve) 与 基于微积分速度拟合的指数动量衰减模型(Momentum Fling Decay)。


一、越界拖拽的橡皮筋非线性动力学数学解

当用户把一个抽屉拖到顶部或底部边界后继续向下拉扯时,界面绝不能像撞墙一样突然停止,而应当表现出越来越大的拉伸阻力,这在人机工程中被称为“橡皮筋效应”。

很多团队习惯使用简单的线性折减:位移超过边界后乘以 $0.3$。但线性折减在数学上是有致命缺陷的:只要手指拉得足够长,元素依然会被无限拉出屏幕,产生强烈的失控感。

苹果 iOS 底层采用的是极其优雅的双曲衰减函数:

$$d(x) = \frac{x \cdot c \cdot d_{max}}{d_{max} + c \cdot x}$$

其中:

  • $x$ 为手指超出物理边界的真实物理位移;
  • $c$ 为阻尼初相系数(标准推荐取 $0.55$);
  • $d_{max}$ 为屏幕所允许的最大理论越界拉伸极限(通常取视口尺寸的 $1/3$)。

分析该函数的微积分导数特性:

  • 当 $x \to 0$ 时,一阶导数 $d'(0) = c$,保证了刚越界时阻尼连续无突变;
  • 当 $x \to \infty$ 时,$d(x) \to d_{max}$,在数学上绝对锁死了拉伸上限!无论用户的手指滑出多远,卡片都会如同拉紧的强力弹簧一样收敛在安全的视觉边界内,绝不穿模。

二、释放瞬时速度的多项式微积分拟合

当用户手指离开屏幕(touchend)时,系统面临的最严苛挑战是:如何精准获知松手那一微秒的瞬时真实物理速度 $v_0$?

如果直接用 (lastX - prevX) / (lastTime - prevTime),由于移动端触控采样率的硬件抖动(Jitter),如果用户手指在松开前发生了一次极其轻微的 10ms 停顿,算出来的初速度会瞬间归零,导致甩掷动效彻底丢帧哑火。

工业级标准做法是:维护一个容量为 5 的环形采样历史队列,仅抓取最近 100ms 内的有效触控点,通过带权最小二乘法(Weighted Least Squares)对时间与位移执行一阶导数拟合:

export interface TouchSample {
  time: number;
  pos: number;
}

export class VelocityTracker {
  private history: TouchSample[] = [];
  private readonly windowMs = 100;

  public addPoint(pos: number): void {
    const now = performance.now();
    this.history.push({ time: now, pos });

    // 清理超过 100ms 的陈旧采样点
    while (this.history.length > 0 && now - this.history[0].time > this.windowMs) {
      this.history.shift();
    }
  }

  /**
   * 采用时间微积分拟合释放瞬时速度 (像素/毫秒)
   */
  public computeVelocity(): number {
    if (this.history.length < 2) return 0;

    const latest = this.history[this.history.length - 1];
    const earliest = this.history[0];
    const dt = latest.time - earliest.time;

    if (dt < 10) return 0; // 采样时间过短,防止除零

    // 简单加权一阶速度推导(对较新样本赋予更高权重)
    let totalWeight = 0;
    let weightedVelocity = 0;

    for (let i = 1; i < this.history.length; i++) {
      const p1 = this.history[i - 1];
      const p2 = this.history[i];
      const segmentDt = p2.time - p1.time;
      if (segmentDt <= 0) continue;

      const segVel = (p2.pos - p1.pos) / segmentDt;
      const weight = i; // 越靠近松手时间,权重越大
      weightedVelocity += segVel * weight;
      totalWeight += weight;
    }

    return totalWeight > 0 ? (weightedVelocity / totalWeight) * 1000 : 0; // 转换为 像素/秒
  }

  public reset(): void {
    this.history = [];
  }
}

三、动量抛掷(Fling)指数衰减模型与停靠解算

松手后,物体进入无外力的自由滑动阶段。在流体与空气阻力下,物体的加速度与当前瞬时速度成正比(一阶阻尼微分方程):

$$m \frac{dv}{dt} = -k \cdot v \implies v(t) = v_0 \cdot e^{-\lambda t}$$

对速度积分,即可得到在时间 $t$ 内累积滑行的精确距离:

$$s(t) = \int_0^t v(\tau) d\tau = \frac{v_0}{\lambda} (1 - e^{-\lambda t})$$

当 $t \to \infty$ 时,滑动的理论终点位移为:

$$S_{total} = \frac{v_0}{\lambda}$$

其中 $\lambda$ 为系统的摩擦衰减常数(标准通常取 $3.8 \sim 4.5$)。

我们甚至完全不需要用 JS 在每一帧循环推演位置!既然终点位移 $S_{total}$ 和衰减时间已经在数学上闭式解出,我们能够直接调用 Web Animations API(WAAPI),为元素挂载一条符合指数减速的贝塞尔或 linear() 动画,直接在 GPU 合成器线程上全速播放!

export class NativeLikeGestureScroller {
  private element: HTMLElement;
  private tracker = new VelocityTracker();
  private startX = 0;
  private currentTranslateX = 0;
  private readonly lambda = 4.2; // 摩擦阻尼系数

  constructor(el: HTMLElement) {
    this.element = el;
    this.bindTouch();
  }

  private bindTouch(): void {
    this.element.addEventListener('touchstart', (e: TouchEvent) => {
      this.startX = e.touches[0].clientX;
      this.tracker.reset();
      this.tracker.addPoint(this.startX);
      // 中断正在进行的惯性动画,实现随摸随停的无缝跟手
      this.element.style.animation = 'none';
    }, { passive: true });

    this.element.addEventListener('touchmove', (e: TouchEvent) => {
      const x = e.touches[0].clientX;
      this.tracker.addPoint(x);
      const deltaX = x - this.startX;
      this.currentTranslateX += deltaX;
      this.startX = x;

      // 实时位移响应
      this.element.style.transform = `translate3d(${this.currentTranslateX}px, 0, 0)`;
    }, { passive: true });

    this.element.addEventListener('touchend', () => {
      const v0 = this.tracker.computeVelocity();
      if (Math.abs(v0) > 150) {
        // 1. 根据物理公式求解总滑行位移
        const flingDistance = v0 / this.lambda;
        const targetX = this.currentTranslateX + flingDistance;
        const durationSeconds = Math.min(1.2, Math.abs(v0) / 1000 * 0.8);

        // 2. 利用 WAAPI 触发原生的指数减速滑行
        this.element.animate(
          [
            { transform: `translate3d(${this.currentTranslateX}px, 0, 0)` },
            { transform: `translate3d(${targetX}px, 0, 0)` }
          ],
          {
            duration: durationSeconds * 1000,
            easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
            fill: 'forwards'
          }
        ).finished.then(() => {
          this.currentTranslateX = targetX;
        });
      }
    });
  }
}

四、生产落地的避坑红线

  1. 瞬时反向手抖防范(Anti-flicker Threshold):用户在快速划过抬起手指的瞬间,由于生理肌腱回缩,手指可能会在抬起的前 5ms 发生极轻微的微量反向抽搐。如果采样窗口太窄,算出来的速度会逆向反弹。前文代码中的**加权滑动平均窗口(100ms Window)**正是消灭这种反向抽动的关键滤波防线。
  2. 硬件刷新率自适应(120Hz ProMotion 对齐):在 iPhone 或高端安卓手机的 120Hz 高刷屏上,触摸事件派发频率翻倍。物理速度计算必须基于 performance.now() 的真实高精度时间差,绝对禁止基于帧数固定步长累加。

把牛顿力学的严密逻辑融入每一次触摸,让像素在指尖的起伏如同真实世界的延伸,这正是全栈交互工程师追求的极致工匠精神。

Logo

一站式 AI 云服务平台

更多推荐