在上一篇文章Android进阶宝典 — 事情抵触怎么处理?先从Android事情分发机制开端说起中,咱们详细地介绍了Android事情分发机制,其实只需页面结构复杂,联动许多就会产生事情抵触,处理不得当便是bug,e.g. 我画了一张很丑的图

Android进阶宝典 -- NestedScroll嵌套滑动机制实现吸顶效果

其实这种交互形式在许多电商、支付渠道都十分常见,页面全体是可滑动的(scrollable),当页面全体往上滑时,是外部滑动组件,e.g. NestedScrollView,当TabBar滑动到顶部的时分吸顶,紧接着ListView自身特性持续往上滑。

其实这种效果,体系现已帮咱们完成好了,尤其是像NestScrollView;假如咱们在自界说View的时分,没有体系才能的加持,会有问题吗?假如熟悉Android事情分发机制,由于全体上滑的时分,外部组件消费了DOWM事情和MOVE事情,等到Tabbar吸顶之后,再次滑动ListView的时分,由于事情都在外部阻拦,此刻 mFirstTouchTarget还是父容器,没有时机让父容器撤销事情再转换到ListView,导致ListView不行滑动。

那么咱们只有松开手,再次滑动ListView,让DOWN事情传递到ListView当中,这样列表会持续滑动,显得没有那么顺滑,从用户体会上来说是不行接受的。

1 自界说滑动布局,完成吸顶效果

首要咱们假如想要完成这个效果,其实办法有许多,CoordinateLayout便是其间之一,可是假如咱们想要自界说一个可滑动的布局,并且还需求完成Tabbar的吸顶效果,咱们需求注意两点:

1)在头部没有被移出屏幕的时分,事情需求被外部阻拦,只能滑动外部布局,ListView不行滑动;

2)当头部被移出到屏幕之外时,事情需求被ListView消费(持续上滑时),假如下滑时则是同样会先把头部拉出来然后才可以滑动ListView

1.1 滑动容器完成

由于咱们知道,要操控view移动,可以调用scrollBy或许scrollTo两个办法,其间两个办法的区别在于,前者是滑动的相对上一次的间隔,而后者是滑动到详细位置。

class MyNestScrollView @JvmOverloads constructor(
    val mContext: Context,
    val attributeSet: AttributeSet? = null,
    val flag: Int = 0
) : LinearLayout(mContext, attributeSet, flag) {
    private var mTouchSlop = 0
    private var startY = 0f
    init {
        mTouchSlop = ViewConfiguration.get(context).scaledPagingTouchSlop
    }
    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
        /**什么时分阻拦事情呢,当头部还没有消失的时分*/
        return super.onInterceptTouchEvent(ev)
    }
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        when (event?.action) {
            MotionEvent.ACTION_DOWN -> {
                Log.e("TAG", "MyNestScrollView ACTION_DOWN")
                startY = event.y
            }
            MotionEvent.ACTION_MOVE -> {
                Log.e("TAG", "MyNestScrollView ACTION_MOVE")
                val endY = event.y
                if (abs(endY - startY) > mTouchSlop) {
                    //滑动了
                    scrollBy(0, (startY - endY).toInt())
                }
                startY = endY
            }
        }
        return super.onTouchEvent(event)
    }
    override fun scrollTo(x: Int, y: Int) {
        var finalY = 0
        if (y < 0) {
        } else {
            finalY = y
        }
        super.scrollTo(x, finalY)
    }
}

所以在事情消费的时分,会调用scrollBy,来进行页面的滑动,假如咱们看scrollBy的源码,会理解终究调用便是经过scrollTo完成的,只不过是在上次pos的根底上进行累计。

public void scrollBy(int x, int y) {
    scrollTo(mScrollX + x, mScrollY + y);
}

所以这儿重写了scrollTo办法,来判别y(纵向)滑动的位置,由于当y小于0的时分,依照Android的坐标系,咱们知道假如一向往下滑,那么△Y(竖直方向滑动间隔) < 0,假如一向向下滑,终究totalY也会小于0,所以这儿也是做一次鸿沟的处理。

接下来咱们需求处理下吸顶效果,所以咱们需求知道,顶部View的高度,以便操控滑动的间隔,也是一次鸿沟处理。

override fun scrollTo(x: Int, y: Int) {
    var finalY = 0
    if (y < 0) {
    } else {
        finalY = y
    }
    if (y > mTopViewHeight) {
        finalY = mTopViewHeight
    }
    super.scrollTo(x, finalY)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    //顶部view是榜首个View
    mTopViewHeight = getChildAt(0).measuredHeight
}

