likes
comments
collection
share

如何应对Android面试官-> NestedScrollView 嵌套滑动机制详解,手写 NestedScrollView 核心实现

作者站长头像
站长
· 阅读数 3

前言

嵌套滚动机制

  • 事件的分发是从 Activity -> ViewGroup -> View
  • 事件的消费是从 View -> ViewGroup -> Activity
  • 事件的序列是从 Down -> Move -> Up/Cancel

前面的章节并没有提及 getScrollY() 指的是哪块区域,我们画两张图看下,白色区域表示屏幕,红色区域表示可滑动的 View:

如何应对Android面试官-> NestedScrollView 嵌套滑动机制详解,手写 NestedScrollView 核心实现

向上滑动时候,getScrollY() 就是负值,根据 Android 坐标系的下正上负

如何应对Android面试官-> NestedScrollView 嵌套滑动机制详解,手写 NestedScrollView 核心实现

那么向下滑动时候,getScrollY() 就是正值;

并且这个 getScrollY() 的结果是累加值;

前面讲解的时候,我们都是直接使用的 NestedScrollView,今天我们来手写一下的核心实现,嵌套滑动我们前面也说到了需要父 View 和子 View 配合使用才行,而我们的 NestedScrollView 实现了 NestedScrollingParent3 也实现了 NestedScrollingChild3,我们这里始终将 NestedScrollView 当做父容器,核心实现也只实现其作为父容器的部分,感兴趣的可以自行实现其作为子容器的逻辑部分,所以我们只需要实现 NestedScrollingParent 接口即可;

public class NestedScrollingParentLayout extends LinearLayout implements NestedScrollingParent {
    public NestedScrollingParentLayout(Context context) {
        super(context);
    }

    public NestedScrollingParentLayout(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    public NestedScrollingParentLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        setOrientation(VERTICAL);
    }

    @Override
    public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @NonNull int[] consumed) {

    }

    /**
     * 当NestedScrollingChild调用方法startNestedScroll()时,会调用该方法。主要就是通过返
     * 回值告诉系统是否需要对后续的滚动进行处理
     * child:该ViewParent的包含 NestedScrollingChild 的直接子 View,如果只有一层嵌套,和
     * target是同一个View
     * target:本次嵌套滚动的NestedScrollingChild
     * nestedScrollAxes:滚动方向
     * @return
     * true:表示我需要进行处理,后续的滚动会触发相应的回到
     * false: 我不需要处理,后面也就不会进行相应的回调了
     */
    // child 和 target 的区别,如果是嵌套两层如:Parent包含一个LinearLayout,LinearLayout里
    // 面才是 NestedScrollingChild 类型的View。这个时候,
    // child 指向 LinearLayout,target 指向 NestedScrollingChild;如果 Parent 直接就包含了
    // NestedScrollingChild,
    // 这个时候 target 和 child 都指向 NestedScrollingChild
    @Override
    public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes) {
        return false;
    }
    
    /**
     * 如果onStartNestedScroll()方法返回的是true的话,那么紧接着就会调用该方法.它是让嵌套滚
     * 动在开始滚动之前,
     * 让布局容器(viewGroup)或者它的父类执行一些配置的初始化的
     */
    @Override
    public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, int type) {

    }
    
    /**
     * 停止滚动了,当子view调用stopNestedScroll()时会调用该方法
     */
    @Override
    public void onStopNestedScroll(@NonNull View target) {

    }
    
    /**
     * 当子view调用dispatchNestedScroll()方法时,会调用该方法。也就是开始分发处理嵌套滑动了
     * dxConsumed:已经被target消费掉的水平方向的滑动距离
     * dyConsumed:已经被target消费掉的垂直方向的滑动距离
     * dxUnconsumed:未被tagert消费掉的水平方向的滑动距离
     * dyUnconsumed:未被tagert消费掉的垂直方向的滑动距离
     */
    @Override
    public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {

    }
    
    /**
     * 当子view调用dispatchNestedPreScroll()方法是,会调用该方法。也就是在
     * NestedScrollingChild在处理滑动之前,
     * 会先将机会给Parent处理。如果Parent想先消费部分滚动距离,将消费的距离放入consumed
     * dx:水平滑动距离
     * dy:处置滑动距离
     * consumed:表示Parent要消费的滚动距离,consumed[0]和consumed[1]分别表示父布局在x和y
     * 方向上消费的距离.
     */
    @Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed) {

    }
}

