安卓长列表下快速精准锚定的解决方案
隽弦2022-02-18

前言

淘宝拍照上线了新结果页后,原先的短列表进化成了电梯多楼层长列表结构。

根据交互要求,当用户点击楼层 tab时,需要将列表滚动到对应的位置,由于商品区块是支持分页加载的,当商品全部加载完成之后,商品区块会变得非常高,如果用户点击 tab 之后需要跨过商品区块,那么根据安卓原生的 scrollTo 实现,需要等待比较长的一段时间才能结束动画,对于用户来说成本较高,产品也无法接受。iOS 的列表组件默认限制了滚动时间,因此不需要做什么特殊处理。

除此之外,由于列表里的 ViewHolder 都是动态化的,每个坑位的高度在渲染完成之前是不确定的,因此当触发滚动时,Scroller 上一帧计算出来的滚动距离,下一帧由于坑位高度变化,已经不准确了,就会造成滚动停止后,停留在我们想要的位置上方或者下方。iOS 端由于实现方案特殊,在UI 渲染前已经把所有卡片的高度都计算好了,所以没有这方面的困扰。

有两个痛点需要我们解决

  1. 如何快速滚动到指定位置
  2. 如何精准滚动到指定位置

以下文章将会介绍这两个痛点的解决过程

快速滚动

视频中是原生的 scroll 效果,当从列表尾部滚动回头部时,整个过程耗时非常长。

Solution 1.0

一般情况下,我们要滚动 RecyclerView,都是使用安卓中提供的 LinearSmoothScroller 组件,通过查看 LinearSmoothScroller 的几个 api,我们会发现有以下这个方法。

     /**
     * Calculates the time it should take to scroll the given distance (in pixels)
     */
    protected int calculateTimeForScrolling(int dx) {
        return (int) Math.ceil(Math.abs(dx) * getSpeedPerPixel());
    }

根据 api 介绍,这个方法是用来计算滚动时间的,那么如果我们把滚动时间做一定程度的打折,就可以提高滚动速度

@Override
  protected int calculateTimeForScrolling(int dx) {
    if (Math.abs(dx) > recyclerView.getMeasuredHeight()) {
      return (int) (super.calculateTimeForScrolling(dx) * 0.2f);
    } else {
      return super.calculateTimeForScrolling(dx);
    }
  }

针对这个 API,我们做点简单的处理,如果某一次滚动距离超过一屏,那么滚动速度x5,否则就按原速度。从视频中可以看到,当从列表头滚动到列表尾部时,滚动很快就停止了,貌似符合了我们的要求。但是仔细看会发现,每一次滚动之间,会有速度不连贯的情况,且滚动停止时的减速时间太少,给人感觉动画比较生硬。在 demo 里可能效果还是可以接受的,但是放到实际场景中又会发现其他问题。

如视频中所看到的,当从列表尾部滚动到列表顶部时,中间出现了白屏的情况。muise 针对速度做了大量的优化,用户正常手速的快速滚动基本是看不到白屏的情况的。但是在将滚动速度x5之后,一切就变得不一样了。ViewHolder 存在复用的情况,当 ViewHolder 上屏时,触发 onBind,在 onBind 中触发 js 执行,此时会先显示占位图,等js 执行结束才会将占位图隐藏。那么当滚动速度很快时,一个 ViewHolder 的 js 执行还未结束,就又离开屏幕,且又被复用了,那么就会造成 js 执行一直不结束,导致占位图一直显示。所以 1.0 方案pass~

Solution 2.0

为了仿照 iOS 的 API(限定 x ms 内滚动结束),我还想出了另一种方案,当滚动触发时,刚开始正常滚动,同时设置一个延时任务,当滚动在 x ms 内没结束时,直接停止滚动,然后触发一个无动画滚动,瞬间到达目标位置。

视频中的 demo 限制了滚动时间在300ms,当300ms 时间一到,就立即触发无动画滚动,到达指定位置。这个方案中间滚动过程还算可以接受,只是停止那一下过于生硬。此方案相对1.0有一个好处就是,由于滚动最后会触发一次无动画滚动,会直接跳过中间的位置,对于动态化长列表来说,可以省略中间无用的 cell 渲染,流畅度会更好一些,同时避免白屏问题。

Solution 3.0

