做者:JasonGaoH
, 连接:https://www.jianshu.com/p/d3c6fbf0752aandroid
前言
在使用CoordinatorLayout
来实现Android中的一种吸顶的时候,遇到了两个CoordinatorLayout
的滑动问题,这里作下记录。web
这里使用CoordinatorLayout
实现的是一个tab
吸顶的效果,相似淘宝,京东首页的一个效果。编程
头部区域展现各类类型banner
卡片,中间是相似TabLayout
的可点击tab,下面是feed卡片,能够一直下拉加载,而且feed卡片区域使用ViewPager
能够支持左右横滑切换tab,另外,就是tab滚动到顶部以后会有个吸顶的效果。微信
咱们在项目中也要实现的效果,一开始个人想法是使用嵌套RecycleView的形式来实现,由于我去调研了下京东和淘宝的首页布局都是这么实现的,京东和淘宝首页实现方式和下面的图相似,外部的整个RecycleView嵌套ViewPager,ViewPager中再有多个RecycleView,这个实现起来稍微有点麻烦,难点是要处理好外部的RecycleView和ViewPager中内部RecycleView的滑动事件传递,这里咱们只是简单介绍下,后面我会专门来介绍相似这样的嵌套RecycleView如何实现。app

接下来是如何采用其余方便的方式来实现相似需求?我想到了CoordinatorLayout
,CoordinatorLayout
在处理吸顶是有一套已经成熟的方案的。编程语言
网上关于CoordinatorLayout
的使用有不少不错的文章,这里就不介绍如何使用,关于CoordinatorLayout
和Behavior
我推荐看看这篇文章针对 CoordinatorLayout 及 Behavior 的一次细节较真
。编辑器
而咱们这篇文章主要是讲使用CoordinatorLayout
中遇到的问题,问题如何解决以及CoordinatorLayout
为何会有这样的问题。ide

实现这个大概是像上面这样相似的布局结构,来看下布局文件。布局
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<android.support.design.widget.AppBarLayout
app:layout_scrollFlags="scroll"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<android.support.design.widget.CollapsingToolbarLayout
app:layout_scrollFlags="scroll"
app:scrimVisibleHeightTrigger="45dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:background="@drawable/header"
app:layout_scrollFlags="scroll"
android:layout_width="match_parent"
android:layout_height="450dp" />
</android.support.design.widget.CollapsingToolbarLayout>
<android.support.design.widget.TabLayout
android:id="@+id/tabs"
app:layout_collapseMode="pin"
app:tabMode="scrollable"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</android.support.design.widget.AppBarLayout>
<android.support.v4.view.ViewPager
android:id="@+id/view_pager"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</android.support.design.widget.CoordinatorLayout>
这样的布局,接着填充数据基本上就能实现tab吸顶效果,feed
卡片区采用RecycleView
实现,能够一直下拉,而且可以支持左右横滑,基本实现了相似京东,淘宝首页的一个效果。动画
可是在使用这种方式来实现发现两个很明显的问题。
第一,抖动问题
该问题场景描述:咱们触摸AppBarLayout
使AppBarLayout
总体向上滑动,,即手指上滑,当AppBarLayout fling
的同时,咱们触摸下部ViewPager中的RecycleView
区域,使RecycleView区域总体向下滑动,即手指下滑,这个时候会发现一个明显页面动画现象,这个问题几乎是必现。
来看下gif效果:

接下来咱们来看问题的缘由,其实这个要搞清楚缘由须要对CoordinatorLayout
的工做机制有个比较清晰的理解,然而CoordinatorLayout
这里牵扯到嵌套滚动以及Behavior
这些,
咱们这里尝试简单地介绍下CoordinatorLayout
的工做机制。

