Android 为RecyclerView添加可吸顶的Header

756 阅读4分钟

前言

1.1 意义

为什么要给RecyclerView添加一个Header呢?

其实原因是这样的,RecyclerView上的ItemView可能会比较频繁的detachFromWindow和attachFromWindow,这就容易引发一些意想不到的效果,比如轮播图白屏等。

其实官方也提供了类似CoordinateLayout的组件,但是也有些小毛病,如滑动,还有几十吸顶支持的也不够好,我们本篇直接自行处理修复了一些滑动问题,这些也在官方组件中出现过。

当然,最核心的是,Scrolling机制可以更加友好的辅助我们实现吸顶效果。

1.2 效果预览

下面是本篇的预览效果

注意:左边的图看着不明显,主要原因是你还没看代码,实际上那个熊猫不在RecyclerView中,右边吸顶的Tab也不在RecyclerView中,而在Header中,为什么要这样做,主要原因是RecyclerView 有detach和attach操作,如果有些需求依赖View是否在Window上(如Surface 所在的一些类),那么显然RecyclerView是不合适的,则会产生冲突。

1.3 实现原理

实际上,我们要实现下面的布局效果,有以下几个特点

  • HeaderView的是不可被回收的
  • 父布局最大能滑动的距离和HeaderView的高度相等
  • RecyclerView的高度和父布局高度相等
  • RecyclerView自身的滑动不受限
  • RecyclerView需要和HeaderView保持滑动效果的联动

基于上述逻辑,我们可以实现下面的结构

当然HeaderView滑动之后还可以到到布局内容区域以外

本篇我们重点使用到的是NestedScrolling机制。

二、关于 NestedScrolling

NestedScrolling 机制主要是能够让父 View 和子 View 在滚动时互相协调配合。其中有两个重要的类,分别是:

接口类
NestedScrollingParent(最新:NestedScrollingParent2) - 代表类:NestedScrollView
NestedScrollingChild(最新:NestedScrollingChild2)   - 代表类:RecyclerView

帮助类
NestedScrollingChildHelper 
NestedScrollingParentHelper(用处不是很大)

父类继承 NestedScrollingParent 接口,而子类继承 NestedScrollingChild 接口,同时让父类包含子类,而不是自接父子关系,就搭起了 NestedScrollingParent 机制的基本骨架。

其主要流程是:

  1. 子类滑动,把滑动产生的事件和参数传给父类

  2. 父类根据子类传过来的参数偏移量、滑动方向等参数、当前滑动 View 等判断是否关注此事件,如果不关注,那么父 View 不会参与子 View 的滑动

  3. 父 View 关注了子 View 的滑动,子 View 通过 PreScroll/PreFling 会优先让父 view 消费事件,其实本质是子 View 的回调

  4. 接着,子 View 消费 “剩余事件 “(父 View 不一定消费掉所有事件,比如某时刻的偏移数据)

  5. 流程终止,子 view 通知父 view 本次滑动任务完成

    public interface NestedScrollingChild {
    
        /**
         * 设置嵌套滑动是否能用
         */
        @Override
        public void setNestedScrollingEnabled(boolean enabled);
    
        /**
         * 判断嵌套滑动是否可用
         */
        @Override
        public boolean isNestedScrollingEnabled();
    
        /**
         * 开始嵌套滑动
         *
         * @param axes 表示方向轴,有横向和竖向
         */
        @Override
        public boolean startNestedScroll(int axes);
    
        /**
         * 停止嵌套滑动
         */
        @Override
        public void stopNestedScroll();
    
        /**
         * 判断是否有父View 支持嵌套滑动
         */
        @Override
        public boolean hasNestedScrollingParent() ;
    
        /**
         * 滑行时调用
         * @param velocityX x 轴上的滑动速率
         * @param velocityY y 轴上的滑动速率
         * @param consumed 是否被消费
         * @return  true if the nested scrolling parent consumed or otherwise reacted to the fling
         */
        @Override
        public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) ;
    
        /**
         * 进行滑行前调用
         * @param velocityX x 轴上的滑动速率
         * @param velocityY y 轴上的滑动速率
         * @return true if a nested scrolling parent consumed the fling
         */
        @Override
        public boolean dispatchNestedPreFling(float velocityX, float velocityY) ;
    
        /**
         * 子view处理scroll后调用
         * @param dxConsumed x轴上被消费的距离(横向)
         * @param dyConsumed y轴上被消费的距离(竖向)
         * @param dxUnconsumed x轴上未被消费的距离
         * @param dyUnconsumed y轴上未被消费的距离
         * @param offsetInWindow 子View的窗体偏移量
         * @return  true if the event was dispatched, false if it could not be dispatched.
         */
        @Override
        public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow) ;
    
        /**
         * 在子View的onInterceptTouchEvent或者onTouch中,调用该方法通知父View滑动的距离
         * @param dx  x轴上滑动的距离
         * @param dy  y轴上滑动的距离
         * @param consumed 父view消费掉的scroll长度
         * @param offsetInWindow   子View的窗体偏移量
         * @return 支持的嵌套的父View 是否处理了 滑动事件
         */
        @Override
        public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow);
    
    }
    

