解決CoordinatorLayout的動畫抖動以及回彈問題

在使用CoordinatorLayout來實現(xiàn)Android中的一種吸頂?shù)臅r候,遇到了兩個CoordinatorLayout的滑動問題,這里做下記錄。

這里使用CoordinatorLayout實現(xiàn)的是一個tab吸頂?shù)男Ч愃铺詫殻〇|首頁的一個效果。
頭部區(qū)域展示各種類型banner卡片,中間是類似TabLayout的可點擊tab,下面是feed卡片,可以一直下拉加載,并且feed卡片區(qū)域使用ViewPager可以支持左右橫滑切換tab,另外,就是tab滾動到頂部之后會有個吸頂?shù)男Ч?/p>

我們在項目中也要實現(xiàn)的效果,一開始我的想法是使用嵌套RecycleView的形式來實現(xiàn),因為我去調研了下京東和淘寶的首頁布局都是這么實現(xiàn)的,京東和淘寶首頁實現(xiàn)方式和下面的圖類似,外部的整個RecycleView嵌套ViewPager,ViewPager中再有多個RecycleView,這個實現(xiàn)起來稍微有點麻煩,難點是要處理好外部的RecycleView和ViewPager中內部RecycleView的滑動事件傳遞,這里我們只是簡單介紹下,后面我會專門來介紹類似這樣的嵌套RecycleView如何實現(xiàn)。

nested_recycler_view.png

接下來是如何采用其他方便的方式來實現(xiàn)類似需求?我想到了CoordinatorLayout,CoordinatorLayout在處理吸頂是有一套已經(jīng)成熟的方案的。

網(wǎng)上關于CoordinatorLayout的使用有很多不錯的文章,這里就不介紹如何使用,關于CoordinatorLayout和Behavior我推薦看看這篇文章針對 CoordinatorLayout 及 Behavior 的一次細節(jié)較真

而我們這篇文章主要是講使用CoordinatorLayout中遇到的問題,問題如何解決以及CoordinatorLayout為什么會有這樣的問題。

image

實現(xiàn)這個大概是像上面這樣類似的布局結構,來看下布局文件。

<?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>

這樣的布局,接著填充數(shù)據(jù)基本上就能實現(xiàn)tab吸頂效果,feed卡片區(qū)采用RecycleView實現(xiàn),可以一直下拉,并且能夠支持左右橫滑,基本實現(xiàn)了類似京東,淘寶首頁的一個效果。

但是在使用這種方式來實現(xiàn)發(fā)現(xiàn)兩個很明顯的問題。

第一,抖動問題

該問題場景描述:我們觸摸AppBarLayout使AppBarLayout整體向上滑動,,即手指上滑,當AppBarLayout fling的同時,我們觸摸下部ViewPager中的RecycleView區(qū)域,使RecycleView區(qū)域整體向下滑動,即手指下滑,這個時候會發(fā)現(xiàn)一個明顯頁面動畫現(xiàn)象,這個問題幾乎是必現(xiàn)。

來看下gif效果:
image

接下來我們來看問題的原因,其實這個要搞清楚原因需要對CoordinatorLayout的工作機制有個比較清晰的理解,然而CoordinatorLayout這里牽扯到嵌套滾動以及Behavior這些,

我們這里嘗試簡單地介紹下CoordinatorLayout的工作機制。

image
  • CoordinatorLayout實現(xiàn)NestedScrollingParent2接口,用于處理與滑動子View的聯(lián)動交互,實際上交由Behavior進行處理。
  • AppBarLayout中默認使用了AppBarLayout.Behavior,主要功能是接收CoordinatorLayout傳輸過來的滑動事件,并且相對應的進行處理,如RecycleView往上滑動到頭時候,繼續(xù)滑動移動AppBarLayout到頭。
  • RecycleView實現(xiàn)了NestedScrollingChild2接口,用于傳輸給CoordinatorLayout,并且消費CoordinatorLayout不消費的觸摸事件,其中還是使用了AppBarLayout.ScrollingViewBehavior,功能是進行監(jiān)聽AppBarLayout的位移變化,從而進行相對應的變化,最明顯的例子就是AppBarLayout上移過程中,RecycleView一起上移。

CoordinatorLayout中Behavior

