Picker组件的惯性滑动实现

Picker组件的惯性滑动实现

前两天修复了 tdesign-miniprogram 的一个 bug,Picker 组件快速滑动的时候会有重影。具体表现在:缓慢滑动的时候,Pickeritem 在抖动。一开始拿到问题猜测是不是 onTouchStart 的时候频繁 setData 导致的性能问题,后来研究了一下 onTouchStart 的实现,发现不是这个原因,原本的逻辑如下:

<view
    class="{{classPrefix}}__wrapper"
    style="transition: transform {{ duration }}ms cubic-bezier(0.215, 0.61, 0.355, 1); transform: translate3d(0, {{ offset }}px, 0)"
  >
   	...
  </view>
onTouchMove(event) {
      const { pickItemHeight } = this.data;
      const { StartY, StartOffset } = this;

      // touch偏移增量
      const touchDeltaY = event.touches[0].clientY - StartY;
      const deltaY = this.calculateViewDeltaY(touchDeltaY, pickItemHeight);

      this.setData({
        offset: range(StartOffset + deltaY, -(this.getCount() * pickItemHeight), 0),
        duration: DefaultDuration, // DefaultDuration: 240
      });
    },
   /**
   * 将屏幕滑动距离换算为视图偏移量 模拟渐进式滚动
   * @param touchDeltaY 屏幕滑动距离
   */
  calculateViewDeltaY(touchDeltaY: number, itemHeight: number): number {
    return Math.abs(touchDeltaY) > itemHeight ? 1.2 * touchDeltaY : touchDeltaY;
  }

问题很明显了,onTouchMove 频繁触发,每次计算出新的 offset 然后给 itemstyle,但是 duration240msonTouchMove 会在 240ms 内触发多次,导致上次的过度效果还没完成,又会重新进入新的过渡从而导致抖动。

原有的逻辑是通过滚动超过原本 1.2 * itemHeight,然后在 240ms 内完成过度来实现渐进式滚动的。那么有没有其它方法来实现渐进式滚动,网上找了一下资料,渐进式滚动类似于 iOS 上手指离开屏幕后的惯性滚动效果,这个就好比自然界中的惯性效果,更加符合人的直觉。

惯性滚动的核心部分在于两个点:

  1. 惯性滚动的速度线性减慢直到停止
  2. 滚动的距离和速度取决于手滑动的速度

第一个好理解,给一个负的加速度。第二点在于如何来判断滚动的速度快慢:手指滑动的距离和持续时间,决定了惯性速度的快慢。换句话说手在短时间内滑动很大一段距离,就代表滑动的快。还有一点需要注意,持续时间越短,速度相对越大,如果停留的时间非常短或者是缓慢滑动是不应该执行惯性滑动的,一般认为停留的时间大于 300ms 且滑动距离大于 15 才执行。

理解了这个核心思路,就该用代码实现效果了。滚动的实现离不开三个方法,onTouchStartonTouchMoveonTouchEnd

onTouchStart:记录第一次触摸屏幕的 timestamp
onTouchMove:实时更新 offset
onTouchEnd:判断是否需要惯性滚动,如果需要计算滚动速度并执行滚动。

// 动画持续时间
const ANIMATION_DURATION = 1000;
// 和上一次move事件间隔小于INERTIA_TIME
const INERTIA_TIME = 300;
// 且距离大于`MOMENTUM_DISTANCE`时,执行惯性滚动
const INERTIA_DISTANCE = 15;

const range = function (num: number, min: number, max: number) {
  return Math.min(Math.max(num, min), max);
};

const momentum = (distance: number, duration: number) => {
  let nDistance = distance;
  // 惯性滚动的速度
  const speed = Math.abs(nDistance / duration);
  // 加速度经验值: 0.005
  // 惯性滚动的距离,注意在上下滑动时偏移量是有正负的
  nDistance = (speed / 0.005) * (nDistance < 0 ? -1 : 1);
  return nDistance;
};
lifetimes = {
  created() {
    this.StartY = 0;
    this.StartOffset = 0;
    this.startTime = 0;
  },
};
methods = {
  onTouchStart(event) {
    this.StartY = event.touches[0].clientY;
    this.StartOffset = this.data.offset;
    this.startTime = Date.now();
    this.setData({
      duration: 0,
    });
  },
  onTouchMove(event) {
    const { StartY, StartOffset } = this;
    const { pickItemHeight } = this.data;
    // 偏移增量
    const deltaY = event.touches[0].clientY - StartY;
    const newOffset = range(StartOffset + deltaY, -(this.getCount() * pickItemHeight), 0);
    this.setData({
      offset: newOffset,
    });
  },

  onTouchEnd(event) {
    const { offset, labelAlias, valueAlias, columnIndex, pickItemHeight } = this.data;
    const { options } = this.properties;
    const { startTime } = this;
    if (offset === this.StartOffset) {
      return;
    }

    // 判断是否需要惯性滚动
    let distance = 0;
    const move = event.changedTouches[0].clientY - this.StartY;
    const moveTime = Date.now() - startTime;
    if (moveTime < INERTIA_TIME && Math.abs(move) > INERTIA_DISTANCE) {
      distance = momentum(move, moveTime);
    }

    // 调整偏移量
    const newOffset = range(offset + distance, -this.getCount() * pickItemHeight, 0);
    const index = range(Math.round(-newOffset / pickItemHeight), 0, this.getCount() - 1);
    this.setData({
      offset: -index * pickItemHeight,
      duration: ANIMATION_DURATION,
      curIndex: index,
    });
    if (index === this._selectedIndex) {
      return;
    }
    this._selectedIndex = index;

    // ... 省略事件触发逻辑
  },
}

另外改了组件逻辑,还得同步改一下测试用例,原本测试用例模拟渐进滚动用的 1.2 * pickItemHeight,新的逻辑会导致部分用例不通过。顺便了解了组件的测试用例方法:大致就是验证 stylepropsevent。在 event 这里用 dispatchEvent('tap') 来模拟点击,.dispatchEvent(touchMove, { touches: [{ x, y }]}) 模拟滚动,同时还要注意要给动画执行时间,比如 sleep(1000) 后再去 expect

最后还有一点不足的是,在 onTouchEnd 后会立马算出 selectedIndex,导致最终的滚动结位置的 item 有了 active 效果,但是滚动还在进行中,视觉上稍微有点不统一,暂时没有好的解决办法。

看了一圈小程序组件库的 Picker 组件,很多都没有渐进式滚动效果,体验效果属实一般,动画直来直往,往往细节决定了用户的体验好坏。效果最好的一个是 NutUI,不仅有合理的动画效果,而且因为跨端的原因,在编译成小程序后用的微信小程序原生 Picker,滚动过程中会有震动的效果,这是原生 Picker 才有的能力。还有一点,在 active 效果的实现上,Picker 上覆盖了一层 mask,中间的一项始终保持 active 效果,这样就不用计算 selectedIndex 了。

posted on 2025-03-12 00:24  吃早餐叫我  阅读(39)  评论(0)    收藏  举报