所以这儿需求和咱们写的布局相对应,顶部view是容器中榜首个子View,经过在onSizeChanged或许onMeasure中获取榜首个子View的高度,在滑动时,假如滑动的间隔超过 mTopViewHeight(顶部View的高度),那么滑动时也就不会再持续滑动了,这样就完成了TabBar的吸顶效果。

根底作业完成了,接下来咱们完成需求注意的榜首点,先看下面的图:

Android进阶宝典 -- NestedScroll嵌套滑动机制实现吸顶效果

当咱们上滑的时分,头部是预备逐步躲藏的,所以这儿会有几个条件,首要 mStartX – nowX > 0 并且 scrollY < mTopViewHeight,并且此刻scrollY是大于0的

/**
 * 头部View逐步消失
 * @param dy 手指滑动的相对间隔 dy >0 上滑 dy < 0 下滑
 */
private fun isViewHidden(dy: Int): Boolean {
    return dy > 0 && scrollY < mTopViewHeight
}

当咱们向下滑动的时分,此刻 mStartX – nowX < 0,由于此刻头部躲藏了,所以ScrollY > 0,并且此刻是可以滑动的,假如到了下面这个鸿沟条件(不会有这种状况产生,因此在滑动时做了鸿沟处理),此刻scrollY < 0

Android进阶宝典 -- NestedScroll嵌套滑动机制实现吸顶效果

private fun isViewShow(dy: Int):Boolean{
    return dy < 0 && scrollY > 0 && !canScrollVertically(-1)
}

此刻还有一个条件,便是canScrollVertically,这个信任同伴们也很熟悉,意味着当时View是可以往下滑动的,假如回来了false,那么便是不能持续往下滑动了。

override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
    var intercepted = false
    /**什么时分阻拦事情呢,当头部还没有消失的时分*/
    when (ev?.action) {
        MotionEvent.ACTION_DOWN -> {
            startY = ev.rawY
        }
        MotionEvent.ACTION_MOVE -> {
            val endY = ev.rawY
            if (abs(startY - endY) > mTouchSlop) {
                if (isViewHidden((startY - endY).toInt())
                    || isViewShow((startY - endY).toInt())
                ) {
                    Log.e("TAG","此刻就需求阻拦,外部进行消费事情")
                    //此刻就需求阻拦,外部进行消费事情
                    intercepted = true
                }
            }
            startY = endY
        }
    }
    return intercepted
}

所以在外部阻拦的时分,经过判别这两种状态,假如满足其间一个条件就会阻拦事情完全由外部容器处理,这样就完成了吸顶效果的处理。

Android进阶宝典 -- NestedScroll嵌套滑动机制实现吸顶效果

1.2 嵌套滑动机制完成交互优化

经过上面的gif,咱们看效果形似还可以,可是有一个问题便是,当完成吸顶之后,ListView并不能跟从手指持续向上滑动,而是需求松开手指之后,再次滑动即可,其实咱们从Android事情分发机制中就可以知道,此刻mFirstTouchTarget == 父容器,此刻再次上滑并没有给父容器Cancel的时机,所以才导致事情没有被ListView接纳。

由于传统的事情抵触处理方案,会导致滑动不流通,此刻就需求嵌套滑动机制处理这个问题。在前面咱们提到过,NestedScrollView其实便是现已处理过嵌套滑动了,所以咱们前面去看一下NestedScrollView究竟干了什么事?

public class NestedScrollView extends FrameLayout implements NestedScrollingParent3,
        NestedScrollingChild3, ScrollingView 

咱们看到,NestedScrollView是完成了NestedScrollingParent3、NestedScrollingChild3等接口,挺有意思的,这几个接口形似都是依据数字做了升级,既然有3,那么必定有1和2,所以咱们看下这几个接口的效果。

1.2.1 NestedScrollingParent接口和NestedScrollingChild接口

对于NestedScrollingParent接口,假如可滑动的ViewGroup,e.g. 咱们在1.1中界说的容器作为父View,那么就需求完成这个接口;假如是作为可滑动的子View,那么就需求完成NestedScrollingChild接口,由于咱们在自界说控件的时分,它既或许作为子View也或许作为父View,因此这俩接口都需求完成。

