前言
在9.0以前版本中首页没有做AppBarLayout与底部RecyclerView的Fling连接处理,导致在AppBarLayout往上Fling时当滚动到AppBarLayout底部时会立即停住,导致动画会比较生硬,当我们在9.0新版首页改版时有用户反馈这块的问题,于是我们花时间进行了一些优化处理,下面先看一下老版本与新版本首页效果的对比。
可以很明显的看到老版本在滚动到AppBarLayout底部时瞬间停住,给人一种很生硬的感觉,下面我们就来讲一讲如何进行优化。
问题分析
为了搞清楚为什么会出现这样的问题,我们分析了一下AppBarLayout的源码。下面是一个大致的流程图:
下面我们进行详细的源码分析:
首先AppBarLayout之所以可以折叠其实是依赖了CoordinatorLayout的能力,用户事件会被CoordinatorLayout感知然后传递给AppBarLayout的Behavior,AppBarLayout的Behavior继承自HeaderBehavior,我们阅读onTouchEvent方法,发现其处理fling的代码如下:
case MotionEvent.ACTION_UP:
if (mVelocityTracker != null) {
mVelocityTracker.addMovement(ev);
mVelocityTracker.computeCurrentVelocity(1000);
float yvel = mVelocityTracker.getYVelocity(mActivePointerId);
fling(parent, child, -getScrollRangeForDragFling(child), 0, yvel);
}
再看fling方法的实现,我们发现了其使用了OverScroller来实现fing效果的算法实现,具体的View滚动由FlingRunnable承担。代码如下:
final boolean fling(CoordinatorLayout coordinatorLayout, V layout, int minOffset,
int maxOffset, float velocityY) {
if (mFlingRunnable != null) {
layout.removeCallbacks(mFlingRunnable);
mFlingRunnable = null;
}
if (mScroller == null) {
mScroller = new OverScroller(layout.getContext());
}
mScroller.fling(
0, getTopAndBottomOffset(), // curr
0, Math.round(velocityY), // velocity.
0, 0, // x
minOffset, maxOffset); // y
if (mScroller.computeScrollOffset()) {
mFlingRunnable = new FlingRunnable(coordinatorLayout, layout);
ViewCompat.postOnAnimation(layout, mFlingRunnable);
return true;
} else {
onFlingFinished(coordinatorLayout, layout);
return false;
}
}
通过以上代码可以发现,在使用OverScroller计算fling事件时,其设置了minOffset(Y轴向上滚动的边界),通过向上跟踪代码发现这个minOffset恰好就是AppBarLayout的高度取反。
int getScrollRangeForDragFling(V view) {
return view.getHeight();
}
这就能解释了为什么滚动到顶部后停止的问题了。下面再看一下具体的fling实现:
private class FlingRunnable implements Runnable {
private final CoordinatorLayout mParent;
private final V mLayout;
FlingRunnable(CoordinatorLayout parent, V layout) {
mParent = parent;
mLayout = layout;
}
@Override
public void run() {
if (mLayout != null && mScroller != null) {
if (mScroller.computeScrollOffset()) {
setHeaderTopBottomOffset(mParent, mLayout, mScroller.getCurrY());
// Post ourselves so that we run on the next animation
ViewCompat.postOnAnimation(mLayout, this);
} else {
onFlingFinished(mParent, mLayout);
}
}
}
}
FlingRunnable是具体的滚动实现,run方法中并没有发现其将fling事件传递给父View CoordinatorLayout,因此这个fling事件由AppBarLayout消费,无法带动底部的RecyvlerView fling。
解决方案
上文中已经找到了具体的原因,但是我们无法修改AppBarLayout代码,因此这里我们要明确一点:如果想让AppBarLayout的Fling连接上RecyclerView就必须自定义Behavior或者修改HeaderBehavior。
由于自定义Behavior必须继承CoordinatorLayout.Behavior,然后把
AppBarLayout.Behavior与其父类一直到ViewOffsetBehavior的代码全部复制出来,并且涉及相关类比较多,因此我们直接把AppBarLayout相关代码全部复制出来,效果如下:
下面进行具体代码的修改。
上文中也提到在使用OverScroller计算fling事件时,其设置了minOffset这个minOffset恰好就是向上滚动到AppBarLayout底部的位置。因此第一步我们要把这个值设置的足够小,让OverScroller计算出更长的fling时间与距离。这里判断如果是向上fling时就把minOffset设置为Integer.MIN_VALUE,具体代码如下:
int fixedMin = velocityY < 0 ? Integer.MIN_VALUE : minOffset;
mScroller.fling(
0, getTopAndBottomOffset(), // curr
0, Math.round(velocityY), // velocity.
0, 0, // x
fixedMin, maxOffset); // y
第二步就是要修改FlingRunnable了,让其在fling时带动AppBarLayout下面的View同时fling。
我们知道CoordinatorLayout就是为了解决嵌套滚动而生,我们应该调用CoordinatorLayout的能力,把这个fling分发给下面的View就可以了。
CoordinatorLayout嵌套滚动的原理如下:
CoordinatorLayout实现了NestedScrollingParent,当CoordinatorLayout内有一个支持NestedScroll的子View时,它的嵌套滑动事件通过NestedScrollingParent的回调分发到各直接子View的Behavior处理。RecyclerView就是实现了NestedScrollingChild2的子View(NestedScrollingChild2继承于NestedScrollingChild),而AppBarLayout却没有实现NestedScrollingChild接口。因此如果我们想通过调用CoordinatorLayout分发嵌套事件会存在以下两个问题:
- 没有可供调用的API或参数无法传递
- 代码逻辑复杂,需要处理各种嵌套相关的事件
因此经过调研我们放弃了这种方案。
下面说一下我们最终使用的方案,首先我们通过id或者tag的方式获取到需要需要被fling带动的目标View,相关代码如下:
public class NestedScrollTarget {
private NestedScrollView mNestedScrollView;
private LinearLayoutManager mLayoutManager;
/**
* 带动RecyclerView fling时的position,默认为0,滚动时不停增加
*/
private int recyclerPosition = 0;
/**
* RecyclerView最后已偏移的Y轴位置,默认为0
*/
private int recyclerLastOffset = 0;
public NestedScrollTarget(View v) {
findScrollTarget(v);
}
/**
* 查找需要嵌套fling的目标
* @param v
*/
protected void findScrollTarget(View v) {
if (findNestedScrollTarget(v)) return;
if (v instanceof ViewPager) {
View root = findCurrentPagerView((ViewPager) v);
if (root == null) return;
View child = root.findViewWithTag("nested_fling");
findNestedScrollTarget(child);
}
}
private View findCurrentPagerView(ViewPager vp) {
int position = vp.getCurrentItem();
PagerAdapter adapter = vp.getAdapter();
if (adapter instanceof FragmentStatePagerAdapter) {
FragmentStatePagerAdapter fsp = (FragmentStatePagerAdapter) adapter;
return fsp.getItem(position).getView();
} else if (adapter instanceof FragmentPagerAdapter) {
FragmentPagerAdapter fp = (FragmentPagerAdapter) adapter;
return fp.getItem(position).getView();
}
return null;
}
private boolean findNestedScrollTarget(View v) {
if (v instanceof NestedScrollView) {
mNestedScrollView = (NestedScrollView) v;
stopScroll(mNestedScrollView);
return true;
}
if (v instanceof RecyclerView) {
RecyclerView.LayoutManager lm = ((RecyclerView) v).getLayoutManager();
if (lm instanceof LinearLayoutManager) {
mLayoutManager = (LinearLayoutManager) lm;
stopScroll((RecyclerView) v);
return true;
}
}
return false;
}
/**
* 停止NestedScrollView滚动
*
* @param v
*/
private void stopScroll(NestedScrollView v) {
try {
Field field = ReflectUtil.getDeclaredField(v, "mScroller");
if (field == null) return;
field.setAccessible(true);
OverScroller scroller = (OverScroller) field.get(v);
if (scroller != null) scroller.abortAnimation();
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 停止RecyclerView滚动
*
* @param
*/
private void stopScroll(RecyclerView rv) {
try {
Field field = ReflectUtil.getDeclaredField(rv, "mViewFlinger");
if (field == null) return;
field.setAccessible(true);
Object obj = field.get(rv);
if (obj == null) return;
Method method = obj.getClass().getDeclaredMethod("stop");
method.setAccessible(true);
method.invoke(obj);
} catch (Exception e) {
e.printStackTrace();
}
}
public void scrollToY(int dy) {
if (mNestedScrollView != null) {
mNestedScrollView.scrollTo(0, dy);
} else if (mLayoutManager != null) {
//动态计算RecyclerView滑动偏移量,以及依赖的位置
if (mLayoutManager != null) {
View view = mLayoutManager.findViewByPosition(recyclerPosition);
int offset = dy - recyclerLastOffset;
if (view != null) {
int height = view.getHeight();
if (dy > (recyclerLastOffset + height)) {
recyclerPosition++;
offset = dy - recyclerLastOffset - height;
recyclerLastOffset += height;
}
}
mLayoutManager.scrollToPositionWithOffset(recyclerPosition, -offset);
}
}
}
}
实际滚动时需要注意,RecyclerView并比支持直接滚动到某一个点,但是提供了scrollToPositionWithOffset方法,这个方法的意思是滚动到某一个Position并且偏移部分像素。我们可以基于此方法来实现滚动到某一个位置,调用这个方法时需要注意第一个参数position一定要传屏幕中显示的position,否则会导致已经不再屏幕中的position不回收,然后很容易引起OOM。具体代码如下:
public void scrollToY(int dy) {
if (mNestedScrollView != null) {
mNestedScrollView.scrollTo(0, dy);
} else if (mLayoutManager != null) {
//动态计算RecyclerView滑动偏移量,以及依赖的位置
if (mLayoutManager != null) {
View view = mLayoutManager.findViewByPosition(recyclerPosition);
int offset = dy - recyclerLastOffset;
if (view != null) {
int height = view.getHeight();
if (dy > (recyclerLastOffset + height)) {
recyclerPosition++;
offset = dy - recyclerLastOffset - height;
recyclerLastOffset += height;
}
}
mLayoutManager.scrollToPositionWithOffset(recyclerPosition, -offset);
}
}
}
总结
AppBarLayout并不支持滚动,只是依附于CoordinatorLayout这个强大的协调布局才有了偏移的功能,因此很多功能并支持,需要我们去看源码分析其中的原因然后再对症修改。