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

  嵌套滑动机制,想必大家都不陌生,当我们在使用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
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容