Android 内嵌滑动之子控件支持内嵌滑动

简介

本篇文档作为滑动控件的延续,如果对滑动控件还有疑问的可以参考连接 https://blog.csdn.net/cxmfzu/article/details/114207345。在VIew的事件分发中,最为难处理的就是滑动事件冲突,使用传统的事件分发处理滑动冲突,可以参考书籍 《Android开发艺术探索》。本文主要分析出现内嵌滑动时,且控件作为子控件是如何处理,实现NestedScrollingxxxx相关接口。本文的相关demo参见 https://github.com/CodeKitBox/Control.git
本文档着重回答一下问题:

  1. Android 源码中View 本身已实现了 NestedScrollingChild相关接口,View 是如何实现 NestedScrollingChild 接口的
  2. 对比View实现 NestedScrollingChild接口和自行实现 NestedScrollingChild接口,孰优孰劣
  3. NestedScrollingChild , NestedScrollingChildHelper,NestedScrollingParent,NestedScrollingParentHelper 四个接口是如何配合解决滑动冲突的
  4. 区别 NestedScrollingChildNestedScrollingChild1NestedScrollingChild2 三个接口的区别

NestedScrollingChild 简介

在正文开始之前,先将 NestedScrollingChild 的相关接口源码贴出来,方便正文的阐述。

	public interface NestedScrollingChild{
		void setNestedScrollingEnabled(boolean enabled);
		boolean isNestedScrollingEnabled();
		boolean startNestedScroll(@ScrollAxis int axes);
		void stopNestedScroll();
		boolean hasNestedScrollingParent();
		boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);
        boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow);
        boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
        boolean dispatchNestedPreFling(float velocityX, float velocityY);        
		
	}

View 实现的内嵌滑动

当我们需要View 实现内嵌滑动时,实现接口 NestedScrollingChild,你会发现我们自定义的View不需要实现任何接口,从中可以View已经实现了 NestedScrollingChild的所有接口。那么View是如何实现的呢?

使用系统提供的控件测试内嵌滑动

我们使用以下布局,将ScrollView 中嵌套一个ListView,实现一个简单的滑动嵌套布局,Xml 如下

	<?xml version="1.0" encoding="utf-8"?>
<ScrollView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:id="@+id/svParent">
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@android:color/holo_green_dark"
            android:id="@+id/llFirst"
            android:orientation="vertical"/>
        <ListView
            android:layout_width="match_parent"
            android:layout_height="400dp"
            android:id="@+id/listView"/>
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@android:color/holo_red_light"
            android:id="@+id/llSecond"
            android:orientation="vertical"/>
    </LinearLayout>
</ScrollView>

ListView 的高度是固定的方便看到嵌套的效果,这时候只需要将ListView 的内嵌属性设置为 true,则可以实现一个嵌套滑动,手指在ListView的区域内,优先滑动ListView。通过分析源码,实现自定义View支持内嵌滑动

自定义View 支持内嵌滑动

内嵌滑动主要是事件分发,因此处理滑动相关的流程在 onTouch中。

  1. 当手指点击在自定义View的区域内,通知父控件,自定义View支持的内嵌滑动方向,源码如下
	override fun onTouchEvent(event: MotionEvent): Boolean{
		...
		when(vtev.actionMasked){
            MotionEvent.ACTION_DOWN->{
                // 点击需要停止惯性滑动
                if(mScroller.isFinished){
                    mScroller.abortAnimation()
                    recycleVelocityTracker()
                }
                // 设置为非拖动状态
                mIsBeingDragged = false
                // 记录触摸点坐标
                saveLocation(event.x.toInt(),event.y.toInt())
                // 通知父控件,子控件支持的内嵌滑动方向
                startNestedScroll(View.SCROLL_AXIS_VERTICAL)
            }
            }
	}

