前言
淘宝拍照上线了新结果页后,原先的短列表进化成了电梯多楼层长列表结构。
根据交互要求,当用户点击楼层 tab时,需要将列表滚动到对应的位置,由于商品区块是支持分页加载的,当商品全部加载完成之后,商品区块会变得非常高,如果用户点击 tab 之后需要跨过商品区块,那么根据安卓原生的 scrollTo 实现,需要等待比较长的一段时间才能结束动画,对于用户来说成本较高,产品也无法接受。iOS 的列表组件默认限制了滚动时间,因此不需要做什么特殊处理。
除此之外,由于列表里的 ViewHolder 都是动态化的,每个坑位的高度在渲染完成之前是不确定的,因此当触发滚动时,Scroller 上一帧计算出来的滚动距离,下一帧由于坑位高度变化,已经不准确了,就会造成滚动停止后,停留在我们想要的位置上方或者下方。iOS 端由于实现方案特殊,在UI 渲染前已经把所有卡片的高度都计算好了,所以没有这方面的困扰。
有两个痛点需要我们解决
-
如何快速滚动到指定位置
-
如何精准滚动到指定位置
以下文章将会介绍这两个痛点的解决过程
快速滚动
视频中是原生的 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 为一屏的高度,当触发滚动时,我们有两种情况
-
列表向上滚动
-
列表向下滚动
为了防止误解,列表向上滚动代表 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的情况
实际高度偏小
实际高度偏大
针对以上两种情况,我用了以下几种办法尝试解决。
监听列表滚动
在 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 掉。
最后看下效果噢~
写在最后
对于用户体验,我们需要持续不断地优化,有些细微的点,优化之后,就能让用户体验提升一个台阶。遇到问题多看源码,深入了解原理之后,解决问题的灵感就会迸发出来。
文中的使用场景来自于淘宝拍照,欢迎大家多多使用噢。淘宝拍照,一拍就好。
团队介绍
我们是大淘宝技术搜索推荐移动端团队,负责集团核心电商搜索推荐,图像视频搜索业务研发、技术平台建设、新业务和前沿技术探索等工作,我们负责的业务拥有亿级流量,能为您提供巨大的机遇和成长空间,期待您的加入。感兴趣的同学可将简历发送到 taozi.ly@taobao.com