58App新版首页之AppBarLayout与RecyclerView的Fling连接

前言

在9.0以前版本中首页没有做AppBarLayout与底部RecyclerView的Fling连接处理,导致在AppBarLayout往上Fling时当滚动到AppBarLayout底部时会立即停住,导致动画会比较生硬,当我们在9.0新版首页改版时有用户反馈这块的问题,于是我们花时间进行了一些优化处理,下面先看一下老版本与新版本首页效果的对比。

可以很明显的看到老版本在滚动到AppBarLayout底部时瞬间停住,给人一种很生硬的感觉,下面我们就来讲一讲如何进行优化。

问题分析

为了搞清楚为什么会出现这样的问题,我们分析了一下AppBarLayout的源码。下面是一个大致的流程图:
58App新版首页之AppBarLayout与RecyclerView的Fling连接_第1张图片
下面我们进行详细的源码分析:
首先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相关代码全部复制出来,效果如下:
58App新版首页之AppBarLayout与RecyclerView的Fling连接_第2张图片
下面进行具体代码的修改。
上文中也提到在使用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嵌套滚动的原理如下:
58App新版首页之AppBarLayout与RecyclerView的Fling连接_第3张图片
CoordinatorLayout实现了NestedScrollingParent,当CoordinatorLayout内有一个支持NestedScroll的子View时,它的嵌套滑动事件通过NestedScrollingParent的回调分发到各直接子View的Behavior处理。RecyclerView就是实现了NestedScrollingChild2的子View(NestedScrollingChild2继承于NestedScrollingChild),而AppBarLayout却没有实现NestedScrollingChild接口。因此如果我们想通过调用CoordinatorLayout分发嵌套事件会存在以下两个问题:

  1. 没有可供调用的API或参数无法传递
  2. 代码逻辑复杂,需要处理各种嵌套相关的事件

因此经过调研我们放弃了这种方案。
下面说一下我们最终使用的方案,首先我们通过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这个强大的协调布局才有了偏移的功能,因此很多功能并支持,需要我们去看源码分析其中的原因然后再对症修改。

你可能感兴趣的