利用Android嵌套滑动机制轻松实现顶部布局置顶

523 阅读5分钟

Google在LOLLIPOP(SDK21)后加入的嵌套滑动官方解决方案。

1、问题典型场景

通常是信息流(比如社区、资讯、新闻)页面或者商品详情页的交互设计。 如图:

分解到代码就是一般三个控件:一个头布局,可能是吧banner;一个导航控件;下面一个内容的列表控件。要求头布局和导航布局在内容布局滑动了一定距离(一般是头布局的高度加上导航控件的高度)后,导航控件置顶,然后内容列表继续滑动。

2、Android事件分发机制处理问题的痛点

传统的Android事件分发是子控件消费了事件,那么父控件就不能再处理这个事件了。也就是说一旦内部的滑动控件消费了滑动操作,外部的滑动控件就不能获取到这个滑动动作也就无法做处理了。在我们上一个情景里,滑动内容列表控件要求头布局和导航布局作出响应就是要求他们的共同父布局作出响应,显然用传统的事件分发处理是很困难的。

3、Android嵌套滑动机制基础概念

嵌套滚动中的两个接口,在上文中已经提到。NestedScrollingParent和NestedScrollingChild 接口中的方法如下: NestedScrollingChild

  • startNestedScroll : 起始方法, 主要作用是找到接收滑动距离信息的外控件.
  • dispatchNestedPreScroll : 在内控件处理滑动前把滑动信息分发给外控件.
  • dispatchNestedScroll : 在内控件处理完滑动后把剩下的滑动距离信息分发给外控件.
  • stopNestedScroll : 结束方法, 主要作用就是清空嵌套滑动的相关状态
  • setNestedScrollingEnabled和isNestedScrollingEnabled : 一对get&set方法, 用来判断控件是否支持嵌套滑动.
  • dispatchNestedPreFling和dispatchNestedFling : 跟Scroll的对应方法作用类似

NestedScrollingParent

  • onStartNestedScroll : 对应startNestedScroll, 内控件通过调用外控件的这个方法来确定外控件是否接收滑动信息.
  • onNestedScrollAccepted : 当外控件确定接收滑动信息后该方法被回调, 可以让外控件针对嵌套滑动做一些前期工作.
  • onNestedPreScroll : 关键方法, 接收内控件处理滑动前的滑动距离信息, 在这里外控件可以优先响应滑动操作, 消耗部分或者全部滑动距离.
  • onNestedScroll : 关键方法, 接收内控件处理完滑动后的滑动距离信息, 在这里外控件可以选择是否处理剩余的滑动距离.
  • onStopNestedScroll : 对应stopNestedScroll, 用来做一些收尾工作.
  • onNestedPreFling和onNestedFling : 同上略

4、嵌套滑动关键类源码分析

子view接受到滚动事件后发起嵌套滚动,询问父View是否要先滚动,父View处理了自己的滚动需求后,回到子View处理自己的滚动需求,假如父View消耗了一些滚动距离,子View只能获取剩下的滚动距离做处理。子View处理了自己的滚动需求后又回到父View,剩下的滚动距离做处理。惯性fling的类似。

public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
    if (hasNestedScrollingParent(type)) {
        // Already in progress
        return true;
    }
    if (isNestedScrollingEnabled()) {
        ViewParent p = mView.getParent();
        View child = mView;
        while (p != null) {
            if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
                setNestedScrollingParentForType(type, p);
                ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
                return true;
            }
            if (p instanceof View) {
                child = (View) p;
            }
            p = p.getParent();
        }
    }
    return false;
}

接下来在RecyclerView的onTouchEvent的 MotionEvent.ACTION_MOVE里调用了dispatchNestedPreScroll和scrollByInternal

case MotionEvent.ACTION_MOVE: {
   
    if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset, TYPE_TOUCH)) {
        dx -= mScrollConsumed[0];
        dy -= mScrollConsumed[1];
        vtev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
        // Updated the nested offsets
        mNestedOffsets[0] += mScrollOffset[0];
        mNestedOffsets[1] += mScrollOffset[1];
    }
    
    if (mScrollState == SCROLL_STATE_DRAGGING) {
        mLastTouchX = x - mScrollOffset[0];
        mLastTouchY = y - mScrollOffset[1];

        if (scrollByInternal(
                canScrollHorizontally ? dx : 0,
                canScrollVertically ? dy : 0,
                vtev)) {
            getParent().requestDisallowInterceptTouchEvent(true);
        }
       
    }
} break;

dispatchNestedPreScroll中调了父View的onNestedPreScroll,并且传入dy 和 consumed。用于做消费计数。

public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow, @NestedScrollType int type) {
    if (isNestedScrollingEnabled()) {
        final ViewParent parent = getNestedScrollingParentForType(type);
        if (parent == null) {
            return false;
        }

        if (dx != 0 || dy != 0) {
            ⋯⋯
            consumed[0] = 0;
            consumed[1] = 0;
            ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type);

            ⋯⋯
            return consumed[0] != 0 || consumed[1] != 0;
        } else if (offsetInWindow != null) {
            offsetInWindow[0] = 0;
            offsetInWindow[1] = 0;
        }
    }
    return false;
}

最终调用了父view的onNestedPreScroll()方法。 依次分析可以看出嵌套滚动执行的方法顺序如下:

(子)startNestedScroll → (父)onStartNestedScroll → (父)onNestedScrollAccepted→ (子)dispatchNestedPreScroll → (父)onNestedPreScroll→ (子)dispatchNestedScroll→ (父)onNestedScroll→ (子)dispatchNestedPreFling → (父)onNestedPreFling→ (子)dispatchNestedFling → (父)stopNestedScroll

5、嵌套滑动典型案例实践

关键方法就两个就可以完成效果,只是和僵硬,为了更好的用户体验,就需要加入手势速度的滑动预判:

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        mHeaderView.measure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
        mMaxScrollHeight = mHeaderView.getMeasuredHeight() - mHeaderRetainHeight;
        //设置主体的高度:代码中设置match_parent
        if (mBodyView.getLayoutParams().height < getMeasuredHeight() - mHeaderRetainHeight) {
            mBodyView.getLayoutParams().height = getMeasuredHeight() - mHeaderRetainHeight;
        }
        setMeasuredDimension(getMeasuredWidth(), mBodyView.getLayoutParams().height + mHeaderView.getMeasuredHeight());
    }

在onMeasure()中计算头部布局和置顶布局高度,完成整个控件的测量,并记下头部布局去掉置顶布局最大可滑动的距离值。

@Override
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
        boolean hiddenTop = dy > 0 && getScrollY() < mMaxScrollHeight;
        boolean showTop = dy < 0 && getScrollY() > 0 && !ViewCompat.canScrollVertically(target, -1);
        if (hiddenTop || showTop) {
            scrollBy(0, dy);
            consumed[1] = dy;
        }
    }

然后重写这个方法就可以实现对应的滑动嵌套,也就是导航栏控件置顶,其实也就是预先知道了导航栏的高度,然后在下滑并且下滑距离大于最大可滑动距离,和上滑并且内容控件不可滑动的时候就全部滑动距离交给父控件也就是实现了NestedScrollParent接口的自己。

相当代码可以参考下我的github实例: StickyNestedScrollLayout

参考: Android NestedScrolling机制完全解析 带你玩转嵌套滑动

嵌套滚动设计和源码分析