物理学的 H5 应用:模拟惯性滑动

在移动端 H5 中,时间选择器(date-picker)、省市区选择器(area-picker)等组件经常会使用这样的交互效果:

微信原生 date-picker 效果

这个 gif 是在【微信钱包 - 账单】中录制的 ios 原生时间选择器。可见,当用户手指在选择器上先是滑动再从屏幕上移开,内容会继续保持一段时间的滚动效果,并且滚动的速度和持续的时间是与滑动手势的强烈程度成正比。这种交互思路源于 ios 系统原生元素的滚动回弹(momentum-based scrolling),来看 H5 的一个普通列表在 ios 上的滚动表现:

社区上大部分的移动端组件库的选择器组件都采取了这种交互方式,看看效果:

weui 的选择器实现了惯性滑动,但滑动动画结束得有点突兀,效果一般。

vant 的选择器压根没有做惯性滑动,当手指从屏幕上移开后,选择器的滑动会立刻停止。可见这样的交互体验是比较差的。

接下来我会从设计层面剖析和模拟惯性滑动的交互效果。

物理学应用

不难想象,惯性滑动非常贴合现实生活中的一些场景,如汽车刹车等。除此之外,与物理力学中的滑块模型也十分相似,由此我会参考滑块模型来剖析惯性滑动的全过程。

惯性 来源于物理学中的惯性定律(即 牛顿第一定律):一切物体在没有受到力的作用的时候,运动状态不会发生改变,物体所拥有的这种性质就被称为惯性。我们不妨把惯性滑动模拟成滑动滑块然后释放的过程(以下讨论中用户滑动的目标皆模拟成 滑块),主要划分为两个阶段:

  • 用户滑动滑块使其从静止开始做加速运动;
  • 用户释放滑块使其只在摩擦力的作用下继续滑动,直至静止;

惯性滑动距离

描述滑块的惯性滑动,首先需要求出滑动的距离。在上述二阶段中,滑块受摩擦力F_{摩}匀减速直线运动。假设滑动距离为s_{2},初速度为v_{0},末速度为0m/s。根据位移公式

s_{2} = \frac{0 + v_{0}}{2}t_{2}

加速度公式

a = \frac{0 - v_{0}}{t_{2}}

可以算出惯性滑动距离

s_{2} = - \frac{v_{0}^2 }{2a}

由于匀减速运动的加速度为负,不妨设一个加速度常量A,使其满足A = -2a,那么

s_{2} = \frac{v_{0}^2}{A}

这里A为正数。也就是说,我们只需要求出初始速度即可。

实际计算时,v_{0}^2会导致计算出的惯性滑动距离过大,因此公式调整为s_{2} = \frac{v_{0}}{A}

关注第一个阶段,假设用户滑动滑块的距离为s_{1},滑动的持续时间是t_{1},那么二阶段的初速度v_{0}可以根据位移公式求得

v_{0} = \frac{2s_{1}}{t_{1}}

综上,求惯性滑动的距离我们需要记录用户滑动滑块的 距离s_{1}持续时间t_{1},并设置一个合理的 加速度常量A

经测试,加速度常量的合适值为 A=0.003

注意,这里的距离和持续时间并不是用户滑动滑块的总距离和时长,而是触发惯性滑动范围内的距离和时长,详见【惯性滑动的启动条件】。

惯性滑动速度曲线

针对二阶段的匀减速直线运动,时间段\Delta t产生的位移差\Delta s = at^2,其中a<0。也就是说时间越往后,同等时间间距下通过的位移越来越小,也就是动画的推进速度越来越慢。

这与 CSS3 transition-timing-function 中的 ease-out 速度曲线相吻合,ease-out (即 cubic-bezier(0, 0, .58, 1))的贝塞尔曲线为

上图来自 在线绘制贝塞尔曲线网站。图表中的纵坐标是指 动画推进的进程;横坐标是指 时间;原点坐标为 (0, 0),终点坐标为 (1, 1),假设动画持续时间为2秒,(1, 1)坐标点则代表离动画开始2秒时动画执行完毕(100%)。根据图表可以得出,时间越往后动画进程的推进速度越慢,符合匀减速直线运动的特性。