那么有没有一种方案,既没有1.0的白屏和卡顿问题,也没有2.0停止生硬的问题呢?

我们可以设想这么一种滚动方式,当滚动距离比较近时,使用系统的原生滚动方式,当滚动距离很远时,先无动画滚动到距离指定位置 x 像素,然后再使用系统原生滚动方式滚动过去。

下面来讲一下实操过程。我们分两大块来分析

目标在屏幕内

StaggeredGridLayoutManager manager = (StaggeredGridLayoutManager) recyclerView.getLayoutManager();
View targetView = manager.findViewByPosition(targetPosition);
if (targetView != null) {
  smoothScroll(targetPosition, offset);
  return;
}

这个比较好实现,通过 findViewByPosition方法,找到对应 position 的 View,如果不为空,说明 cell 在屏幕内,那么我们直接使用LinearSmoothScroller 滚动过去即可。

目标不在屏幕内

目标距离超过 x

我们指定x 为一屏的高度,当触发滚动时,我们有两种情况

  1. 列表向上滚动
  2. 列表向下滚动

为了防止误解,列表向上滚动代表 scrollY 减小,向下滚动代表 scrollY 增加

列表向上滚动,说明目标位置在屏幕下方

此时我们需要把目标 cell 的位置卡在屏幕底下,使用方法

layoutManager.scrollToPositionWithOffset(targetPosition,x);//x 为我们指定的距离,本文中是1.5屏的高度

列表向上滚动,说明目标位置在屏幕上方

此时我们需要把目标 cell 的位置卡在屏幕上方x 距离的位置,使用方法

layoutManager.scrollToPositionWithOffset(targetPosition,-x);//x 为我们指定的距离,本文中是1.5屏的高度

当无动画滚动完成后,我们再触发一次默认的 smooth scroll 即可,什么时候触发 smooth scroll 呢?我们知道,在调用scrollToPositionWithOffset时,会触发一次 requestLayout,那么我们只需要监听layout 结束就行。

public class LayoutManager{
  /**
   * Called after a full layout calculation is finished.
   */
  public void onLayoutCompleted(RecyclerView.State state) {
  }
}

需要实现 onLayoutCompleted 方法,在此方法中判断是否需要触发 smooth scroll 即可。

目标距离不超过 x

这种情况直接触发默认的 smooth scroll 即可

判断目标距离是否超过 x

那么如何判断目标位置距离当前屏幕滚动位置的距离呢?让我们回归到源码本身,看看LinearSmoothScroller是如何判断每一帧要滚动多少距离的。

public class LinearSmoothScroller {
  protected void onTargetFound(View targetView, RecyclerView.State state, RecyclerView.SmoothScroller.Action action) {
    //...
    final int dy = calculateDyToMakeVisible(targetView, getVerticalSnapPreference());
    //...
  }

  protected void onSeekTargetStep(int dx, int dy, RecyclerView.State state, RecyclerView.SmoothScroller.Action action) {
    //...
    if (mInterimTargetDx == 0 && mInterimTargetDy == 0) {
      updateActionForInterimTarget(action);
    }
  }

  void start(RecyclerView recyclerView, RecyclerView.LayoutManager layoutManager) {

    //...
    mRecyclerView = recyclerView;
    mLayoutManager = layoutManager;
    //...
  }

  //
  @Nullable
  public PointF computeScrollVectorForPosition(int targetPosition) {
    //...
  }
  
  //第一帧触发 dy = 0
  void onAnimation(int dx, int dy) {
    final RecyclerView recyclerView = mRecyclerView;
    //...
    //目标位置不在屏幕内,触发一次滚动1px,这部分逻辑在新的 RecyclerView 中有,手淘用的v7:26.1.0版本,没有这部分逻辑
    if (mPendingInitialRun && mTargetView == null && mLayoutManager != null) {
      PointF pointF = computeScrollVectorForPosition(mTargetPosition);
      if (pointF != null && (pointF.x != 0 || pointF.y != 0)) {
        recyclerView.scrollStep(
                (int) Math.signum(pointF.x),
                (int) Math.signum(pointF.y),
                null);
      }
    }
    if (mTargetView != null) {
      if (getChildPosition(mTargetView) == mTargetPosition) {
        //目标 Cell 已经在屏幕内,停止seek
        onTargetFound(mTargetView, recyclerView.mState, mRecyclingAction);
      } else {
        mTargetView = null;
      }
    }
    if (mRunning) {
      //此时目标cell 还不在屏幕内
      onSeekTargetStep(dx, dy, recyclerView.mState, mRecyclingAction);
      //...
    }
  }
}

