美文网首页新收藏
Android - 手把手教你写出一个支持嵌套滑动的View

Android - 手把手教你写出一个支持嵌套滑动的View

作者: 琼珶和予 | 来源:发表于2021-09-21 21:00 被阅读0次

      嵌套滑动机制,想必大家都不陌生,当我们在使用CoordinatorLayout + AppBarLayout框架设计界面,嵌套滑动就显得尤为地重要。CoordinatorLayout成为协调布局,目的是协调多个布局的联动,联动就会涉及多个View在滑动时候相互的响应,简单来说,就是一个View在滑动的时候,另一个View可能需要对应的滑动。那么这种联动是怎么实现的呢?换句话说,View是怎么知道其他View在滑动呢?有人可能说,是Behavior在进行协调。Behavior毕竟是设计CoordinatorLayout实现出来的东西,不能用于任何View,也就是说,Behavior众多方法的回调还得依赖View的某些底层机制来实现,那么这个底层机制是什么呢?那就是嵌套滑动机制。
      回过头来看一下标题,本文目的是介绍怎么自定义一个可以产生嵌套滑动的View。那么既然官方提供了众多可以支持嵌套滑动的View,为啥我们还要自己定义呢?自然是官方的不能满足我们的要求,这也是从我工作中得来教训。最近,我在负责界面的改版,新界面的交互逼得我不得不使用CoordinatorLayout + AppBarLayout进行开发。当我在开发某一个模块时,发现需要使用一个支持嵌套滑动的View。最初的想法是使用NestedScrollView套一下,但是 NestedScrollView会把Child给摊平,性能问题自然就会出现了。所以为了追求极致,就自己定义一个可以支持嵌套滑动的View。
      在阅读本文之前,需要准备知识:

    1. CoordinatorLayout 的实现原理。
    2. 嵌套滑动实现的原理。

      本文不会深入分析上面两部分知识,所以我默认大家都了解,有兴趣的同学可以参考如下文章:

    1. Android 源码分析 - 嵌套滑动机制的实现原理
    2. CoordinatorLayout 学习(一) - CoordinatorLayout的基本使用
    3. CoordinatorLayout 学习(二) - RecyclerView和AppBarLayout的联动分析
    4. 从一次真实经历中说说使用嵌套滑动过程中常见的坑

    1. 说说嵌套滑动

      嵌套滑动机制在API 21 之后就跟View绑定了,Google爸爸在官方库里面提供了支持嵌套滑动的View,而这部分View可以分为两类:

    1. 产生嵌套滑动事件的View:这类View前提上是自己本身可以滑动,如果自己都不能滑动,那嵌套滑动什么的都是白扯。比如说,RecyclerView,NestedScrollView之类,主要是实现NestedScrollingChild、NestedScrollingChild2、NestedScrollingChild3这三个接口的View。(至于这三个接口有啥区别,后文我会分析)
    2. 处理嵌套滑动事件的View:这类View都会实现NestedScrollingParent、NestedScrollingParent2、NestedScrollingParent3这三个接口中的任意一个。比如说比如CoordinatorLayout、NestedScrollView、SwipeRefreshLayout之类。

      通常来说,在嵌套滑动机制中,这两类的View都是成对出现的,一般是产生嵌套滑动事件的View作为处理嵌套滑动事件的View的子View,从另一个方面来说,处理嵌套滑动事件的View一般都是ViewGroup,而产生嵌套滑动事件的View可能是任意View的子类。同时,这两类View如果只出现一个,嵌套滑动也会失效。
      从上面举的例子中,我们可以发现,NestedScrollView同时实现了NestedScrollingChild3、NestedScrollingParent3这两个接口,那么就表示这个View同时可以产生嵌套滑动和处理嵌套滑动。这也是为什么现在有一个NestedScrollView 套RecyclerView的实现方案。而我本人不推荐此方案,因为NestedScrollView会摊平内部所有的Child,这就意味着RecyclerView会众多特性就失效。这也是本文写作的原因,本文的目的是给大家介绍怎样自定义一个产生嵌套滑动事件View
      在正式介绍之前,我先给大家分析一下NestedScrollingChildX、NestedScrollingParentX之间的区别。
      NestedScrollingChildX之间的区别,直接来看他们的类图关系:


      我来分析这图中的重点:
    1. NestedScrollingChild:这个接口主要定义了嵌套滑动需要的几个关键方法,包括preScroll、scroll、preFling、fling等方法。
    2. NestedScrollingChild2:这个接口是NestedScrollingChild的子接口,在原有的方法基础上增加type参数,用来判断TOUCH和非TOUCH的情况,用来区分手指是否还在屏幕上
    3. NestedScrollingChild3:这个接口是NestedScrollingChild2的子接口,主要是重载了dispatchNestedScroll,在原有的接触上增加了一个consumed 参数。

      我相信大家能区分出来1和2之间的区别,但是3就增加了一个consumed 参数,这是为何呢?很明显,这个是用来标记父View消费了多少距离,这个有啥作用呢?主要是在调用了dispatchNestedScroll之后,如果还有未消费的距离,子View就可以停掉滑动。这样能解决很多奇怪的问题,比如说,我们在Fling RecyclerView到边界时,触发了加载更多,理论上应当停掉Fling,但事实上当使用RecyclerView的嵌套滑动时,加载更多完成时会继续Fling,这就是Fling没有停掉的原因。不过,这个问题的解决方案需要NestedScrollingChild3 配合NestedScrollingParent3才会有效。
      我们继续来看NestedScrollingParentX之间的类图关系:


      他们之间的区别跟NestedScrollingChild之间的类似,这里就不赘述了。不过大家需要注意的是,尽量都实现NestedScrollingChild3和NestedScrollingParent3,因为这两个接口方法是最全的,同时最好是将全部的方法都是实现一遍,因为在某些手机可能会抛出AbstractMethodError异常,特别是在21以下的手机上。

    2. 准备工作

      前面对嵌套滑动介绍的差不多了,现在我来介绍怎么定义一个嵌套滑动的View。步骤主要分为4步:

    1. 指定的View实现了NestedScrollingChild3接口,同时实现相关方法,同时使用NestedScrollingChildHelper来分发嵌套滑动。并且调用setNestedScrollingEnabled,设置为true,表示该View能够产生嵌套滑动的事件,这一点非常的重要。
    2. 在第一步的基础上,先支持单指的嵌套滑动。
    3. 在第二步的基础上,实现多指的嵌套滑动。
    4. 在第三步的基础上,实现Fling的嵌套滑动。

      注意,本文使用的是CoordinatorLayout 来处理嵌套滑动
      我们先来看看具体的效果:

      图中的CustomNestedViewGroup就是本文要实现的View。同时介于第一步比较简单,本文就不介绍具体的操作。
      本文源码地址:NestedScrollActivity,有兴趣的同学可以参考一下。本文的实现代码主要参考于NestedScrollView

    3. 支持单指滑动

      单指滑动非常的简单,无非就是在ACTION_MOVE的时机上来触发滑动而已。但是这种事情看上去简单,实际上在开发过程中有很多的细节得需要我们注意,正所谓书上得来终觉浅,绝知此事要躬行。不亲身去尝试着写,理论永远是理论。
      好了,废话扯得有点多,我们正式开始介绍吧。需要一个View支持单指滑动,基本框架就是要重写onInterceptTouchEventonTouchEvent这两个方法(当然你是继承View类,便不用重写onInterceptTouchEvent方法)。所以,我们分别来看一下这个两个方法的实现。

    (1). onInterceptTouchEvent方法

      重写onInterceptTouchEvent方法的目的是在合适的时机拦截事件,表示我们的View需要消费后续的事件。我们直接来看onInterceptTouchEvent方法的实现:

        override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
            val action = ev.actionMasked
            if (action == MotionEvent.ACTION_MOVE && mIsBeingDragged) {
                return true
            }
    
            when (action) {
                MotionEvent.ACTION_DOWN -> {
                    mLastMotionY = ev.y.toInt()
                    // 开始建立嵌套滑动传递链
                    startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH)
    
                }
                MotionEvent.ACTION_MOVE -> {
                    val y = ev.y.toInt()
                    val deltaY = abs(y - mLastMotionY)
                    if (deltaY > mTouchSlop) {
                        mIsBeingDragged = true
                        mNestedYOffset = 0
                        mLastMotionY = y
                        parent?.let {
                            parent.requestDisallowInterceptTouchEvent(true)
                        }
                    }
                }
                MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
                    mIsBeingDragged = false
                    // 切断嵌套滑动的传递链
                    stopNestedScroll(ViewCompat.TYPE_TOUCH)
                }
            }
            return mIsBeingDragged
        }
    

      onInterceptTouchEvent的实现非常简单,我在这里重点的分析几点:

    1. 我们在ACTION_DOWN调用了startNestedScroll方法,表示建立起嵌套滑动的传递链,需要特别注意的是,这里的Type传递的是ViewCompat.TYPE_TOUCH,主要是为了区分后续的Fling滑动;其次,我们在ACTION_CANCELACTION_UP调用了stopNestedScroll,表示切断嵌套滑动的传递链。
    2. ACTION_MOVE里面尝试设置mIsBeingDragged,从而拦截事件进行消费。

      从onInterceptTouchEvent方法,我们可以看出一个特点,这个方法不会消费move事件,而只是在这个时机设置某些状态值,比如说:

    mIsBeingDragged :用来表示当前是否需要消费时机,我们可以看到,只要滑动距离超过mTouchSlop ,就要进行消费。
    mLastMotionY:用来记录上一次event的Y坐标,主要用于计算当前event相比于上一次event,产生多少的滑动距离。
    mNestedYOffset:用以记录产生的滑动距离,被父View消费了多少。这个变量怎么来理解呢?在我们这个案例中,假设View产生了100px的滑动距离,如果View和AppBarLayout整体上移了50px的距离,那么mNestedYOffset就为50。这个变量非常的重要,后续计算mLastMotionY,以及UP的时候Fling的初速度,都需要它。

      既然onInterceptTouchEvent方法不消费事件,那么在哪里消费事件呢?自然是onTouchEvent方法。

    (2). onTouchEvent方法

      当View内部没有child消费的事件,,或者被onInterceptTouchEvent拦截的事件,都会传递到onTouchEvent方法里面。而onTouchEvent方法的作用自然是消费事件,要触发View内部内容进行滑动,就是在该方法里面实现。
      我们直接来看onTouchEvent方法的实现:

        override fun onTouchEvent(event: MotionEvent): Boolean {
            val action = event.actionMasked
            if (action == MotionEvent.ACTION_DOWN) {
                mNestedYOffset = 0
            }
            when (action) {
                MotionEvent.ACTION_DOWN -> {
                    mLastMotionY = event.y.toInt()
                    startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH)
                }
                MotionEvent.ACTION_MOVE -> {
                    val y = event.y.toInt()
                    var deltaY = mLastMotionY - y
                    if (!mIsBeingDragged && abs(deltaY) > mTouchSlop) {
                        parent?.let {
                            requestDisallowInterceptTouchEvent(true)
                        }
                        mIsBeingDragged = true
                        if (deltaY > 0) {
                            deltaY -= mTouchSlop
                        } else {
                            deltaY += mTouchSlop
                        }
                    }
                    if (mIsBeingDragged) {
                        // 1. 在内部内容滑动之前,先调用dispatchNestedPreScroll,让父View进行滑动。
                        if (dispatchNestedPreScroll(
                                0,
                                deltaY,
                                mScrollConsumed,
                                mScrollOffset,
                                ViewCompat.TYPE_TOUCH
                            )
                        ) {
                            // 更新剩下的滑动距离
                            deltaY -= mScrollConsumed[1]
                            // 更新父View滑动的距离
                            mNestedYOffset += mScrollOffset[1]
                        }
                        // 更新上一次event的Y坐标
                        mLastMotionY = y - mScrollOffset[1]
    
                        val oldScrollY = scrollY
                        val range = getScrollRange()
                        // 2. 触发内容的滑动
                        overScrollBy(0, deltaY, 0, oldScrollY, 0, range, 0, 0, true)
                        val scrollDeltaY = scrollY - oldScrollY
                        val unconsumedY = deltaY - scrollDeltaY
                        mScrollConsumed[1] = 0
                        // 3. 当内容滑动完成,如果滑动距离还未消费,那么就调用dispatchNestedScroll方法,询问父View是否还消费
                        dispatchNestedScroll(
                            0,
                            scrollDeltaY,
                            0,
                            unconsumedY,
                            mScrollOffset,
                            ViewCompat.TYPE_TOUCH,
                            mScrollConsumed
                        )
                        // 再次更新相关信息
                        mLastMotionY -= mScrollOffset[1]
                        mNestedYOffset += mScrollOffset[1]
                    }
                }
                MotionEvent.ACTION_UP -> {
                    endDrag()
                }
                MotionEvent.ACTION_CANCEL -> {
                    endDrag()
                }
            }
    
            return true
        }
    

      onTouchEvent方法代码比较长,但是重点都是在move里面,我们分开来看:

    1. down事件都是一些基本实现,比如说更新mLastMotionY,还有就是调用startNestedScroll方法。
    2. up和cancel都是调用了endDrag,这个方法里面只做两件事,重置mIsBeingDragged,同时还调用了stopNestedScroll

      而move事件的实现就比较复杂了,我将其分为三步:

    1. 根据mLastMotionY计算出来本次产生了多少滑动距离,然后就是调用dispatchNestedPreScroll方法。目的就是,在内部滑动之前,先询问父View是否要消费距离。其中mScrollConsumed里面记录的父View消费的距离,同时mScrollOffset表示我们的View在屏幕滑动的距离,主要是根据getLocationInWindow来计算的。父View滑动完成,自然就是就是更新某些状态值,比如说:deltaY 、mNestedYOffset 、mLastMotionY 。可能有人会有疑问,为啥还有更新mLastMotionY呢?因为我们的View在屏幕中更新了位置,所记录的上一次event的Y坐标自然也要更新,不然下一次event计算的滑动距离会有误差。
    2. 调用overScrollBy方法,用来滑动View内部的内容。这里,大家可能又有疑问了,为啥要调用overScrollBy,而不是调用scrollBy或者scrollYo呢?举一个例子,如果滑动距离还剩下100px,但是View其实只能滑动50px,此时不能直接滑动100px,所以这里需要裁剪滑动距离,如果直接调用scrollBy或者scrollYo,我们需要自己计算裁剪距离,但是overScrollBy方法内部会根据scrollRange来进行裁剪,所以第调用overScrollBy是为了我们自己不需要写裁剪的代码。
    3. 当View自己滑动完成,调用dispatchNestedScroll,询问父View是否需要消费剩下的距离。如果消费了,自然要更新mLastMotionYmNestedYOffset

      在嵌套滑动流程中,特别是move事件中需要触发嵌套滑动时,这个流程固定不变的,即:


      看上去还是比较简单的,但是有些前提大家必须知道,在这里,我再次强调一遍:

    1. 调用setNestedScrollingEnabled方法,设置为true。
    2. 在调用dispatchNestedPreScrolldispatchNestedScroll之前,必须先调用startNestedScroll,并且传递的Type必须是一致的。
    3. 滑动完成之后,需要调用stopNestedScroll方法来切断传递链。

      Type一共有两个,分别是:

    1. TYPE_TOUCH:表示手指在屏幕上产生的嵌套滑动事件。
    2. TYPE_NON_TOUCH:表示手指未在屏幕上产生的嵌套滑动事件,比如说Fling滑动。

      单指滑动的实现就介绍在这里,整体上来说还是比较简单的。完整代码大家可以在KotlinDemo找到,commit message 为【新增自定义NestedScrollViewGroup的Demo,并且完成CustomNestedScrollView的move事件处理】

    4. 支持多指滑动

      如果要支持多指滑动,首先要引入新的action含义,如下:

    1. ACTION_POINTER_DOWN:表示非第一个手指落在屏幕中。
    2. ACTION_POINTER_UP: 表示非最后一个手指离开屏幕。
    3. ACTION_UP:表示最后一个手指离开屏幕
    4. ACTION_MOVE:表示任意一个手指在滑动。
    5. ACTION_DOWN:表示第一个手指落在屏幕中。

      同时,从整体上来看,我们需要定义一个变量,表示最近一个落在屏幕中的手指;还有就是,我们需要在down和up时,实时的更新这个记录的最近手指。最后就是,在获取滑动坐标时,需要传入手指Id,不能像以前直接getY来获取。

    (1).使用手指Id

      前面已经提到了,此时获取坐标不能直接调用getY方法,我们来看一下怎么获取,这里只看onTouchEvent方法:

        override fun onTouchEvent(event: MotionEvent): Boolean {
            // ······
            when (action) {
                MotionEvent.ACTION_DOWN -> {
                    mActivePointerId = event.getPointerId(0)
                    mLastMotionY = event.y.toInt()
                    startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH)
                }
                MotionEvent.ACTION_MOVE -> {
                    val pointerIndex = event.findPointerIndex(mActivePointerId)
                    if (pointerIndex == -1) {
                        return true
                    }
    
                    val y = event.getY(pointerIndex).toInt()
                    var deltaY = mLastMotionY - y
                    //······
                }
                // ······
            }
    
            return true
        }
    

      我们发现在ACTION_DOWNACTION_MOVE中,都有一个mActivePointerId,用来表示最近活跃的手指Id,可以通过这个id,找到一个index,然后event可以通过index,获取对应的坐标。同时,我们还发现一个小细节,就是在ACTION_DOWN里面初始化了mActivePointerId了,这一点大家要注意。

    (2). 更新手指Id

      除去初始化手指Id和使用手指Id,还有必不可少的步骤就是:更新手指Id。更新时机体现在如下几个地方:

    1. ACTION_CANCEL、ACTION_UP
    2. ACTION_POINTER_DOWN
    3. ACTION_POINTER_UP

      我们直接看代码:

        override fun onTouchEvent(event: MotionEvent): Boolean {
            // ······
            when (action) {
                // ······
                MotionEvent.ACTION_UP -> {
                    mActivePointerId = INVALID_POINTER
                    // // ······
                }
                MotionEvent.ACTION_CANCEL -> {
                    mActivePointerId = INVALID_POINTER
                    endDrag()
                }
                MotionEvent.ACTION_POINTER_DOWN -> {
                    val newPointerIndex = event.actionIndex
                    mLastMotionY = event.getY(newPointerIndex).toInt()
                    mActivePointerId = event.getPointerId(newPointerIndex)
                }
                MotionEvent.ACTION_POINTER_UP -> {
                    onSecondaryPointerUp(event)
                }
            }
            // ······
            return true
        }
    

      从代码中来看,三个地方的区别如下:

    1. ACTION_CANCEL、ACTION_UP:重置mActivePointerId
    2. ACTION_POINTER_DOWN:更新mActivePointerId,因为这个时机表示一个手指落入屏幕,所以直接更新为当前手指。
    3. ACTION_POINTER_UP:调用onSecondaryPointerUp方法,尝试更新mActivePointerId。分为两种情况来看待:如果离开屏幕的手指Id不是mActivePointerId记录的,那么就直接忽略;如果是mActivePointerId记录的,就跟据pointerIndex来判断,将mActivePointerId更新到第一个手指,还是其他手指。细节大家可以看onSecondaryPointerUp方法实现。

      总的来说,多指滑动的实现比较简单,毕竟已经有单指滑动的基础。完整代码大家可以在KotlinDemo找到,commit message 为【支持多指滑动】

    5. 支持Fling滑动

      其实支持了单指滑动和多指滑动,就能满足大部分的要求,Fling滑动算是比较特别的需求了,大概只有列表类View才需要支持。不过,我们也来看看怎么实现。
      要想一个View支持Fling,我们需要准备两个东西:

    1. 需要计算手指离开View时,滑动的速度。这个速度可以作为Fling滑动的初速度。
    2. 在初速度基础上,来实现Fling滑动。

    (1). VelocityTracker

      要计算手指离开的屏幕时滑动的速度,这个非常简单,官方已经提供对应的工具了,那就是VelocityTracker,我们将对应的Event传入到这个工具类里面就可以计算出我们想要的速度。
      不过,在这里我需要强调一个事,那就是由于涉及到到嵌套滑动,那么View就会屏幕中改变位置,直接传入Event会导致我们计算的速度不是正确的。举一个例子,假设上一个move event的Y坐标是500,同时这个move事件产生了100px的滑动距离,将该View上移了100px,那么下一个event的Y坐标也是500,实际上是没有改变的,因为event.getY是相对于View的坐标,手指相对于View的位置没有变,所以event的坐标没有变。这也是为什么前面,我们需要实时更新mLastMotionY,就是为了避免下一次计算的滑动距离不正确;同时还定义了一个mNestedYOffset,用来记录的是,本次完整事件链中(down->move->up),该View在屏幕中移动了多少距离。
      我们直接来看实现,还是onTouchEvent方法:

        override fun onTouchEvent(event: MotionEvent): Boolean {
            val action = event.actionMasked
            if (action == MotionEvent.ACTION_DOWN) {
                mNestedYOffset = 0
            }
            val vtev = MotionEvent.obtain(event)
            vtev.offsetLocation(0f, mNestedYOffset.toFloat())
            // ······
    
            mVelocityTracker?.let {
                it.addMovement(vtev)
            }
            vtev.recycle()
    
            return true
        }
    

      这里为了解决上面所说速度计算不对,在调用addMovement之前,调整event的坐标,调整所依赖的就是mNestedYOffset
      只要我们理解到这一点就行,其他的就不需要过多的分析了。

    (2). OverScroller

      要想实现Fling滑动,我们需要使用到OverScrollerOverScroller的作用是,拥有一个初速度,然后不断轮询产生滑动的距离,我们可以这个滑动距离,用来滑动我们想要的东西,这里就是两部分:父View和子View的内容。
      我们来看一下实现:

        override fun onTouchEvent(event: MotionEvent): Boolean {
                // ······
                MotionEvent.ACTION_UP -> {
                    mActivePointerId = INVALID_POINTER
                    val velocityTracker = mVelocityTracker
                    velocityTracker?.computeCurrentVelocity(1000, mMaximumVelocity.toFloat())
                    // 向上滑动速度为负,向下滑动速度为正
                    val initVelocity = velocityTracker?.getYVelocity(mActivePointerId)?.toInt() ?: 0
                    if (abs(initVelocity) > mMinimumVelocity) {
                        if (!dispatchNestedPreFling(0F, -initVelocity.toFloat())) {
                            dispatchNestedFling(0F, -initVelocity.toFloat(), true)
                            fling(-initVelocity.toFloat())
                        }
                    }
                    endDrag()
                }
                // ······
            return true
        }
    

      fling的触发是在up里面方法,但是我们从代码实现上可以看出来,在正式调用fling方法之前,还通过嵌套滑动的方法--dispatchNestedPreFling,将fling的滑动分发到父View,主要的目的是为了询问父View是否消费fling事件。如果返回的是false,那么表示父View不消费fling,那就是子View自己消费了,消费的逻辑主要是体现在fling发现里面。不过在看fling方法之前,我们先关注另一个点,那就是endDrag:

        private fun endDrag() {
            mIsBeingDragged = false
            stopNestedScroll(ViewCompat.TYPE_TOUCH)
        }
    

      在这个方法里面,我们关注的是,这个通过stopNestedScroll切断了type为TYPE_TOUCH的传递链,这是为了后面能够重新建立TYPE_NON_TOUCH的传递链做准备。
      我们回过头来看继续看fling方法:

        private fun fling(velocityY: Float) {
            mOverScroller.fling(0, scrollY, 0, velocityY.toInt(), 0, 0, Int.MIN_VALUE, Int.MAX_VALUE)
            runAnimatedScroll()
        }
    

      这里通过OvserScrolled的fling方法开始触发fling滑动,同时还调用了runAnimatedScroll方法:

        private fun runAnimatedScroll() {
            startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH)
            mLastScrollY = scrollY
            ViewCompat.postInvalidateOnAnimation(this)
        }
    

      这个方法里面主要是做了三件事:

    1. 建立TYPE_NON_TOUCH的传递链。
    2. 记录scrollY,用以计算OverScroller产生的滑动距离。
    3. 调用postInvalidateOnAnimation方法,从而触发轮询,回调computeScroll方法。

      fling真实的滑动实现逻辑都在computeScroll方法里面,我们看一下:

        override fun computeScroll() {
            if (mOverScroller.isFinished) {
                return
            }
            mOverScroller.computeScrollOffset()
            val y = mOverScroller.currY
            var deltaY = y - mLastScrollY
            mLastScrollY = y
            mScrollConsumed[1] = 0
            dispatchNestedPreScroll(0, deltaY, mScrollConsumed, null, ViewCompat.TYPE_NON_TOUCH)
            deltaY -= mScrollConsumed[1]
            val range = getScrollRange()
            if (deltaY != 0) {
                val oldScrollY = scrollY
                overScrollBy(0, deltaY, 0, oldScrollY, 0, range, 0, 0, false)
                val consumedY = scrollY - oldScrollY
                deltaY -= consumedY
                mScrollConsumed[1] = 0
                dispatchNestedScroll(
                    0,
                    consumedY,
                    0,
                    deltaY,
                    null,
                    ViewCompat.TYPE_NON_TOUCH,
                    mScrollConsumed
                )
                deltaY -= mScrollConsumed[1]
            }
            if (deltaY != 0) {
                abortAnimateScroll()
            }
            if (!mOverScroller.isFinished) {
                ViewCompat.postInvalidateOnAnimation(this)
            } else {
                abortAnimateScroll()
            }
        }
    

      从代码实现上来看,整体框架跟move的实现比较类似。首先,先计算产生的滑动距离,然后通过dispatchNestedPreScroll询问父View是否消费滑动距离,然后更新滑动距离,调用overScrollBy方法,自己消费滑动距离;当自己消费完成,再调用dispatchNestedScroll询问父View是否消费。
      这里有一个小细节,当到最后滑动距离没有消费完成,表示当前已经滚到边界了此时需要停掉Fling滑动。
      fling 的整个逻辑就是这样,整体来说还是比较清晰的。完整代码大家可以在KotlinDemo找到,commit message 为【支持Fling滑动】

    6. 总结

      到这里,我对实现嵌套滑动的介绍就结束了。本文的重点介绍如何定义一个能够产生嵌套滑动的View,并没有介绍如何定义一个处理嵌套滑动的View。
      这个可以留给大家,有兴趣的同学可以参考CoordinatorLayout的实现,自己实现一个。之前我定义过处理上下两个RecyclerView的ViewGroup,这之间的联动真是折腾人。类似于这种结构:


      其中黄色部分的ViewGroup就是需要我来定义。有兴趣的同学尝试这种ViewGroup怎么来定义。
      我对本文的内容来做一个简单的总结:
    1. 要定义一个产生嵌套滑动的View,实现需要实现NestedScrollingChild接口,并且调用setNestedScrollingEnabled方法,设置为true,表示该View可以产生嵌套滑动的事件。
    2. 定义一个产生嵌套滑动的View,需要处理三个问题:单指滑动,多指滑动,Fling滑动。
    3. 单指滑动,需要在down时,调用startNestedScroll方法建立起嵌套滑动的传递链;在move时,计算产生的滑动距离,先调用dispatchNestedPreScroll方法,询问父View消费滑动的距离,然后在自己消费滑动距离,最后在调用dispatchNestedScroll再次询问父View消费滑动的距离。
    4. 多指滑动,需要在down时,定义一个活跃的手指Id;在move时,使用这个手指Id计算event的Y坐标,从而正确的计算滑动距离;最后就是在合适的时机(up、cancel)正确的更新这个手指Id。
    5. Fling 滑动需要处理两个问题:计算滑动速度和触发Fling滑动。滑动速度可以使用VelocityTracker来计算,Fling滑动可以使用OverScroller来实现。但是Fling滑动需要跟手指滑动区分的是,Fling滑动建立的嵌套滑动传递链,type是TYPE_NON_TOUCH;而单指滑动是TYPE__TOUCH

    相关文章

      网友评论

        本文标题:Android - 手把手教你写出一个支持嵌套滑动的View

        本文链接:https://www.haomeiwen.com/subject/ttcbgltx.html