然而这样的速度曲线过于线性平滑,减速效果不明显。我们基于 ios 滚动回弹的效果,调整贝塞尔曲线的参数为 cubic-bezier(.17, .89, .45, 1)

回弹

滑块滑动不是无边界的,我们来考虑这样的场景:当滑块向下滑动,其顶部正要接触容器上边界时速度还没有降到0m/s,此时如果让滑块瞬间停止运动,这样的交互效果是不理想的。

我们可以把上边界想象成一条与滑块紧密贴合的固定弹簧,当滑块到达临界点而速度还没有降到0m/s时,滑块会继续滑动并拉动弹簧使其往下形变,同时会受到弹簧的反拉力作减速运动(动能转化为内能);当滑块速度降为0m/s,此时弹簧的形变量最大,由于弹性特质弹簧会恢复原状(内能转化成动能),从而拉动滑块反向运动

回弹过程也可以分为两个阶段:

  • 滑块拉动弹簧作变减速运动。此阶段滑块受摩擦力F_{摩}和越来越大的弹簧反拉力F_{弹}共同作用,加速度越来越大,所以速度降为0m/s的时间非常短;
  • 弹簧恢复原状,拉动滑块作先变加速后变减速运动。此阶段滑块受到的摩擦力F_{摩}和越来越小的弹簧拉力F_{弹}相互抵消,刚开始F_{弹}>F_{摩},滑块作加速度越来越小的变加速运动;随之F_{弹}<F_{摩},滑块作加速度越来越大的变减速运动,直至静止。这里为了交互效果我们可以营造一个理想状态:滑块静止时弹簧刚好恢复形变。

回弹距离

根据上述分析,回弹的第一阶段作加速度越来越大的变减速直线运动,设此阶段的初速度为v_{1},可以与v_{0}建立以下关系

l_{滑块} = \frac{v_{1}^2 - v_{0}^2}{2a}

那么回弹距离为

S_{回弹}=\int_{0}^{t}v(t)dt

微积分都来了,简直没法算好吧…

我们可以根据运动模型来简化S_{回弹}的计算,由于该阶段的加速度大于 非回弹惯性滑动 的加速度,设 非回弹惯性滑动 的总距离为S_{滑},那么

S_{回弹}<S_{滑}-l_{滑块}

所以可以设置一个合理的常量B,使其满足

S_{回弹} = \frac{S_{滑}-l_{滑块}}{B}

经测试,常量B的合理取值为 10。

回弹速度曲线

整个触发回弹的惯性滑动模型包括三个运动阶段:

然而把 阶段a 和 阶段b 描绘成 CSS 动画是有一定复杂度和风险的:

  • 阶段b 中的变减速运动难以描绘;
  • 两个阶段运动方向相同但动画速度曲线不连贯,容易造成用户体验的断层;

出于简化的考虑,可以将 阶段a、b 合并为一个运动阶段:

对于合并后的 阶段a 末段,由于反向加速度越来越大,因此滑块减速的效率会比 非回弹惯性滑动 同期更大,对应的贝塞尔曲线末段也会更陡,参数调整为 cubic-bezier(.25, .46, .45, .94)

在 阶段b 中,滑块先变加速后变减速,尝试 ease-in-out 的动画曲线:

可以看出,由于 阶段b 初始的 ease-in 曲线使 阶段a、b 的衔接段稍有停留,效果体验一般。所以我们选择只描绘变减速运动这一段,调整贝塞尔曲线为 cubic-bezier(.165, .84, .44, 1)

由于 mp4 转 gif 格式会掉帧,所以示例效果看起来会有点卡顿,建议直接体验 demo

动画时长

PS:以下取值都是基于对 ios 滚动回弹实例的测量。

一次惯性滑动可能会出现两种情况:

  • 没有触发回弹
    滑动动画的持续时间为 2500ms

  • 触发回弹
    阶段a 中,当S_{回弹}大于某个阈值时,为 强回弹,动画时长设为 400ms,反之为 弱回弹,时长设为 800ms
    阶段b 持续时间为 500ms