-
CoordinatorLayout实现
NestedScrollingParent2
接口,用于处理与滑动子View的联动交互,实际上交由Behavior
进行处理。 -
AppBarLayout
中默认使用了AppBarLayout.Behavior
,主要功能是接收CoordinatorLayout传输过来的滑动事件,而且相对应的进行处理,如RecycleView往上滑动到头时候,继续滑动移动AppBarLayout
到头。 -
RecycleView
实现了NestedScrollingChild2
接口,用于传输给CoordinatorLayout
,而且消费CoordinatorLayout
不消费的触摸事件,其中仍是使用了AppBarLayout.ScrollingViewBehavior
,功能是进行监听AppBarLayout
的位移变化,从而进行相对应的变化,最明显的例子就是AppBarLayout上移过程当中,RecycleView
一块儿上移。
CoordinatorLayout中Behavior
其实CoordinatorLayout
就是经过Behavior
这个机制来协调各个子View的滚动。好比咱们来看CoordinatorLayout
的onStartNestedScroll
方法,这个实际上是NestedScrollingParent2
中的方法。
当CoordinatorLayout子view的调用NestedScrollingChild2
的方法startNestedScroll
时,会调用到该方法
该方法决定了当前控件是否能接收到其内部View(并不是是直接子View)滑动时的参数。
//CoordinatorLayout中的onStartNestedScroll方法:
@Override
public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
return onStartNestedScroll(child, target, nestedScrollAxes, ViewCompat.TYPE_TOUCH);
}
@Override
public boolean onStartNestedScroll(View child, View target, int axes, int type) {
boolean handled = false;
final int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
final View view = getChildAt(i);
if (view.getVisibility() == View.GONE) {
// If it's GONE, don't dispatch
continue;
}
final LayoutParams lp = (LayoutParams) view.getLayoutParams();
final Behavior viewBehavior = lp.getBehavior();
if (viewBehavior != null) {
final boolean accepted = viewBehavior.onStartNestedScroll(this, view, child,
target, axes, type);
handled |= accepted;
lp.setNestedScrollAccepted(type, accepted);
} else {
lp.setNestedScrollAccepted(type, false);
}
}
return handled;
}
CoordinatorLayout
中的onStartNestedScroll
方法基本都会调用到每一个子View的Behavior中相应的方法中去。
关于Nested嵌套滚动机制能够看看下面这篇博客。
事件分发和NestedScrolling
嵌套滚动机制NestedScrollingParent2
和NestedScrollingChild2
的各个回调方法调用流程以下图所示:

上图列出来手指从按下到抬起时的整个流程,固然这些都是在子View的onTouchEvent()
中完成的,因此父View必定不能拦截子View的事件,不然这套机制就失效了。
除此以外,箭头的左边分别都是NestedScrollingChild2
中的各类方法,右边则是NestedScrollingParent2
对应的方法。使用时,通常是子View经过dispatchXXX()
来通知父View,而后父View经过onXXX()
来进行回应。
方法调用的前后时机也有区别,对应到上图中,图越往下,调用的时机越晚。
AppBarLayout中的Behavior
接着咱们来看看AppBarLayout
中的Behavior,ApprBarLayout
的默认Behavior就是AppBarLayout.Behavior
这个类,而AppBarLayout.Behavior
继承自HeaderBehavior
,HeaderBehavior
又继承自ViewOffsetBehavior
,这里先总结一下两个类的做用。
-
ViewOffsetBehavior
:该Behavior
主要运用于View的移动,从名字就能够看出来,该类中提供了上下移动,左右移动的方法。 -
HeaderBehavior
:该类主要用于View处理触摸事件以及触摸后的fling
事件。
因为上面两个类功能的实现,使得AppBarLayout.Behavior
具备了同时移动自己以及处理触摸事件的功能。
public boolean onTouchEvent(CoordinatorLayout parent, V child, MotionEvent ev) {
...
switch (ev.getActionMasked()) {
...
case MotionEvent.ACTION_MOVE: {
final int activePointerIndex = ev.findPointerIndex(mActivePointerId);
if (activePointerIndex == -1) {
return false;
}
final int y = (int) ev.getY(activePointerIndex);
int dy = mLastMotionY - y;
if (!mIsBeingDragged && Math.abs(dy) > mTouchSlop) {
mIsBeingDragged = true;
if (dy > 0) {
dy -= mTouchSlop;
} else {
dy += mTouchSlop;
}
}
if (mIsBeingDragged) {
mLastMotionY = y;
// We're being dragged so scroll the ABL
scroll(parent, child, dy, getMaxDragOffset(child), 0);
}
break;
}
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);
}
...
return true;
}
咱们来看onTouchEvent
的方法,主要逻辑仍是在ACTION_MOVE
中,能够看到在滑动过程当中调用了scroll(...)
方法,scroll(...)
方法在HeaderBehavior
中进行实现,最终调用到了额setHeaderTopBottomOffset(...)
方法,该方法在AppBarLayout.Behavior
中进行了重写,因此,咱们直接看AppBarLayout.Behavior
中的源码便可:
@Override
//newOffeset传入了dy,也就是咱们手指移动距离上一次移动的距离,
//minOffset等于AppBarLayout的负的height,maxOffset等于0。
int setHeaderTopBottomOffset(CoordinatorLayout coordinatorLayout,
AppBarLayout appBarLayout, int newOffset, int minOffset, int maxOffset) {
final int curOffset = getTopBottomOffsetForScrollingSibling();//获取当前的滑动Offset
int consumed = 0;
//AppBarLayout滑动的距离若是超出了minOffset或者maxOffset,则直接返回0
if (minOffset != 0 && curOffset >= minOffset && curOffset <= maxOffset) {
//矫正newOffset,使其minOffset<=newOffset<=maxOffset
newOffset = MathUtils.clamp(newOffset, minOffset, maxOffset);
//因为默认没设置Interpolator,因此interpolatedOffset=newOffset;
if (curOffset != newOffset) {
final int interpolatedOffset = appBarLayout.hasChildWithInterpolator()
? interpolateOffset(appBarLayout, newOffset)
: newOffset;
//调用ViewOffsetBehvaior的方法setTopAndBottomOffset(...),最终经过
//ViewCompat.offsetTopAndBottom()移动AppBarLayout
final boolean offsetChanged = setTopAndBottomOffset(interpolatedOffset);
//记录下消费了多少的dy。
consumed = curOffset - newOffset;
//没设置Interpolator的状况, mOffsetDelta永远=0
mOffsetDelta = newOffset - interpolatedOffset;
....
//分发回调OnOffsetChangedListener.onOffsetChanged(...)
appBarLayout.dispatchOffsetUpdates(getTopAndBottomOffset());
updateAppBarLayoutDrawableState(coordinatorLayout, appBarLayout, newOffset,
newOffset < curOffset ? -1 : 1, false);
}
...
return consumed;
}
AppBarLayout
中移动主要就是这部分逻辑了,经过setTopAndBottomOffset()
来达到了移动咱们的AppBarLayout
,那么这里AppBarLayout就能够跟着手上下移动了。
RecycleView中的Behavior
那么接下来咱们看看RecycleView在CoordinatorLayout
中如何是移动的?
上面讲了AppBarLayout
是如何经过Behavior
来移动的,咱们在上面布局文件中指定了ViewPager的Behavior。
app:layout_behavior="@string/appbar_scrolling_view_behavior"
这个"appbar_scrolling_view_behavior"
其实就是ScrollingViewBehavior
,ScrollingViewBehavior
也继承自ViewOffsetBehavior
,咱们在上下移动AppBarLayout
的时候,下面的RecycleView也是须要跟着移动的,它上下移动就是靠这个来ScrollingViewBehavior
来实现的。
在阅读ScrollingViewBehavior
源码中发现其实现了以下方法:
@Override
public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
// We depend on any AppBarLayouts
return dependency instanceof AppBarLayout;
}
@Override
public boolean onDependentViewChanged(CoordinatorLayout parent, View child,
View dependency) {
offsetChildAsNeeded(parent, child, dependency);
return false;
}
这样咱们这个RecycleView依赖于AppBarLayout
,在AppBarLayout
移动的过程当中,RecycleView
会随着AppBarLayout
的移动回调onDependentViewChanged(...)
方法,进而调用offsetChildAsNeeded(parent, child, dependency)
。
用这么多篇幅主要讲了CoordinatorLayout
如何协调AppBarLayout
和RecycleView
来上下滚动的,接着回到刚开始咱们要讨论那个动画抖动问题。
其实形成这个的缘由主要是AppBarLayout的fling操做和RecycleView联动形成的问题。
在AppBarLayout
的Behavior中的onTouchEvent()
事件中处理了fling
事件:
public boolean onTouchEvent(CoordinatorLayout parent, V child, MotionEvent ev) {
...
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);
}
...
return true;
}
在fling
的方法中使用OverScroller来模拟进行fling操做,最终会调到setHeaderTopBottomOffset(...)
来使AppBarLayout进行fling的滑动操做。
在绝大部分滑动逻辑中,这样处理是正确的,可是若是在AppBarLayout在fling
的时候主动滑动RecyclerView,那么就会形成动画抖动的问题了。
在当前状况下,RecyclerView滑动到头了,那么就会把未消费的事件经过NestedScrollingChild2
交付由CoordinatorLayout(实现了NestedScrollingParent2
)处理,parent又最终交付由AppBarLayout.Behavior
进行处理的,其中调用的方法以下:
@Override
public void onNestedScroll(CoordinatorLayout coordinatorLayout, AppBarLayout child,
View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed,
int type) {
if (dyUnconsumed < 0) {
// If the scrolling view is scrolling down but not consuming, it's probably be at
// the top of it's content
scroll(coordinatorLayout, child, dyUnconsumed,
-child.getDownNestedScrollRange(), 0);
}
}
这里的scroll
方法最终会调用setHeaderTopBottomOffset(...)
,因为两次分别触摸在AppBarLayout和RecyclerView
的方向不一致,致使了最终的抖动的效果。
解决方式也很简单,只要在CoordinatorLayout
的onInterceptedTouchEvent()
中中止AppBarLayout的fling操做就能够了,直接操做的对象就是AppBarLayout中的Behavior,该Behavior继承自HeaderBehavior,而fling
操做由OverScroller产生,因此自定义一个FixedBehavior
:
public class FixedBehavior extends AppBarLayout.Behavior {
private OverScroller mOverScroller;
public FixedBehavior() {
super();
}
public FixedBehavior(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public void onAttachedToLayoutParams(@NonNull CoordinatorLayout.LayoutParams params) {
super.onAttachedToLayoutParams(params);
}
@Override
public void onDetachedFromLayoutParams() {
super.onDetachedFromLayoutParams();
}
@Override
public boolean onTouchEvent(CoordinatorLayout parent, AppBarLayout child, MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_UP) {
reflectOverScroller();
}
return super.onTouchEvent(parent, child, ev);
}
/**
*
*/
public void stopFling() {
if (mOverScroller != null) {
mOverScroller.abortAnimation();
}
}
/**
* 解决AppbarLayout在fling的时候,再主动滑动RecyclerView致使的动画错误的问题
*/
private void reflectOverScroller() {
if (mOverScroller == null) {
Field field = null;
try {
field = getClass().getSuperclass()
.getSuperclass().getDeclaredField("mScroller");
field.setAccessible(true);
Object object = field.get(this);
mOverScroller = (OverScroller) object;
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
}
而后在重写CoordinatorLayout
,暴露一个接口:
public class CustomCoordinatorLayout extends CoordinatorLayout {
private OnInterceptTouchListener mListener;
public void setOnInterceptTouchListener(OnInterceptTouchListener listener) {
mListener = listener;
}
public CustomCoordinatorLayout(Context context) {
super(context);
}
public CustomCoordinatorLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
public CustomCoordinatorLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (mListener != null) {
mListener.onIntercept();
}
return super.onInterceptTouchEvent(ev);
}
public interface OnInterceptTouchListener {
void onIntercept();
}
}
接着在接口中处理滑动问题便可:
coordinatorLayout.setOnInterceptTouchListener {
//RecyclerView滑动的时候禁止AppBarLayout的滑动
if (customBehavior != null) {
customBehavior!!.stopFling()
}
}
第二,回弹问题
问题场景描述,咱们反复上下滑动AppBarLayout
的时候,能够看到AppBarLayout
在滑出屏幕外以后又反弹回去了,并且当你滑动的加速度很大的时候,这个反弹的幅度也会跟着变大。

这个问题形成的缘由是由于在手指向上滑动后形成RecyclerView的fling
操做执行,具体的代码在RecyclerView内部类ViewFlinger
中。
我使用Android Studio中的Profiler抓取了一下当出现反弹问题的时候出现的方法调用堆栈以下所示:

发现RecyclerView中ViewFlinger调用后,接着触发了HeaderBehavior中的FlingRunnable
。而ViewFling中会调用dispatchNestedScroll(...)
方法,RecyclerView
做为CoordinatorLayout
的子View,它经过嵌套滚动的机制又会调用到CoordinatorLayout
中的onNestedScroll
,这里主要就是经过AppBarLayout的Behavior中的方法setHeaderTopBottomOffset
来实现AppBarLayout的滚动,后面会发现屡次setHeaderTopBottomOffset
的调用,其实目前看到这里,并不太肯定形成这个问题的具体缘由是啥,感受上是由于RecyclerView的滑动和CoordinatorLayout
的滑动冲突致使了反弹效果的出现。
因而尝试了下面的解决方法:
coordinatorLayout.setOnInterceptTouchListener {
mRecyclerView.stopScroll()
}
试了这个方法发现果真有效。
另外,我在写demo的时候发现,这个问题在support-27
是存在的,在support-28
Google已经修复过了。
我尝试过看看support-28
里面的都有哪些改动,想看看Google是如何修复的。看了下Google的release note并无说起,若是从Google的commit history来看实在页看不出来啥,暂时也没有个具体的缘由。
后面能够将support-27
和support-28
的source下载下来,而后使用Beyond Compare来看看具体的diff改动是在哪。
---END---


本文分享自微信公众号 - 技术最TOP(Tech-Android)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。