View 源码中 startNestedScroll(int axes)的源码如下

    public boolean startNestedScroll(int axes) {
        if (hasNestedScrollingParent()) {
        	// 已经找到支持内嵌滑动的父控件
            // Already in progress
            return true;
        }
        // 判断当前的控件是否支持内嵌滑动
        if (isNestedScrollingEnabled()) {
        	// 遍历父控件,
            ViewParent p = getParent();
            View child = this;
            while (p != null) {
                try {
                   // 调用父控件的 onStartNestedScroll 方法
                   // onStartNestedScroll 在ViewGrop中返回false
                   // 当是在ScrollView 中,根据条件 (nestedScrollAxes & SCROLL_AXIS_VERTICAL) != 0; 返回的是true
                   // 因此在ScrollView 作为父控件,默认是支持内嵌滑动的
                    if (p.onStartNestedScroll(child, this, axes)) {
                    	// 找到支持内嵌滑动的父控件之后,保存,然后返回
                        mNestedScrollingParent = p;
                        p.onNestedScrollAccepted(child, this, axes);
                        return true;
                    }
                } catch (AbstractMethodError e) {
                    Log.e(VIEW_LOG_TAG, "ViewParent " + p + " does not implement interface " +
                            "method onStartNestedScroll", e);
                    // Allow the search upward to continue
                }
                if (p instanceof View) {
                    child = (View) p;
                }
                p = p.getParent();
            }
        }
        return false;
    }

在View 中的 startNestedScroll 中是查找到离控件本身最近的支持内嵌滑动的父控件。

  1. 当子控件开始滑动的处理流程
    当子控件开始滑动的处理流程是:首先通知父控件子控件的滑动距离,由父控件决定是否消费子控件的滑动距离,然后子控件根据父控件的消费情况,在进行控件本身的滑动,最后通知父控件是否需要消费子控件剩余的滑动距离
    实现源码如下