public interface NestedScrollingChild {
    /**
     * Enable or disable nested scrolling for this view.
     *
     * 启动或许禁用嵌套滑动,假如回来ture,那么说明当时布局存在嵌套滑动的场景,反之没有
     * 运用场景:NestedScrollingParent嵌套NestedScrollingChild
     * 在此接口中的办法,都是交给NestedScrollingChildHelper署理类完成
     */
    void setNestedScrollingEnabled(boolean enabled);
    /**
     * Returns true if nested scrolling is enabled for this view.
     * 其实便是回来setNestedScrollingEnabled中设置的值
     */
    boolean isNestedScrollingEnabled();
    /**
     * Begin a nestable scroll operation along the given axes.
     * 表明view开端翻滚了,一般是在ACTION_DOWN中调用,假如回来true则表明父布局支持嵌套翻滚。
     * 一般也是直接署理给NestedScrollingChildHelper的同名办法即可。这个时分正常状况会触发Parent的onStartNestedScroll()办法
     */
    boolean startNestedScroll(@ScrollAxis int axes);
    /**
     * Stop a nested scroll in progress.
     * 中止嵌套翻滚,一般在UP或许CANCEL事情中执行,告知父容器现已中止了嵌套滑动
     */
    void stopNestedScroll();
    /**
     * Returns true if this view has a nested scrolling parent.
     * 判别当时View是否存在嵌套滑动的Parent
     */
    boolean hasNestedScrollingParent();
    /**
    * 当时View消费滑动事情之后,翻滚一段间隔之后,把剩余的间隔回调给父容器,父容器知道当时剩余间隔
    * dxConsumed:x轴翻滚的间隔
    * dyConsumed:y轴翻滚的间隔
    * dxUnconsumed:x轴未消费的间隔
    * dyUnconsumed:y轴未消费的间隔
    * 这个办法是嵌套滑动的时分调用才有用,回来值 true分发成功;false 分发失败
    */
    boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);
    /**
     * Dispatch one step of a nested scroll in progress before this view consumes any portion of it.
     * 在子View消费滑动间隔之前,将滑动间隔传递给父容器,相当于把消费权交给parent
     * dx:当时水平方向滑动的间隔
     * dy:当时笔直方向滑动的间隔
     * consumed:输出参数,会将Parent消费掉的间隔封装进该参数consumed[0]代表水平方向,consumed[1]代表笔直方向
    * @return true:代表Parent消费了翻滚间隔
     */
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow);
    /**
     * Dispatch one step of a nested scroll in progress.
     * 处理惯性事情,与dispatchNestedScroll相似,也是在消费事情之后,将消费和未消费的间隔都传递给父容器
     */
    boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
    /**
     * Dispatch a fling to a nested scrolling parent before it is processed by this view.
     * 与dispatchNestedPreScroll相似,在消费之前首要会传递给父容器,把优先处理权交给父容器
     */
    boolean dispatchNestedPreFling(float velocityX, float velocityY);
}
public interface NestedScrollingParent {
    /**
     * React to a descendant view initiating a nestable scroll operation, claiming the
     * nested scroll operation if appropriate.
     * 当子View调用startNestedScroll办法的时分,父容器会在这个办法中获取回调
     */
    boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
    /**
     * React to the successful claiming of a nested scroll operation.
     * 在onStartNestedScroll调用之后,就紧接着调用这个办法
     */
    void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
    /**
     * React to a nested scroll operation ending.
     * 当子View调用 stopNestedScroll办法的时分回调
     */
    void onStopNestedScroll(@NonNull View target);
    /**
     * React to a nested scroll in progress.
     * 
     */
    void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed);
    /**
     * React to a nested scroll in progress before the target view consumes a portion of the scroll.
     * 在子View调用dispatchNestedPreScroll之后,这个办法拿到了回调
     * 
     */
    void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);
    /**
     * Request a fling from a nested scroll.
     *
     */
    boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);
    /**
     * React to a nested fling before the target view consumes it.
     *
     */
    boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);
    /**
     * Return the current axes of nested scrolling for this NestedScrollingParent.
     * 回来当时滑动的方向
     */
    @ScrollAxis
    int getNestedScrollAxes();
}

经过这两个接口,咱们大概就可以理解,其实嵌套滑动机制完全是子View在做主导,经过子View可以决定Parent是否可以优先消费事情(dispatchNestedPreScroll),所以咱们先从子View开端,开启嵌套滑动之旅。

1.2.2 预翻滚阶段完成

在这个示例中,需求与parent嵌套滑动的便是RecyclerView,所以RecyclerView就需求完成child接口。前面咱们看到child接口很多办法,该怎么调用呢?其实这个接口中大部分的办法都可以交给一个helper署理类完成,e.g. NestedScrollingChildHelper.