一个简单的实现 NestScrollingParent3 接口的 LinearLayout 子类;

```
public class NestedScrollingChildLayout extends LinearLayout implements NestedScrollingChild {
    public NestedScrollingChildLayout(Context context) {
        super(context);
    }

    public NestedScrollingChildLayout(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    public NestedScrollingChildLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        setOrientation(VERTICAL);
    }
    
   /**
    * 启用或禁用嵌套滚动的方法,设置为true,并且当前界面的View的层次结构是支持嵌套滚动的
    * (也就是需要NestedScrollingParent嵌套NestedScrollingChild),才会触发嵌套滚动。
    * 一般这个方法内部都是直接代理给NestedScrollingChildHelper的同名方法即可
    */
    @Override
    public void setNestedScrollingEnabled(boolean enabled) {
        
    }
    
    /**
     * 判断当前View是否支持嵌套滑动。一般也是直接代理给NestedScrollingChildHelper的同名方法即可
     */
    @Override
    public boolean isNestedScrollingEnabled() {
        return super.isNestedScrollingEnabled();
    }
    
    /**
     * 表示 view 开始滚动了,一般是在 ACTION_DOWN 中调用,如果返回 true 则表示父布局支持嵌套滚动。
     * 一般也是直接代理给 NestedScrollingChildHelper 的同名方法即可。这个时候正常情况会触发
     *  Parent的 onStartNestedScroll() 方法
     */
    @Override
    public boolean startNestedScroll(int axes) {
        return false;
    }
    /**
     * 一般是在事件结束比如 ACTION_UP 或者 ACTION_CANCLE 中调用,告诉父布局滚动结束。一般也是直
     * 接代理给NestedScrollingChildHelper的同名方法即可
     */
    @Override
    public void stopNestedScroll() {

    }
    /**
     * 判断当前View是否有嵌套滑动的Parent。一般也是直接代理给NestedScrollingChildHelper的
     * 同名方法即可
     */
    @Override
    public boolean hasNestedScrollingParent() {
        return false;
    }
    
    /**
     * 在当前View消费滚动距离之后。通过调用该方法,把剩下的滚动距离传给父布局。如果当前没有发生
     * 嵌套滚动,或者不支持嵌套滚动,调用该方法也没啥用。
     * 内部一般也是直接代理给NestedScrollingChildHelper的同名方法即可
     * dxConsumed:被当前View消费了的水平方向滑动距离
     * dyConsumed:被当前View消费了的垂直方向滑动距离
     * dxUnconsumed:未被消费的水平滑动距离
     * dyUnconsumed:未被消费的垂直滑动距离
     * offsetInWindow:输出可选参数。如果不是null,该方法完成返回时,
     * 会将该视图从该操作之前到该操作完成之后的本地视图坐标中的偏移量封装进该参数中,
     * offsetInWindow[0]水平方向,offsetInWindow[1]垂直方向
     * @return true:表示滚动事件分发成功,fasle: 分发失败
     */
    @Override
    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow) {
        return false;
    }
    
    /**
     * 在当前View消费滚动距离之前把滑动距离传给父布局。相当于把优先处理权交给Parent
     * 内部一般也是直接代理给NestedScrollingChildHelper的同名方法即可。
     * dx:当前水平方向滑动的距离
     * dy:当前垂直方向滑动的距离
     * consumed:输出参数,会将Parent消费掉的距离封装进该参数consumed[0]代表水平方向,
     * consumed[1]代表垂直方向
     * @return true:代表Parent消费了滚动距离
     */
    @Override
    public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow) {
        return false;
    }
   
    /**
     * 将惯性滑动的速度分发给Parent。内部一般也是直接代理给NestedScrollingChildHelper的同名
     * 方法即可
     * velocityX:表示水平滑动速度
     * velocityY:垂直滑动速度
     * consumed:true:表示当前View消费了滑动事件,否则传入false
     * @return true:表示Parent处理了滑动事件
     */
    @Override
    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
        return super.dispatchNestedFling(velocityX, velocityY, consumed);
    }
    
    /**
     * NestedScrollingParent
     * 在当前View自己处理惯性滑动前,先将滑动事件分发给Parent,一般来说如果想自己处理惯性的滑动
     * 事件,
     * 就不应该调用该方法给Parent处理。如果给了Parent并且返回true,那表示Parent已经处理了,
     * 自己就不应该再做处理。
     * 返回false,代表Parent没有处理,但是不代表Parent后面就不用处理了
     * @return true:表示Parent处理了滑动事件
     */
    @Override
    public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
        return super.dispatchNestedPreFling(velocityX, velocityY);
    }

}

一个简单的实现 NestScrollingChild3 接口的 LinearLayout 子类;

在撸码之前我们先来看嵌套滑动的流程图,以及它的详细原理:

如何应对Android面试官-> NestedScrollView 嵌套滑动机制详解,手写 NestedScrollView 核心实现

嵌套滑动由子 View 主动来开始消费事件,但是它在主动消费这个事件之前,它会主动询问它的父 View,看它要不要消费,这里可以详细分成四个阶段

  • 初始阶段(确认开启NestedScrolling,关联父View/子View)

    • 包含了五个方法,其中子 View 的startNestedScrollingEnabled、startNestedScroll 的两个方法,父 View 的 getNestedScrollAxes、onNestedScroll、onNestedScrollAccepted 三个方法
    • 初始阶段主要由我们的子 View 开始主动进行调用,它主要做了两件事情,第一件事情是:告诉父 View 要进行嵌套滑动了,在这个过程中有一件非常重要的操作就是它要找到这个子 View 对应的父 View,这个意思就是说:子 View 要先找有没有可以滑动的父 View,如果找到了,它会把这个父 View 记录下来;
  • 预滚动阶段(子 View 处理滚动事件)

    • 如果有可以滑动的父 View,子 View 首先不会自己消费这个事件,而是先把这个事件通过 dispatchNestedPreScroll 方法分发给父 View,看父 View 会不会消费这个事件,父 View 通过 onNestedPreScroll 进行消费,如果父 View 消费完之后,子 View 还有可以滑动的空间,子 View 继续消费;
  • 滚动阶段

    • 子 View 如果还有可以消费的事件,通过 dispatchNestedScroll 消费完之后,如果还有剩余会调用 onNestedScroll 再去问询下父 View 是不是要继续进行消费,如果父 View 没有进行消费,子 View 再去把它消费掉;
  • 结束阶段(处理惯性滑动、然后结束)

    • 主要针对的是手指抬起后的惯性滑动,惯性滑动也是会通过 dispatchNestedPreFling 优先询问父 View 是不是要消费这个惯性滑动事件,如果父 View 通过 onNestedFling 消费了这个滑动事件之后,子 View 会判断自己还能不能消费,消费的话通过 dispatchNestedFling 进行消费,消费之后,在问询下父 View 要不要消费,如果父 View 子 View 都没有消费的事件了,就停止滑动;

关于父 View 中的这个 onStartNestedScroll 方法中的参数 child 和 target,我们通过一张图更直观的来看下 如何应对Android面试官-> NestedScrollView 嵌套滑动机制详解,手写 NestedScrollView 核心实现

核心实现

我们先上效果给大家看一下:

如何应对Android面试官-> NestedScrollView 嵌套滑动机制详解,手写 NestedScrollView 核心实现

布局就是最外层 NestedScrollingParentLayout 嵌套了 ImageView、TextView、NestedScrollingChildLayout,然后 NestedScrollingChildLayout 包裹了一个内容很长的 TextView 这里面其实大部分的接口都不需要我们实现,NestedScrollingChildHelper、NestedScrollingParentHelper 来转嫁一下即可,我们需要处理的主要就是 onTouchEvent 方法,子 View 的事件处理是从 ACTION_DOWN 开始的,根据前面的讲解,我们需要在 DOWN 的时候开始调用 startNestScroll

case MotionEvent.ACTION_DOWN: {
    mLastTouchY = (int)(event.getRawY() + .5f);
    int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
    startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
    break;
}

这个方法最终调用的是 helper.startNestScroll 方法,我们进入这个 helper 的方法看下都发生了什么

public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
    // 先判断是不是已经有滑动的父View了,如果有直接返回
    if (hasNestedScrollingParent(type)) {
        return true;
    }
    // 判断是否支持嵌套滑动
    if (isNestedScrollingEnabled()) {
        // 获取当前 View 的父 View
        ViewParent p = mView.getParent();
        View child = mView;
        // 这里开启了一个循环
        while (p != null) {
            // 找到可以消耗滑动的父 View
            if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
                // 为 true 说明找到了
                setNestedScrollingParentForType(type, p);
                // 
                ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
                return true;
            }
            if (p instanceof View) {
                child = (View) p;
            }
            p = p.getParent();
        }
    }
    return false;
}

我们接下来看下 ViewCompat.onStartNestedSctoll 方法发生了什么

public static boolean onStartNestedScroll(ViewParent parent, View child, View target,
        int nestedScrollAxes, int type) {
    // 判断 parent 是不是实现了这个 NestedScrollingParent2 接口
    if (parent instanceof NestedScrollingParent2) {
        // 回调父 View 的 onStartNestedScroll 方法
        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 parent.onStartNestedScroll(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;
}

当 ViewCompat.onStartNestedScroll 返回 true 的时候,说明找到了可以消费滑动事件的父 View,然后会调用 setNestedScrollingParentForType 将这个支持嵌套滑动的父 View 存起来

private void setNestedScrollingParentForType(@NestedScrollType int type, ViewParent p) {
    switch (type) {
        case TYPE_TOUCH:
            // 将这个支持嵌套滑动的父 View 存起来
            mNestedScrollingParentTouch = p;
            break;
        case TYPE_NON_TOUCH:
            mNestedScrollingParentNonTouch = p;
            break;
    }
}

在这个 View 树中会向上遍历,找到可以支持嵌套滑动的父 View,找到之后再调用 onNestedScrollAccepted 方法

public static void onNestedScrollAccepted(ViewParent parent, View child, View target,
        int nestedScrollAxes, int type) {
    if (parent instanceof NestedScrollingParent2) {
        // First try the NestedScrollingParent2 API
        ((NestedScrollingParent2) parent).onNestedScrollAccepted(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 {
                parent.onNestedScrollAccepted(child, target, nestedScrollAxes);
            } catch (AbstractMethodError e) {
                Log.e(TAG, "ViewParent " + parent + " does not implement interface "
                        + "method onNestedScrollAccepted", e);
            }
        } else if (parent instanceof NestedScrollingParent) {
            ((NestedScrollingParent) parent).onNestedScrollAccepted(child, target,
                    nestedScrollAxes);
        }
    }
}

判断条件都是一样的,最终回调到父 View 的对应的方法,这个 parent.onNestedScrollAccepted 方法主要做的就是把滑动的方向是 x 轴还是 y 轴给存起来;

DOWN 事件中,我们开启了嵌套滑动,就分析完了,接下来我们看下 MOVE 的核心实现

int[] consumed = new int[2];

case MotionEvent.ACTION_MOVE:{
    int x = (int)(event.getRawX() + .5f);
    int y = (int)(event.getRawY() + .5f);
    // 获取滑动的距离 x 方向
    int dx = mLastTouchX - x;
    // 获取滑动的距离 y 方向
    int dy = mLastTouchY - y;
    mLastTouchX = x;
    mLastTouchY = y;
    // 将滑动的距离 优先传递给父 View,同时传递一个数组进去用来记录父 View 消费的距离(本次只考虑 Y 方向) [0]对应x轴  [1]对应y轴
    if (dispatchNestedPreScroll(dx, dy, consumed, null)) {
        Log.i(TAG, "dy: " + dy + ", cosumed: " + consumed[1]);
        // 减去父 View 消耗的距离,如果还有剩余,子 View 消耗
        dy -= consumed[1];
        if(dy == 0){
            Log.i(TAG, "dy: " + dy);
            return true;
        }
    } else {
        Log.i(TAG, "scrollBy: " + dy);
        // 父 View 不能滑动了,子 View 自己消耗掉
        scrollBy(0,dy);
    }
    break;
}

我们来看下这个 dispatchNestedPreScroll 的具体实现,最终也是调度到 helper.dispatchNestedPreScroll 方法

public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
        @Nullable int[] offsetInWindow, @NestedScrollType int type) {
    // 先判断是不是支持嵌套滑动    
    if (isNestedScrollingEnabled()) {
        // 拿到缓存的可以支持嵌套滑动的父 View
        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;
            // 分发给父 View,让父 View 先进行滑动
            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;
}

这里会调用 ViewParentCompat.onNestedPreScroll 方法让父 View 先进行滑动,我们进入这个方法看一下:

public static void onNestedPreScroll(ViewParent parent, View target, int dx, int dy,
        int[] consumed, int type) {
    if (parent instanceof NestedScrollingParent2) {
        // First try the NestedScrollingParent2 API
        ((NestedScrollingParent2) parent).onNestedPreScroll(target, dx, dy, consumed, 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 {
                parent.onNestedPreScroll(target, dx, dy, consumed);
            } catch (AbstractMethodError e) {
                Log.e(TAG, "ViewParent " + parent + " does not implement interface "
                        + "method onNestedPreScroll", e);
            }
        } else if (parent instanceof NestedScrollingParent) {
            ((NestedScrollingParent) parent).onNestedPreScroll(target, dx, dy, consumed);
        }
    }
}

经过一系列的判断,最终调用 parent.onNestedPreScroll 方法,也就是调用到了我们自己定义的 NestedScrollingParentLayout 的 onNestPreScroll 方法,而我们自定义的这个父 View 中就要滑动指定的距离

// 这个方法是需要业务实现的核心地方,父 View 根据自身的情况来判断是不是要消费掉这段 dy 距离
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed) {
    boolean show = showImg(dy);
    boolean hide = hideImg(dy);
    if(show || hide) {
        consumed[1] = dy; // 全部消费
        // 滑动指定的距离
        scrollBy(0, dy);
        Log.i(TAG,"Parent滑动:"+dy);
    }
    Log.i(TAG, "onNestedPreScroll--getScrollY():" + getScrollY() + ",dx:" + dx + ",dy:" + dy + ",consumed:" + consumed[1]);
}

当父 View 消耗完指定的距离之后,子 View 要减去父 View 消耗的距离,也就是 dy -= consumed[1] 的距离;

滑动结束,手指抬起或者 Cancel 的时候,调用 stopNestedScroll()

case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
    stopNestedScroll();
    break;

stop 中就是各个状态的重置逻辑;

整体的一个事件消费图如下:

如何应对Android面试官-> NestedScrollView 嵌套滑动机制详解,手写 NestedScrollView 核心实现

整个完整的 NestedScrollView 的核心实现就完成了;

简历润色

简历上可写:深度理解嵌套滑动机制,可实现复杂的嵌套滑动效果,例如 WebView 与 NA 的嵌套滑动

下一站预告

带你深度理解协调者布局的 beavhior;

欢迎三连

来都来了,点个关注点个赞吧,你的支持是我最大的动力~~~