二、实现

2.1 实现原理

我们之前使用 ViewDragerHelper 实现的《自定义地图上滑组件 NestedScrollLayout》,其实可以通过 NestedScrolling 机制实现,NestedScrolling 相比 ViewDragHelper,ViewDragHelper 联动使用不当可能产生丢帧问题,联动机制非常强大,相比 ViewDragHelper,可减少很多事件处理,让联动变的更简单。当然如果是单 View 非联动操作 ViewDragHelper 更有优势,具体问题具体解决。

我们通过本文开头展示效果,给 RecyclerView 增加一个的 Header,这个 Header 不是通过 Adapter 实现,而是通过联动效果效果实现。

步骤:

  • 标记HeaderView
  • 将RecyclerView标记为Body部分
  • 组合联动

为什么本篇能实现的呢,主要原因是RecyclerView也实现了Scrolling机制。

2.2 全部代码

下面是完整的逻辑,NestedScrollingChildLayout具备2个View,一个是Header,另一个是作为Body的RecyclerView

public class NestedScrollChildLayout extends FrameLayout implements NestedScrollingParent2 {
    private final int mFlingVelocity;
    private  int mOverScrollExtends;
    private float startEventX = 0;
    private float startEventY = 0;
    private float mSlopTouchScale = 0;
    private boolean isTouchMoving = false;
    private View mHeaderView = null;
    private View mBodyView = null;
    private View mVerticalScrollView = null;
    private VelocityTracker mVelocityTracker;
    public NestedScrollChildLayout(@NonNull Context context) {
        this(context, null);
    }
    public NestedScrollChildLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }
    public NestedScrollChildLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        mSlopTouchScale = ViewConfiguration.get(context).getScaledTouchSlop();
        mFlingVelocity = ViewConfiguration.get(context).getScaledMaximumFlingVelocity();
        setClickable(true);
    }

    public void setOverScrollExtends(int overScrollExtends) {
        this.mOverScrollExtends = overScrollExtends;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int childCount = getChildCount();
        int height = MeasureSpec.getSize(heightMeasureSpec);
        int overScrollExtent = overScrollExtent();
        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);
            LayoutParams lp = (LayoutParams) child.getLayoutParams();
            if (lp.childLayoutType == LayoutParams.TYPE_BODY) {
                final int childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec,
                        getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin
                                + 0, lp.width);
                final int childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec,
                        getPaddingTop() + getPaddingBottom() + lp.topMargin + lp.bottomMargin
                                + 0, height-overScrollExtent);
                child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
            }
        }

    }
  //计算垂直方向是否可以继续滑动
    public boolean canScrollVertically(int direction) {
        final int offset = computeVerticalScrollOffset();
        final int range = computeVerticalScrollRange() - computeVerticalScrollExtent();
        if (range == 0) return false;
        if (direction < 0) {
            return offset > 0;
        } else {
            return offset < range;
        }
    }

    @Override
    protected int computeVerticalScrollRange() {
        int childCount = getChildCount();
        if (childCount == 0) return super.computeVerticalScrollRange();
        int range = getPaddingBottom() + getPaddingTop();
        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);
            LayoutParams lp = (LayoutParams) child.getLayoutParams();
            range += child.getHeight() + lp.bottomMargin + lp.topMargin;
        }
        if (range < getHeight()) {
            return super.computeVerticalScrollRange();
        }
        return range;
    }
    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        mHeaderView = getChildView(LayoutParams.TYPE_HEAD);
        mBodyView = getChildView(LayoutParams.TYPE_BODY);
        int childLeft = getPaddingLeft();
        int childTop = getPaddingTop();
        if (mHeaderView != null) {
            LayoutParams lp = (LayoutParams) mHeaderView.getLayoutParams();
            mHeaderView.layout(childLeft + lp.leftMargin, childTop + lp.topMargin, childLeft + lp.leftMargin + mHeaderView.getMeasuredWidth(), childTop + lp.topMargin + mHeaderView.getMeasuredHeight());
            childTop += mHeaderView.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
        }
        if (mBodyView != null) {
            LayoutParams lp = (LayoutParams) mBodyView.getLayoutParams();
            mBodyView.layout(childLeft + lp.leftMargin, childTop + lp.topMargin, childLeft + lp.leftMargin + mBodyView.getMeasuredWidth(), childTop + lp.topMargin + mBodyView.getMeasuredHeight());
        }
    }
    protected int overScrollExtent() {
        return  mOverScrollExtends;
    }
    private View getHeaderView() {
        return mHeaderView;
    }

    private View getBodyView() {
        return mBodyView;
    }

    private View findTouchView(float currentX, float currentY) {

        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);

            float childX = (child.getX() - getScrollX());
            float childY = (child.getY() - getScrollY());

            if (currentX < childX || currentX > (childX + child.getWidth())) {
                continue;
            }
            if (currentY < childY || currentY > (childY + child.getHeight())) {
                continue;
            }
            return child;
        }
        return null;
    }

    private boolean hasHeader() {
        int count = getChildCount();
        for (int i = 0; i < count; i++) {
            LayoutParams lp = (LayoutParams) getChildAt(i).getLayoutParams();
            if (lp.childLayoutType == LayoutParams.TYPE_HEAD) {
                return true;
            }
        }
        return false;
    }
    public View getChildView(int layoutType) {
        int count = getChildCount();
        for (int i = 0; i < count; i++) {
            LayoutParams lp = (LayoutParams) getChildAt(i).getLayoutParams();
            if (lp.childLayoutType == layoutType) {
                return getChildAt(i);
            }
        }
        return null;
    }

    private boolean hasBody() {
        int count = getChildCount();
        for (int i = 0; i < count; i++) {
            LayoutParams lp = (LayoutParams) getChildAt(i).getLayoutParams();
            if (lp.childLayoutType == LayoutParams.TYPE_BODY) {
                return true;
            }
        }
        return false;
    }

    @Override
    public void addView(View child) {
        assertLayoutType(child);
        super.addView(child);
    }
    private void assertLayoutType(View child) {
        ViewGroup.LayoutParams lp = child.getLayoutParams();
        assertLayoutParams(lp);
    }
    private void assertLayoutParams(ViewGroup.LayoutParams lp) {

        if (hasHeader() && hasBody()) {
            throw new IllegalStateException("header and body has already existed");
        }
        if (hasHeader()) {
            if (!(lp instanceof LayoutParams)) {
                throw new IllegalStateException("header should keep only one");
            }
            if (((LayoutParams) lp).childLayoutType == LayoutParams.TYPE_HEAD) {
                throw new IllegalStateException("header should keep only one");
            }
        }
        if (hasBody()) {
            if ((lp instanceof LayoutParams) && ((LayoutParams) lp).childLayoutType == LayoutParams.TYPE_BODY) {
                throw new IllegalStateException("header should keep only one");
            }
        }
    }
    @Override
    public void addView(View child, int index, ViewGroup.LayoutParams params) {
        assertLayoutParams(params);
        super.addView(child, index, params);
    }
    @Override
    public void addView(View child, int index) {
        assertLayoutType(child);
        super.addView(child, index);
    }
    @Override
    public void addView(View child, int width, int height) {
        assertLayoutParams(new LinearLayout.LayoutParams(width, height));
        super.addView(child, width, height);
    }
    @Override
    public void onViewAdded(View child) {
        super.onViewAdded(child);
        LayoutParams lp = (LayoutParams) child.getLayoutParams();
        if (lp.childLayoutType != LayoutParams.TYPE_BODY) {
            return;
        }
        if (!(child instanceof NestedScrollingChild) &&  !(child instanceof ScrollFlingChild)) {
            throw new RuntimeException("body must be 'view implemention NestedScrollingChild or ScrollFlingChild '");
        }
    }
    @Override
    protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
        return p instanceof LayoutParams;
    }
    @Override
    protected FrameLayout.LayoutParams generateDefaultLayoutParams() {
        return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
    }

    @Override
    public FrameLayout.LayoutParams generateLayoutParams(AttributeSet attrs) {
        return new LayoutParams(getContext(), attrs);
    }

    @Override
    protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams lp) {
        return new LayoutParams(lp);
    }

    @Override
    public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, int type) {
        if (axes == SCROLL_AXIS_VERTICAL) {
            //只关注垂直方向的移动
            int maxOffset = computeVerticalScrollRange() - computeVerticalScrollExtent();
            int offset = computeVerticalScrollOffset();
            if (offset <= maxOffset) {
                mVerticalScrollView = target;
                return true;
            }
        }
        return false;
    }

    @Override
    protected int computeVerticalScrollExtent() {
        int computeVerticalScrollExtent = super.computeVerticalScrollExtent();
        return computeVerticalScrollExtent ;
    }

    @Override
    public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, int type) {

    }
    @Override
    public void onStopNestedScroll(@NonNull View target, int type) {
        if (mVerticalScrollView == target) {
            Log.d("onNestedScroll", "::::onStopNestedScroll vertical");
        }
    }
    @Override
    public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {

    }
    @Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @Nullable int[] consumed, int type) {
        int scrollRange = computeVerticalScrollRange();
        if (scrollRange <= getHeight()) {
            return;
        }
        if (target == null) return;
        if (mVerticalScrollView != target) {
            return;
        }
        int maxOffset = computeVerticalScrollRange() - computeVerticalScrollExtent();
        int scrollOffset = computeVerticalScrollOffset();
        handleVerticalNestedScroll(dx, dy, consumed, maxOffset, scrollOffset);

    }

    private void handleVerticalNestedScroll(int dx, int dy, @Nullable int[] consumed, int maxOffset, int scrollOffset) {
        if (dy == 0) return;
        if (!checkScrollableStationTop(mVerticalScrollView)) {
            return;
        }
        int dyOffset = dy;
        int targetOffset = scrollOffset + dy;
        if (targetOffset >= maxOffset) {
            dyOffset = maxOffset - scrollOffset;
        }
        if (targetOffset <= 0) {
            dyOffset = 0 - scrollOffset;
        }
        if (!canScrollVertically(dyOffset)) {
            return;
        }
        consumed[1] = dyOffset;
        Log.d("onNestedScroll", "::::" + dyOffset + "+" + scrollOffset + "=" + (scrollOffset + dyOffset));
        scrollBy(0, dyOffset);
    }
    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        int scrollRange = computeVerticalScrollRange();
        if (scrollRange <= getHeight()) {
            return super.dispatchTouchEvent(event);
        }
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        int action = event.getAction();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mVelocityTracker.addMovement(event);
                startEventX = event.getX();
                startEventY = event.getY();
                isTouchMoving = false;
                break;
            case MotionEvent.ACTION_MOVE:
                float currentX = event.getX();
                float currentY = event.getY();
                float dx = currentX - startEventX;
                float dy = currentY - startEventY;
                if (!isTouchMoving && Math.abs(dy) < Math.abs(dx)) {
                    startEventX = currentX;
                    startEventY = currentY;
                    break;
                }
                View touchView = null;
                int offset = (int) -dy;
                if (Math.abs(dy) >= mSlopTouchScale) {
                    touchView = findTouchView(currentX, currentY);
                    isTouchMoving = touchView != null && touchView == getHeaderView();
                }
                if (offset != 0 && !canScrollVertically(offset)) {
                    isTouchMoving = false;
                }
                startEventX = currentX;
                startEventY = currentY;
                if (!isTouchMoving) {
                    break;
                }
                mVelocityTracker.addMovement(event);
                int maxOffset = computeVerticalScrollRange() - computeVerticalScrollExtent();
                int scrollOffset = computeVerticalScrollOffset();
                int targetOffset = scrollOffset + offset;
                if (targetOffset >= maxOffset) {
                    offset = maxOffset - scrollOffset;
                }
                if (targetOffset <= 0) {
                    offset = 0 - scrollOffset;
                }
                if (offset == 0) {
                    break;
                }

                scrollBy(0, offset);
                Log.d("onNestedScroll", ">:>:>" + offset + "+" + scrollOffset + "=" + (scrollOffset + offset));
                super.dispatchTouchEvent(event);
                return true;

            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_OUTSIDE:
                mVelocityTracker.addMovement(event);
                if (isTouchMoving) {
                    isTouchMoving = false;
                    mVelocityTracker.computeCurrentVelocity(1000, mFlingVelocity);
                    startFling(mVelocityTracker, (int) event.getX(), (int) event.getY());
                    mVelocityTracker.recycle();
                    mVelocityTracker = null;
                }
                break;
        }

        return super.dispatchTouchEvent(event);
    }

    private void startFling(VelocityTracker velocityTracker, int x, int y) {
        int xVolecity = (int) velocityTracker.getXVelocity();
        int yVolecity = (int) velocityTracker.getYVelocity();
        if (mVerticalScrollView instanceof NestedScrollingChild) {
            Log.d("onNestedScroll", "onNestedScrollfling xVolecity=" + xVolecity + ", yVolecity=" + yVolecity);
            ((RecyclerView) mVerticalScrollView).fling(xVolecity, -yVolecity);
        }
        if (mVerticalScrollView instanceof ScrollFlingChild) {
            ((ScrollFlingChild) mVerticalScrollView).startFling(xVolecity, yVolecity);
        }
    }
    private boolean checkScrollableStationTop(View view) {
        if (view instanceof RecyclerView) {
            //显示区域最上面一条信息的position
            RecyclerView.LayoutManager manager = ((RecyclerView) view).getLayoutManager();
            if (manager == null) {
                return true;
            }
            if (manager.getChildCount() == 0) {
                return true;
            }
            int scrollOffset = ((RecyclerView) view).computeVerticalScrollOffset();
            return scrollOffset <= 0;
        }
        if (view instanceof NestedScrollingChild) {
            return view.canScrollVertically(-1);
        }
        if ((view instanceof View) && !(view instanceof ViewGroup)) {
            return true;
        }

        throw new IllegalArgumentException("不支持非NestedScrollingChild子类ViewGroup");
    }

    public static class LayoutParams extends FrameLayout.LayoutParams {
        public final static int TYPE_HEAD = 0;
        public final static int TYPE_BODY = 1;
        private int childLayoutType = TYPE_HEAD;

        public LayoutParams(@NonNull Context c, @Nullable AttributeSet attrs) {
            super(c, attrs);
            if (attrs == null) return;
            final TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.NestedScrollChildLayout);
            childLayoutType = a.getInt(R.styleable.NestedScrollChildLayout_layoutScrollNestedType, 0);
            a.recycle();
        }

        public LayoutParams(int width, int height) {
            super(width, height);
        }
        public LayoutParams(@NonNull ViewGroup.LayoutParams source) {
            super(source);
        }
        public LayoutParams(@NonNull MarginLayoutParams source) {
            super(source);
        }

    }
    public interface ScrollFlingChild {
        public void startFling(int xVolecity, int yVolecity);
    }
}