其實CoordinatorLayout就是通過Behavior這個機制來協(xié)調各個子View的滾動。比如我們來看CoordinatorLayout的onStartNestedScroll方法,這個其實是NestedScrollingParent2中的方法。

當CoordinatorLayout子view的調用NestedScrollingChild2的方法startNestedScroll時,會調用到該方法
該方法決定了當前控件是否能接收到其內部View(并非是直接子View)滑動時的參數(shù)。

//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嵌套滾動機制可以看看下面這篇博客。

事件分發(fā)和NestedScrolling

嵌套滾動機制NestedScrollingParent2和NestedScrollingChild2的各個回調方法調用流程如下圖所示:


image

上圖列出來手指從按下到抬起時的整個流程,當然這些都是在子View的onTouchEvent()中完成的,所以父View一定不能攔截子View的事件,否則這套機制就失效了。

除此之外,箭頭的左邊分別都是NestedScrollingChild2中的各種方法,右邊則是NestedScrollingParent2對應的方法。使用時,一般是子View通過dispatchXXX()來通知父View,然后父View通過onXXX()來進行回應。

方法調用的先后時機也有區(qū)別,對應到上圖中,圖越往下,調用的時機越晚。

AppBarLayout中的Behavior

接著我們來看看AppBarLayout中的Behavior,ApprBarLayout的默認Behavior就是AppBarLayout.Behavior這個類,而AppBarLayout.Behavior繼承自HeaderBehavior,HeaderBehavior又繼承自ViewOffsetBehavior,這里先總結一下兩個類的作用。

  • ViewOffsetBehavior:該Behavior主要運用于View的移動,從名字就可以看出來,該類中提供了上下移動,左右移動的方法。
  • HeaderBehavior:該類主要用于View處理觸摸事件以及觸摸后的fling事件。

由于上面兩個類功能的實現(xiàn),使得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中進行實現(xiàn),最終調用到了額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;
                    ....
                     //分發(fā)回調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來實現(xiàn)的。

在閱讀ScrollingViewBehavior源碼中發(fā)現(xiàn)其實現(xiàn)了如下方法:

@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如何協(xié)調AppBarLayout和RecycleView來上下滾動的,接著回到剛開始我們要討論那個動畫抖動問題。

其實造成這個的原因主要是AppBarLayout的fling操作和RecycleView聯(lián)動造成的問題。

在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(實現(xiàn)了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產(chǎn)生,所以自定義一個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在滑出屏幕外之后又反彈回去了,而且當你滑動的加速度很大的時候,這個反彈的幅度也會跟著變大。

coordinator_resize_2.gif

這個問題造成的原因是因為在手指向上滑動后造成RecyclerView的fling操作執(zhí)行,具體的代碼在RecyclerView內部類ViewFlinger中。

我使用Android Studio中的Profiler抓取了一下當出現(xiàn)反彈問題的時候出現(xiàn)的方法調用堆棧如下所示:


coordinator_bugs_method_call.jpg

發(fā)現(xiàn)RecyclerView中ViewFlinger調用后,接著觸發(fā)了HeaderBehavior中的FlingRunnable。而ViewFling中會調用dispatchNestedScroll(...)方法,RecyclerView作為CoordinatorLayout的子View,它通過嵌套滾動的機制又會調用到CoordinatorLayout中的onNestedScroll,這里主要就是通過AppBarLayout的Behavior中的方法setHeaderTopBottomOffset來實現(xiàn)AppBarLayout的滾動,后面會發(fā)現(xiàn)多次setHeaderTopBottomOffset的調用,其實目前看到這里,并不太確定造成這個問題的具體原因是啥,感覺上是因為RecyclerView的滑動和CoordinatorLayout的滑動沖突導致了反彈效果的出現(xiàn)。

于是嘗試了下面的解決方法:

coordinatorLayout.setOnInterceptTouchListener {
    mRecyclerView.stopScroll()
}

試了這個方法發(fā)現(xiàn)果然有效。

另外,我在寫demo的時候發(fā)現(xiàn),這個問題在support-27是存在的,在support-28 Google已經(jīng)修復過了。

我嘗試過看看support-28里面的都有哪些改動,想看看Google是如何修復的。看了下Google的release note并沒有提及,如果從Google的commit history來看實在頁看不出來啥,暫時也沒有個具體的原因。

后面可以將support-27和support-28的source下載下來,然后使用Beyond Compare來看看具體的diff改動是在哪。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容