由于一切的嵌套滑动都是由子View主导,所以咱们先看子View消费事情,也便是onTouchEvent中,假如当手指按下的时分,首要获取滑动的是x轴还是y轴,这儿咱们就认为是竖向滑动,然后调用NestedScrollingChild的startNestedScroll办法,这个办法就代表开端滑动了。

override fun onTouchEvent(e: MotionEvent?): Boolean {
    when(e?.action){
        MotionEvent.ACTION_DOWN->{
            mStartX = e.y.toInt()
            //子View开端嵌套滑动
            var axis = ViewCompat.SCROLL_AXIS_NONE
            axis = axis or ViewCompat.SCROLL_AXIS_VERTICAL
            nestedScrollingChildHelper.startNestedScroll(axis)
        }
        MotionEvent.ACTION_MOVE->{
        }
    }
    return super.onTouchEvent(e)
}

咱们看下startNestedScroll内部的源码:

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;
}

从源码中 咱们可以看到,首要假如有嵌套滑动的父容器,直接回来true,此刻代表嵌套滑动成功;

public boolean hasNestedScrollingParent(@NestedScrollType int type) {
    return getNestedScrollingParentForType(type) != null;
private ViewParent getNestedScrollingParentForType(@NestedScrollType int type) {
    switch (type) {
        case TYPE_TOUCH:
            return mNestedScrollingParentTouch;
        case TYPE_NON_TOUCH:
            return mNestedScrollingParentNonTouch;
    }
    return null;
}

在判别的时分,会判别mNestedScrollingParentTouch是否为空,由于榜首次进来的时分肯定是空的,所以会持续往下走;假如支持嵌套滑动,那么就会进入到while循环中。

中心代码1:

while (p != null) {
    //---------- 判别条件1 -------------//
    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();
}

首要调用ViewParentCompat的onStartNestedScroll办法如下:

public static boolean onStartNestedScroll(@NonNull ViewParent parent, @NonNull View child,
        @NonNull View target, int nestedScrollAxes, int type) {
    if (parent instanceof NestedScrollingParent2) {
        // First try the NestedScrollingParent2 API
        return ((NestedScrollingParent2) parent).onStartNestedScroll(child, target,
                nestedScrollAxes, type);
    } else if (type == ViewCompat.TYPE_TOUCH) {
        // Else if the type is the default (touch), try the NestedScrollingParent API
        if (Build.VERSION.SDK_INT >= 21) {
            try {
                return Api21Impl.onStartNestedScroll(parent, child, target, nestedScrollAxes);
            } catch (AbstractMethodError e) {
                Log.e(TAG, "ViewParent " + parent + " does not implement interface "
                        + "method onStartNestedScroll", e);
            }
        } else if (parent instanceof NestedScrollingParent) {
            return ((NestedScrollingParent) parent).onStartNestedScroll(child, target,
                    nestedScrollAxes);
        }
    }
    return false;
}

其实在这个办法中,便是判别parent是否完成了NestedScrollingParent(2 3)接口,假如完成了此接口,那么回来值便是parent中onStartNestedScroll的回来值。

这儿需求注意的是,假如parent中onStartNestedScroll的回来值为false,那么就不会进入代码块的条件判别,所以在完成parent接口的时分,onStartNestedScroll需求回来true。进入代码块中调用setNestedScrollingParentForType办法,将父容器给mNestedScrollingParentTouch赋值,那么此刻hasNestedScrollingParent办法就回来true,不需求遍历View层级了。

private void setNestedScrollingParentForType(@NestedScrollType int type, ViewParent p) {
    switch (type) {
        case TYPE_TOUCH:
            mNestedScrollingParentTouch = p;
            break;
        case TYPE_NON_TOUCH:
            mNestedScrollingParentNonTouch = p;
            break;
    }
}

然后又紧接着调用了parent的onNestedScrollAccepted办法,这两者一前一后,这样预翻滚阶段就算是完成了

在父容器中,预翻滚节点就需求处理这两个回调即可,关键在于onStartNestedScroll的回来值。

override fun onStartNestedScroll(child: View, target: View, axes: Int): Boolean {
    Log.e("TAG","onStartNestedScroll")
    //这儿需求return true,否则在子View中分发事情就不会成功
    return true
}
override fun onNestedScrollAccepted(child: View, target: View, axes: Int) {
    Log.e("TAG","onNestedScrollAccepted")
}

1.2.3 翻滚阶段完成

然后MOVE事情来了,这个时分咱们需求记住,即便是滑动了子View,可是子View依然是需求将事情扔给父类,这儿就需求调用dispatchNestedPreScroll办法,这儿在1.2.1中介绍过,需求跟dispatchNestedScroll区别,dispatchNestedPreScroll是在子View消费事情之前就交给父类优先处理

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) {
            int startX = 0;
            int startY = 0;
            if (offsetInWindow != null) {
                mView.getLocationInWindow(offsetInWindow);
                startX = offsetInWindow[0];
                startY = offsetInWindow[1];
            }
            if (consumed == null) {
                consumed = getTempNestedScrollConsumed();
            }
            consumed[0] = 0;
            consumed[1] = 0;
            ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type);
            if (offsetInWindow != null) {
                mView.getLocationInWindow(offsetInWindow);
                offsetInWindow[0] -= startX;
                offsetInWindow[1] -= startY;
            }
            //-------- 由父容器是否消费决定回来值 -------//
            return consumed[0] != 0 || consumed[1] != 0;
        } else if (offsetInWindow != null) {
            offsetInWindow[0] = 0;
            offsetInWindow[1] = 0;
        }
    }
    return false;
}