2.2 定义 View 属性参数

用于标记不同的角色,这个主要作用于布局问题

 <declare-styleable name="NestedScrollChildLayout">
        <attr name="layoutScrollNestedType" format="flags">
            <flag name="Head" value="0"/>
            <flag name="Body" value="1"/>
        </attr>
</declare-styleable>

2.3 使用方式

下面是在布局中的使用方式,我们通过nestedChildLayoutType对View进行标记,head表示Header,而body表示内容区域,本篇的核心是RecyclerView,这里我们使用RecyclerView。

<com.cn.scrolllayout.view.NestedScrollChildLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:focusable="true"
    android:focusableInTouchMode="true"
    android:orientation="vertical">

    <LinearLayout
        android:id="@+id/head"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        app:nestedChildLayoutType="Header"
        >
        <ImageView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:src="@mipmap/img_sample_panda"
            android:scaleType="centerCrop"
            />
    </LinearLayout>

    <android.support.v7.widget.RecyclerView
        android:id="@+id/body"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:nestedChildLayoutType="Body"
        android:background="@color/colorPrimary"
        />

</com.cn.scrolllayout.view.NestedScrollChildLayout>

2.4 吸顶效果

当然,我们还有个吸顶效果,其实这里的实现更简单,在代码中设置便宜量,就能减少RecyclerView的高度,从而可以露出吸顶View的一部份。

    public void setOverScrollExtends(int overScrollExtends) {
        this.mOverScrollExtends = overScrollExtends;
    }

三、总结

本篇是利用NestedScrollLing机制实现了自定义View布局,相比ViewDragHelper和传统的事件处理,开发效率和简单程度上大幅提升,同时避免了ViewDragHelper不能多View联动以及传统事件处理的复杂性。实际上本篇并不能阻止传统的View组件没落的趋势,唯有Canvas不分还能继续使用外,传统的布局中处理Surface相关类之外,被jetPack Compose取代是迟早的事。本篇主要还是复习Android知识。