override fun onTouchEvent(event: MotionEvent): Boolean{
	MotionEvent.ACTION_MOVE->{
				var dy = differY(event.y.toInt())
                println("dy == $dy ; touchSlop = $touchSlop ; mIsBeingDragged=$mIsBeingDragged ")
                if(isNestedScrollingEnabled){
                    // 判断是否父控件优先滑动
                    /**
                     * 接口  dispatchNestedPreScroll
                     * 参数 dx dy 子控件在x 轴,y 轴的偏移
                     * 参数 consumed 是数组,是父控件消耗了子控件偏移的数据
                     * 参数 mScrollOffset 也是数组,是父控件滑动了,这时候子控件也要应得滑动
                     */
                    if (dispatchNestedPreScroll(0, dy.toInt(), mScrollConsumed, mScrollOffset)) {
                        println("父控件优先滑动")
                        // 调整事件坐标
                        dy -= mScrollConsumed[1]
                        vtev.offsetLocation(0f, mScrollOffset[1].toFloat())
                        mNestedYOffset += mScrollOffset[1]
                    }else{
                        println("子控件优先滑动")
                        // 子控件优先 不用调整
                    }
                }
                // 通过系统分发到这里,判断是否可以滑动
                if(abs(dy) > touchSlop && !mIsBeingDragged){
                    mIsBeingDragged = true
                    parent?.requestDisallowInterceptTouchEvent(true)
                }
                if (mIsBeingDragged){
                    // 内嵌滑动下,子控件滑动
                    // 调用View的接口判断滑动
                    // 参数  deltaX ,deltaY 指的是滑动的偏移量
                    // 参数 scrollX scrollY 指的是已经滑动的距离
                    // 参数 scrollRangeX scrollRangeY 指的是滑动的范围
                    // 参见 maxOverScrollX  maxOverScrollY 指的是越界滑动的距离
                    //  isTouchEvent 系统中此参数没有使用
                    // 返回值 true 标识达到了最大越界,在惯性滑动中使用
                    // 调用onOverScrolled 实现真正的滑动
                    //println("dy = $dy ;scrollY = $scrollY ")
                    // 记录上一次滑动得距离
                    val oldY = scrollY
                    // 子控件滑动
                    overScrollBy(0,dy,0,scrollY,0,scrollRange,0,0,true)
                    // 子控件滑动完成,需要判断dy 被完全消费,如果没有完全消费,通知父控件进行一定得滑动
                    val scrolledDeltaY: Int = scrollY - oldY
                    val unconsumedY: Int = dy - scrolledDeltaY
                    if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset)) {
                        dy -= mScrollOffset[1]
                        vtev.offsetLocation(0f, mScrollOffset[1].toFloat())
                        mNestedYOffset += mScrollOffset[1]
                    }
                    // 记录触摸点坐标
                    saveLocation(event.x.toInt(),event.y.toInt())
                }
	}
}
  • 函数 boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,@Nullable int[] offsetInWindow) 是通知在子控件滑动之前通知父控件子控件的滑动距离,由父控件决定是否需要在消费子控件的滑动距离,即父控件优先滑动。
    源码如下
    public boolean dispatchNestedPreScroll(int dx, int dy,
            @Nullable @Size(2) int[] consumed, @Nullable @Size(2) int[] offsetInWindow) {
        if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
            if (dx != 0 || dy != 0) {
                int startX = 0;
                int startY = 0;
                if (offsetInWindow != null) {
                    getLocationInWindow(offsetInWindow);
                    startX = offsetInWindow[0];
                    startY = offsetInWindow[1];
                }

                if (consumed == null) {
                    if (mTempNestedScrollConsumed == null) {
                        mTempNestedScrollConsumed = new int[2];
                    }
                    consumed = mTempNestedScrollConsumed;
                }
                consumed[0] = 0;
                consumed[1] = 0;
                // 通知父控件,父控件根据传入的dx dy 来进行滑动,如果由滑动,将滑动距离保存在数组 consumed
                mNestedScrollingParent.onNestedPreScroll(this, dx, dy, consumed);
				// 调整View 的位置
                if (offsetInWindow != null) {
                    getLocationInWindow(offsetInWindow);
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                // 如果父控件由消费滑动距离,则返回true,否则返回false
                return consumed[0] != 0 || consumed[1] != 0;
            } else if (offsetInWindow != null) {
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }
  • 函数 overScrollBy 是控件本身的滑动,最终调用scrollTo接口
  • 函数 boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow) 在子控件本身完成滑动之后通知父控件剩余的滑动距离,一般在子控件滑动到底部的时候,父控件接着滑动。
    源码中
    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable @Size(2) int[] offsetInWindow) {
        if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
            if (dxConsumed != 0 || dyConsumed != 0 || dxUnconsumed != 0 || dyUnconsumed != 0) {
                int startX = 0;
                int startY = 0;
                if (offsetInWindow != null) {
                    getLocationInWindow(offsetInWindow);
                    startX = offsetInWindow[0];
                    startY = offsetInWindow[1];
                }
				// 通知父控件,父控件根据传入的dxUnconsumed, dyUnconsumed 来进行滑动,
				// dxConsumed,dyConsumed 是子控件已经消费的滑动距离
                mNestedScrollingParent.onNestedScroll(this, dxConsumed, dyConsumed,
                        dxUnconsumed, dyUnconsumed);

                if (offsetInWindow != null) {
                    getLocationInWindow(offsetInWindow);
                    offsetInWindow[0] -= startX;
                    offsetInWindow[1] -= startY;
                }
                return true;
            } else if (offsetInWindow != null) {
                // No motion, no dispatch. Keep offsetInWindow up to date.
                offsetInWindow[0] = 0;
                offsetInWindow[1] = 0;
            }
        }
        return false;
    }
  1. 子控件开始惯性滑动
    当手指离开界面时,为了更好的用户体验一般需要继续滑动一段距离,即进行惯性滑动。执行惯性滑动的源码如下