可以看到,RV 的 scroller 工作原理就是,先判断是否找到目标 View,如果找到了,就计算目标 View 的位置,然后算出一个最后的滚动距离;如果没找到目标 View,那么就算出一个大的滚动距离,触发 smooth scroll,在滚动的下一帧继续上述逻辑。

那么了解了原理以后,我们可以尝试着模仿系统的操作,一开始我的做法是:监听onSeekTargetStep和onTargetFound,如果前者触发了,说明没找到目标 View,那么可以认为滚动距离超出了X,如果后者先触发了,说明滚动距离没超过 X。这个方案在【x == recyclerView.getMeasuredHeight()】&& 比较新的RecyclerView 版本时有效。

比较新的版本指的是 RecyclerView 中有 scrollStep 方法,手淘中的 RecyclerView 没有这个方法。

你可能会问,为啥onSeekTargetStep触发就可以认为是超过了 X?

深入 RecyclerView 源码发现,StaggeredGridLayoutManager 中有个 LayoutState 的概念

class LayoutState {
    /**
     * This is the target pixel closest to the start of the layout that we are trying to fill
     */
    int mStartLine = 0;

    /**
     * This is the target pixel closest to the end of the layout that we are trying to fill
     */
    int mEndLine = 0;
    /**
     * @return true if there are more items in the data adapter
     */
    boolean hasMore(RecyclerView.State state) {
        return mCurrentPosition >= 0 && mCurrentPosition < state.getItemCount();
    }

    /**
     * Gets the view for the next element that we should render.
     * Also updates current item index to the next item, based on {@link #mItemDirection}
     */
    View next(RecyclerView.Recycler recycler) {
        final View view = recycler.getViewForPosition(mCurrentPosition);
        mCurrentPosition += mItemDirection;
        return view;
    }
}

mStartLine = -列表高度,mEndLine = 列表高度,用图表示就是

当触发滚动时,在第一次调用动画回调时,会执行以下逻辑

if (mPendingInitialRun && mTargetView == null && mLayoutManager != null) {
      PointF pointF = computeScrollVectorForPosition(mTargetPosition);
      if (pointF != null && (pointF.x != 0 || pointF.y != 0)) {
        recyclerView.scrollStep(
                (int) Math.signum(pointF.x),
                (int) Math.signum(pointF.y),
                null);
      }
    }

其中 scrollStep 会调用 fill 方法,逻辑简单讲就是,会根据滚动距离计算从 mStartLine 到 mEndLine 区间内的 View,所以如果要向上滚动,因为 scrollStep 取 View 会往上取负一屏,所以如果目标位置在 x 距离内,就一定能拿到 targetView。如果是向下滚动,由于目标位置在屏幕外,本身就符合了距离大于 x 的条件,所以也同样生效。

但是上面也讲了,这个方案在手淘里不适用,且如果 x 大于 recyclerView 高度时(比如我想要让滚动的距离更长一点,在1.5屏开始滚动),就不能用了,我们需要一个更加通用的方案。

虽然系统的回调不能直接用,但是,根据 scrollStep 的逻辑,我们可以尝试一下启发自己。有没有一种可能,在用户UI不感知的情况下, 我们即可以向上取到 x 像素的 View,向下同样也能取到。

根据这个思路,新的方案诞生了。我们通过 scrollBy 方法,触发滚动方向上的 View 加载。例如我们设定的距离是1.5屏高度,那就往滚动方向预取两屏的 View,看是否能拿到 targetPosition 上的 View,如果取到了,则计算一下targetPosition 上的 View 置顶需要滚动多少距离。

for (int i = 0; i < count; i++) {
  //这里通过 scrollStep 方法,触发滚动,然后尝试去取 targetView
  scrollStep(step);
  scrollY += consumedY;
  targetView = manager.findViewByPosition(targetPosition);
  if (targetView != null) {
    break;
  }
}

若 targetView 取不到,则代表目标位置距离我们当前屏幕超过了两屏,则需要触发一次无动画滚动。

若 targetView 取到了,则计算一下需要的滚动距离。