惯性滑动启停

  • 启动条件

惯性滑动的启动需要有足够的动量。我们可以简单地认为,当用户滑动的距离足够大(大于 15px)和持续时间足够短(小于 300ms)时,即可产生惯性滑动。也就是说,最后一次 touchmove 事件触发的时间和 touchend 事件触发的时间间隔小于 300ms,且两者产生的距离差大于 15px 时认为启动惯性滑动。

  • 暂停时机

当惯性滑动未结束(包括处于回弹过程),用户再次触碰滑块时会暂停滑块的运动。原理上是通过 getComputedStylegetPropertyValue 方法获取当前的 transform: matrix() 矩阵值,抽离出水平 y 轴偏移量后重新调整 translate 的位置。

完整代码

demo 基于 vuejs 实现,预览地址:https://codepen.io/JunreyCen/pen/arRYem

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=0">
    <style>
      body, ul {
        margin: 0;
        padding: 0;
      }
      ul {
        list-style: none;
      }
      .wrapper {
        position: absolute;
        top: 50%;
        left: 0;
        right: 0;
        margin: 0 auto;
        height: 80%;
        width: 80%;
        max-width: 300px;
        max-height: 500px;
        border: 1px solid #000;
        transform: translateY(-50%);
        overflow: hidden;
      }
      .list {
        background-color: #70f3b7;
      }
      .list-item {
        height: 40px;
        line-height: 40px;
        width: 100%;
        text-align: center;
        border-bottom: 1px solid #ccc;
      }
    </style>
  </head>
  <body>
    <div id="app"></div>
  
    <template id="tpl">
      <div
        class="wrapper"
        ref="wrapper"
        @touchstart.prevent="onStart"
        @touchmove.prevent="onMove"
        @touchend.prevent="onEnd"
        @touchcancel.prevent="onEnd"
        @mousedown.prevent="onStart"
        @mousemove.prevent="onMove"
        @mouseup.prevent="onEnd"
        @mousecancel.prevent="onEnd"
        @mouseleave.prevent="onEnd"
        @transitionend="onTransitionEnd">
        <ul
          class="list"
          ref="scroller"
          :style="scrollerStyle">
          <li 
            class="list-item"
            v-for="item in list">
            {{item}}
          </li>
        </ul>
      </div>
    </template>

    <script src="https://cdn.jsdelivr.net/npm/vue"></script>
    <script>
      new Vue({
        el: '#app',
        template: '#tpl',
        computed: {
          list() {
            const list = [];
            for (let i = 0; i < 100; i++) {
              list.push(i);
            }
            return list;
          },
          scrollerStyle() {
            return {
              'transform': `translate3d(0, ${this.offsetY}px, 0)`,
              'transition-duration': `${this.duration}ms`,
              'transition-timing-function': this.bezier,
            };
          },
        },
        data() {
          return {
            wrapper: null,
            scroller: null,
            minY: 0,
            maxY: 0,
            wrapperHeight: 0,
            offsetY: 0,
            duration: 0,
            bezier: 'linear',
            startY: 0,
            pointY: 0,
            startTime: 0,                 // 惯性滑动范围内的 startTime
            momentumStartY: 0,            // 惯性滑动范围内的 startY
            momentumTimeThreshold: 300,   // 惯性滑动的启动 时间阈值
            momentumYThreshold: 15,       // 惯性滑动的启动 距离阈值
            isStarted: false,             // start锁
          };
        },
        mounted() {
          this.$nextTick(() => {
            this.wrapper = this.$refs.wrapper;
            this.scroller = this.$refs.scroller;
            const { height: wrapperHeight } = this.wrapper.getBoundingClientRect();
            const { height: scrollHeight } = this.scroller.getBoundingClientRect();
            this.wrapperHeight = wrapperHeight;
            this.minY = wrapperHeight - scrollHeight;
          });
        },
        methods: {
          onStart(e) {
            const point = e.touches ? e.touches[0] : e;
            this.isStarted = true;
            this.duration = 0;
            this.stop();
            this.pointY = point.pageY;
            this.momentumStartY = this.startY = this.offsetY;
            this.startTime = new Date().getTime();
          },
          onMove(e) {
            if (!this.isStarted) return;
            const point = e.touches ? e.touches[0] : e;
            const deltaY = point.pageY - this.pointY;
            // 浮点数坐标会影响渲染速度
            let offsetY = Math.round(this.startY + deltaY);
            // 超出边界时增加阻力
            if (offsetY < this.minY || offsetY > this.maxY) {
              offsetY = Math.round(this.startY + deltaY / 3);
            }
            this.offsetY = offsetY;
            const now = new Date().getTime();
            // 记录在触发惯性滑动条件下的偏移值和时间
            if (now - this.startTime > this.momentumTimeThreshold) {
              this.momentumStartY = this.offsetY;
              this.startTime = now;
            }
          },
          onEnd(e) {
            if (!this.isStarted) return;
            this.isStarted = false;
            if (this.isNeedReset()) return;
            const absDeltaY = Math.abs(this.offsetY - this.momentumStartY);
            const duration = new Date().getTime() - this.startTime;
            // 启动惯性滑动
            if (duration < this.momentumTimeThreshold && absDeltaY > this.momentumYThreshold) {
              const momentum = this.momentum(this.offsetY, this.momentumStartY, duration);
              this.offsetY = Math.round(momentum.destination);
              this.duration = momentum.duration;
              this.bezier = momentum.bezier;
            }
          },
          onTransitionEnd() {
            this.isNeedReset();
          },
          momentum(current, start, duration) {
            const durationMap = {
              'noBounce': 2500,
              'weekBounce': 800,
              'strongBounce': 400,
            };
            const bezierMap = {
              'noBounce': 'cubic-bezier(.17, .89, .45, 1)',
              'weekBounce': 'cubic-bezier(.25, .46, .45, .94)',
              'strongBounce': 'cubic-bezier(.25, .46, .45, .94)',
            };
            let type = 'noBounce';
            // 惯性滑动加速度
            const deceleration = 0.003;
            // 回弹阻力
            const bounceRate = 10;
            // 强弱回弹的分割值
            const bounceThreshold = 300;
            // 回弹的最大限度
            const maxOverflowY = this.wrapperHeight / 6;
            let overflowY;

            const distance = current - start;
            const speed = 2 * Math.abs(distance) / duration;
            let destination = current + speed / deceleration * (distance < 0 ? -1 : 1);
            if (destination < this.minY) {
              overflowY = this.minY - destination;
              type = overflowY > bounceThreshold ? 'strongBounce' : 'weekBounce';
              destination = Math.max(this.minY - maxOverflowY, this.minY - overflowY / bounceRate);
            } else if (destination > this.maxY) {
              overflowY = destination - this.maxY;
              type = overflowY > bounceThreshold ? 'strongBounce' : 'weekBounce';
              destination = Math.min(this.maxY + maxOverflowY, this.maxY + overflowY / bounceRate);
            }

            return {
              destination,
              duration: durationMap[type],
              bezier: bezierMap[type],
            };
          },
          // 超出边界时需要重置位置
          isNeedReset() {
            let offsetY;
            if (this.offsetY < this.minY) {
              offsetY = this.minY;
            } else if (this.offsetY > this.maxY) {
              offsetY = this.maxY;
            }
            if (typeof offsetY !== 'undefined') {
              this.offsetY = offsetY;
              this.duration = 500;
              this.bezier = 'cubic-bezier(.165, .84, .44, 1)';
              return true;
            }
            return false;
          },
          stop() {
            // 获取当前 translate 的位置
            const matrix = window.getComputedStyle(this.scroller).getPropertyValue('transform');
            this.offsetY = Math.round(+matrix.split(')')[0].split(', ')[5]);
          },
        },
      });
    </script>
  </body>
</html>

Reference

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,718评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,683评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,207评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,755评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,862评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,050评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,136评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,882评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,330评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,651评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,789评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,477评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,135评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,864评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,099评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,598评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,697评论 2 351