override fun onTouchEvent(event: MotionEvent): Boolean {
	when(vtev.actionMasked){
		MotionEvent.ACTION_UP,MotionEvent.ACTION_CANCEL->{
                // 开始惯性滑动
                if(mIsBeingDragged){
                    mVelocityTracker?.let {
                        it.computeCurrentVelocity(1000)
                        // 判断是否支持惯性滑动,参考系统源码
                        println("惯性滑动 ${it.xVelocity};${it.xVelocity}; minFling =$minFling")
                        // 惯性滑动的速度大于系统最小的惯性滑动速度,执行惯性滑动
                        if(abs(it.yVelocity) > minFling){
                            val velocityY = -(it.yVelocity.toInt())
                            // 判断是否可以滑动
                            val canFling = (scrollY > 0 || velocityY > 0) &&
                                    (scrollY < getScrollRange() || velocityY < 0)
                            println("canFling == $canFling")
                            // dispatchNestedPreFling 返回true 表明父控件完全消费子控件的惯性滑动
                            if (!dispatchNestedPreFling(0f, velocityY.toFloat())) {
                                println("子控件消费控件惯性滑动 ")
                                // 父控件滑动,这里子控件和父控件(如果支持)本质上是做了一的滑动,没有办法父控件滑动一部分,子控件滑动一部分
                                // 这部分的优化在 NestedScrollingChild2 NestedScrollingParent2 中实现了,需要参考RecyclerView的源码
                                val ret = dispatchNestedFling(0f, velocityY.toFloat(), canFling)
                                println("dispatchNestedFling ret == $ret")
                                if (canFling) {
                                    /**
                                     * 参数 startX, startY 起始的滑动距离
                                     * 参数 velocityX velocityY 滑动的速度
                                     * 参数  minX minY  最小的滑动距离
                                     * 参数  maxX maxY 最大的滑动就离
                                     */
                                    mScroller.fling(0, scrollY, 0, velocityY,
                                            0, 0,
                                            0, getScrollRange())
                                    // 通知界面刷新
                                    invalidate()
                                }
                            }else{
                                println("父控件消费惯性滑动")
                            }
                        }
                    }
                }
                // 设置为非拖动状态
                mIsBeingDragged = false
                // 记录触摸点坐标
                saveLocation(event.x.toInt(),event.y.toInt())
                recycleVelocityTracker()
            }
	}
}
  • 函数 dispatchNestedPreFling,通知父控件开始滑动,当这个接口返回true的时候,表明父控件完全消费惯性滑动,子控件不进行滑动,当返回false,父控件和子控件一起惯性滑动一样的距离
  • 函数 dispatchNestedFling 和子控件一起进行一个惯性滑动
  • 由于惯性滑动不能实现子控件滑动一部分,父控件滑动一部分,因此有了其他接口

重新内嵌滑动相关接口和自定义View实现 NestedScrollingChild区别

  1. 自定义View 默认已经实现了 NestedScrollingChild的接口, 且有默认的行为,因此较为简单,可以针对不满足条件的进行重写就可以。
  2. View 实现 NestedScrollingChild的默认行为和NestedScrollingChildHelper的默认行为是一致的。
  3. 通过实现NestedScrollingChild接口的好处是提供了一个清晰的实现行为,一般通过代理模式来做,会取得较好的效果。
  4. 如果有实现 NestedScrollingChild1 或者 NestedScrollingChild2,最好通过 NestedScrollingChildHelper来实现。

区别 NestedScrollingChildNestedScrollingChild1NestedScrollingChild2 三个接口的区别

NestedScrollingChild1

NestedScrollingChild1 的源码如下
public interface NestedScrollingChild2 extends NestedScrollingChild{
	boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type);
	void stopNestedScroll(@NestedScrollType int type);
	boolean hasNestedScrollingParent(@NestedScrollType int type);
	boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow,
            @NestedScrollType int type);
    boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
            @Nullable int[] offsetInWindow, @NestedScrollType int type);        
}

通过和NestedScrollingChild对比发现接口的名非常相似,只是多了一个参数 type,参数type是用来区分是触摸滑动还是非触摸滑动的。具体用法参见RecyclerView

  1. 当 RecyclerView 通过代码实现平滑滑动时,即调用接口 smoothScrollBy
void smoothScrollBy(@Px int dx, @Px int dy, @Nullable Interpolator interpolator,
            int duration, boolean withNestedScrolling){
            ...
            startNestedScroll(nestedScrollAxis, TYPE_NON_TOUCH);
}
  1. 当RecyclerView 执行惯性滑动时
public boolean fling(int velocityX, int velocityY) {
	...
	startNestedScroll(nestedScrollAxis, TYPE_NON_TOUCH);
}
  1. 当相应Down事件时,onInterceptTouchEvent,onTouch
case MotionEvent.ACTION_DOWN:
	...
	startNestedScroll(nestedScrollAxis, TYPE_TOUCH);

dispatchNestedPreScrolldispatchNestedScroll调用的源码在 onTouchEvent,和 ViewFlinger中,从中可以看出,子控件将是否为触摸引起的滑动通知给了父控件,父控件可以根据是否是手指滑动进行一些特殊处理

NestedScrollingChild3

NestedScrollingChild3 的源码如下,实现子空间滑动结束后也通知父控件是怎么样的滑动类型。

public interface NestedScrollingChild3 extends NestedScrollingChild2 {
 void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed,
            @Nullable int[] offsetInWindow, @ViewCompat.NestedScrollType int type,
            @NonNull int[] consumed);
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值