if (targetView != null) {
  //如果取到了 targetView,那么就计算 targetView 的距离
  FakeScroller scroller = new FakeScroller(recyclerView.getContext(), offset, recyclerView);
  int y = scroller.calculateDyToMakeVisible(targetView, scroller.getVerticalSnapPreference());
  if (!scrollUp || offset>0) {
    //这里矫正一次,算出真正的滚动距离,列表尾部和头部可能会存在无法再滚动的情况
    //矫正的原理是,算出 y 后,让列表在滚动-y 值,看能滚动多少,如果 -y 值能全部滚动完,说明目标 view
    //距离当前位置至少大于等于 scrollY,则矫正后的 y 值不变
    //如果-y 值滚动不完,那么算出targetView 完全显示需要滚动多少距离,如果小于 distance,则触发 smooth scroll
    //如果大于 distance,则需要走即时滚动
    scrollStep(-y);
    y = -consumedY;
    scrollStep(-consumedY);
  }
  dy = y - scrollY;
}

最后算出来dy 就是我们需要滚动的距离,判断下 dy 是否超过我们预设的值即可。

下面讲一下这个距离的计算逻辑。本质上就是计算从当前屏幕位置,让目标 View 滚动到 offset 位置,要滚动多少距离,然后判断这个距离是否超过 distance

CASE 1 - 目标在屏幕下方

CASE 2 -目标在屏幕上方

为了书写方便,代码中统一用 dy = y - scrollY,最终判定时用绝对值包住。

以上我们解决了快速滚动的痛点~

精准滚动

由于列表中的卡片都是动态化的,所以系统计算的距离会不准,会存在两种情况

卡片实际高度相对占位高度

系统计算距离偏大(小)

停留位置相对顶部

实际高度偏小

偏大

偏靠上

实际高度偏大

偏小

偏靠下

以下展示列表定位第五十个50的情况

实际高度偏小

实际高度偏大

针对以上两种情况,我用了以下几种办法尝试解决。

监听列表滚动

@Override
  public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
    if (!scrolling) {
      return;
    }
    //...
    View targetView = targetHolder == null ? null : targetHolder.itemView;
    if (targetView != null && (getViewTop(targetView) <= recyclerView.getMeasuredHeight() || targetView.getBottom() >= 0)) {
      if (dy > 0 && getViewTop(targetView) <= scrollOffset || dy < 0 && getViewTop(targetView)  >= scrollOffset) {
        recyclerView.stopScroll();
        correctPosition();
      }
    }
  }

在 onScrolled 回调中,获取目标 View 的位置,当发现目标 View 已经超过停止线时,立刻停止滚动,触发位置矫正。

监听滚动状态变化

@Override
  public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
    if (!scrolling) {
      return;
    }
    //...
    if (newState == RecyclerView.SCROLL_STATE_IDLE && targetHolder != null) {
      startObserve = true;
      correctPosition();
    } else if (newState == RecyclerView.SCROLL_STATE_IDLE) {
      //targetHolder 是空的
      startObserve = true;
      scrollToPositionInner(targetPosition, false, scrollOffset);
    }
  }

当列表停止时,如果目标 View 已在屏幕上,则开始位置矫正。如果目标 View 不在屏幕上,则需要补充滚动一次

监听布局变化

@Override
public void onGlobalLayout() {
  //...
  if (targetHolder != null) {
    if (getViewTop(targetHolder.itemView) != scrollOffset) {
      scrollToPosition(targetPosition, false, scrollOffset);
    }
  }
}

这个主要用来应对列表滚动完成以后,高度又发生了改变的场景。当高度变化,且目标 View 在屏幕上时,计算目标 View 位置,若目标 View 不在停止线上,则矫正位置。

需要注意的是,如果是快速滚动配合精准滚动使用,需要在调用快速滚动的smoothScrollToPosition方法前,设置个标志位,让精准滚动这里的 onScrolled 和 onScrollStateChanged 直接 return 掉。

最后看下效果噢~

写在最后

对于用户体验,我们需要持续不断地优化,有些细微的点,优化之后,就能让用户体验提升一个台阶。遇到问题多看源码,深入了解原理之后,解决问题的灵感就会迸发出来。

文中的使用场景来自于淘宝拍照,欢迎大家多多使用噢。淘宝拍照,一拍就好。