在子View调用dispatchNestedPreScroll办法时,需求传入四个参数,这儿咱们再次详细介绍一下:
dx、dy指的是x轴和y轴滑动的间隔;
consumed在子View调用时,其实只需求传入一个空数组即可,详细的赋值是需求在父容器中进行,父view消费了多少间隔,就传入多少,consumed[0]代表x轴,consumed[1]代表y轴;

看上面的源码,当dx或许dy不为0的时分,说明有滑动了,那么此刻就会做一些初始化的装备,把consumed数组清空,然后会调用父容器的onNestedPreScroll办法,父容器决定是否消费这个事情,由于在父容器中会对consumed数组进行复制,所以这个办法的回来值代表着父容器是否消费过事情;假如消费过,那么就回来true,没有消费过,那么就回来false.

所以咱们先看父容器的处理:

override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray) {
    Log.e("TAG", "onNestedPreScroll")
    //父容器什么时分 消费呢?
    if (isViewShow(dy) || isViewHidden(dy)) {
        //假定这个时分把事情全消费了
        consumed[1] = dy
        scrollBy(0, dy)
    }
}

其实咱们这儿便是直接将之前在onTouchEvent中的处理逻辑放在了onNestedPreScroll中,假如在上拉或许下滑时,首要头部优先,假定父容器把间隔全部消费,这个时分给consumed[1]赋值为dy。

MotionEvent.ACTION_MOVE -> {
    val endY = e.y.toInt()
    val endX = e.x.toInt()
    var dx = mStartX - endX
    var dy = mStartY - endY
    //进行事情分发,优先给parent
    if (dispatchNestedPreScroll(dx, dy, cosumed, null)) {
        //假如父容器消费过事情,这个时分,cosumed有值了,咱们只关怀dy
        dy -= cosumed[1]
        if (dy == 0) {
            //代表父容器全给消费了
            return true
        }
    } else {
        //假如没有消费事情,那么就子view消费吧
        smoothScrollBy(dx, dy)
    }
}

再来看子View,这儿是在MOVE事情中进行事情分发,调用dispatchNestedPreScroll办法,判别假如父容器有事情消费,看消费了多少,剩余的便是子View消费;假如父容器没有消费,dispatchNestedPreScroll回来了false,那么子View自行处理事情

所以假如子View运用的是RecyclerView,那么在父容器做完处理之后,其实就可以完成嵌套滑动吸顶的完美效果,为什么呢?是由于RecyclerView本来就完成了parent接口,所以假如在自界说子View(可滑动)时,子View处理的这部分代码就需求特别关怀。

1.2.4 翻滚结束

在手指抬起之后,调用stopNestedScroll办法。

MotionEvent.ACTION_UP->{
    nestedScrollingChildHelper.stopNestedScroll()
}

从源码中看,其实便是回到父容器的onStopNestedScroll办法,然后将滑动的标志位(mNestedScrollingParentTouch)置为空,在下次按下的时分,重新初始化。

public void stopNestedScroll(@NestedScrollType int type) {
    ViewParent parent = getNestedScrollingParentForType(type);
    if (parent != null) {
        ViewParentCompat.onStopNestedScroll(parent, mView, type);
        setNestedScrollingParentForType(type, null);
    }
}