58App新版首頁之AppBarLayout與RecyclerView的Fling鏈接

前言

在9.0之前版本中首頁沒有作AppBarLayout與底部RecyclerView的Fling鏈接處理,致使在AppBarLayout往上Fling時當滾動到AppBarLayout底部時會當即停住,致使動畫會比較生硬,當咱們在9.0新版首頁改版時有用戶反饋這塊的問題,因而咱們花時間進行了一些優化處理,下面先看一下老版本與新版本首頁效果的對比。
新老首頁對比
能夠很明顯的看到老版本在滾動到AppBarLayout底部時瞬間停住,給人一種很生硬的感受,下面咱們就來說一講如何進行優化。java

問題分析

爲了搞清楚爲何會出現這樣的問題,咱們分析了一下AppBarLayout的源碼。下面是一個大體的流程圖:
AppBarLayout fling實現原理.png
下面咱們進行詳細的源碼分析:
首先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承擔。代碼以下:ide

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。post

解決方案

上文中已經找到了具體的緣由,可是咱們沒法修改AppBarLayout代碼,所以這裏咱們要明確一點:若是想讓AppBarLayout的Fling鏈接上RecyclerView就必須自定義Behavior或者修改HeaderBehavior。
因爲自定義Behavior必須繼承CoordinatorLayout.Behavior,而後把
AppBarLayout.Behavior與其父類一直到ViewOffsetBehavior的代碼所有複製出來,而且涉及相關類比較多,所以咱們直接把AppBarLayout相關代碼所有複製出來,效果以下:
75A19010-5552-4F21-B5F8-90E3780B128B.png
下面進行具體代碼的修改。
上文中也提到在使用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 NestedScrolling原理.png
CoordinatorLayout實現了NestedScrollingParent,當CoordinatorLayout內有一個支持NestedScroll的子View時,它的嵌套滑動事件經過NestedScrollingParent的回調分發到各直接子View的Behavior處理。RecyclerView就是實現了NestedScrollingChild2的子View(NestedScrollingChild2繼承於NestedScrollingChild),而AppBarLayout卻沒有實現NestedScrollingChild接口。所以若是咱們想經過調用CoordinatorLayout分發嵌套事件會存在如下兩個問題:動畫

  1. 沒有可供調用的API或參數沒法傳遞
  2. 代碼邏輯複雜,須要處理各類嵌套相關的事件

所以通過調研咱們放棄了這種方案。
下面說一下咱們最終使用的方案,首先咱們經過id或者tag的方式獲取到須要須要被fling帶動的目標View,相關代碼以下:this

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。具體代碼以下:spa

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這個強大的協調佈局纔有了偏移的功能,所以不少功能並支持,須要咱們去看源碼分析其中的緣由而後再對症修改。

相關文章
相關標籤/搜索