做者: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
![](http://static.javashuo.com/static/loading.gif)
接下來是如何採用其餘方便的方式來實現相似需求?我想到了CoordinatorLayout
,CoordinatorLayout
在處理吸頂是有一套已經成熟的方案的。編程語言
網上關於CoordinatorLayout
的使用有不少不錯的文章,這裏就不介紹如何使用,關於CoordinatorLayout
和Behavior
我推薦看看這篇文章針對 CoordinatorLayout 及 Behavior 的一次細節較真
。編輯器
而咱們這篇文章主要是講使用CoordinatorLayout
中遇到的問題,問題如何解決以及CoordinatorLayout
爲何會有這樣的問題。ide
![](http://static.javashuo.com/static/loading.gif)
實現這個大概是像上面這樣相似的佈局結構,來看下佈局文件。佈局
<?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效果:
![](http://static.javashuo.com/static/loading.gif)
接下來咱們來看問題的緣由,其實這個要搞清楚緣由須要對CoordinatorLayout
的工做機制有個比較清晰的理解,然而CoordinatorLayout
這裏牽扯到嵌套滾動以及Behavior
這些,
咱們這裏嘗試簡單地介紹下CoordinatorLayout
的工做機制。
![](http://static.javashuo.com/static/loading.gif)
-
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
的各個回調方法調用流程以下圖所示:
![](http://static.javashuo.com/static/loading.gif)
上圖列出來手指從按下到擡起時的整個流程,固然這些都是在子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
在滑出屏幕外以後又反彈回去了,並且當你滑動的加速度很大的時候,這個反彈的幅度也會跟着變大。
![](http://static.javashuo.com/static/loading.gif)
這個問題形成的緣由是由於在手指向上滑動後形成RecyclerView的fling
操做執行,具體的代碼在RecyclerView內部類ViewFlinger
中。
我使用Android Studio中的Profiler抓取了一下當出現反彈問題的時候出現的方法調用堆棧以下所示:
![](http://static.javashuo.com/static/loading.gif)
發現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---
![](http://static.javashuo.com/static/loading.gif)
![微信IMG605.jpg](http://static.javashuo.com/static/loading.gif)
本文分享自微信公衆號 - 技術最TOP(Tech-Android)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。