Android 实现嵌套滑动

前言

Android实现简易版滑动

上次文章中实现了简易的ScrollerView滑动,但实际使用中许多场景都会涉及到嵌套滑动,在今天的博文中我们基于上次的ScrollLayout来进一步实现嵌套滑动。

嵌套滑动预备知识:https://juejin.cn/post/6844904184911773709

整体页面结构

<?xml version="1.0" encoding="utf-8"?>
<com.example.nestedscroll.ScrollParentLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">


    <TextView
        android:layout_width="match_parent"
        android:layout_height="300dp"
        android:text="I'm TOP!"
        android:gravity="center"
        android:textSize="24sp"
        android:background="@color/teal_700"/>

    <com.example.nestedscroll.ScrollChildLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="100dp"
            android:text="I'm 1"
            android:gravity="center"
            android:textSize="24sp"
            android:background="@color/red1"/>
        <TextView
            android:layout_width="match_parent"
            android:layout_height="100dp"
            android:text="I'm 2"
            android:gravity="center"
            android:textSize="24sp"
            android:background="@color/red2"/>
        <TextView
            android:layout_width="match_parent"
            android:layout_height="100dp"
            android:text="I'm 3"
            android:gravity="center"
            android:textSize="24sp"
            android:background="@color/red1"/>
            
            ... 后边还有n个TextView

    </com.example.nestedscroll.ScrollChildLayout>

</com.example.nestedscroll.ScrollParentLayout>

嵌套结构中父ViewGroup为ScrollParentLayout,子ViewGroup为ScrollChildLayout。

class ScrollParentLayout @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null,
) : NestedScrollLayout(context, attrs), NestedScrollingParent3 

class ScrollChildLayout @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null,
) : NestedScrollLayout(context, attrs), NestedScrollingChild3 
  • ScrollParentLayout和ScrollChildLayout均继承自NestedScrollLayout(NestedScrollLayout为ScrollLayout的copy,以不修改ScrollLayout实现)以提供滑动功能
  • ScrollParentLayout实现了NestedScrollingParent3接口,作为嵌套滑动的父控件
  • ScrollChildLayout实现了NestedScrollingChild3接口,作为嵌套滑动的子控件

页面滑不动

运行后发现页面滑不动,查看NestedScrollLayout的onInterceptTouchEvent()实现,为简单实现滑动效果,上节中简单将NestedScrollLayout设置为拦截所有触摸事件。

override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
    return true
}

这直接导致了页面滑不动,因为ScrollParentLayout其子View的高度经过onMeasure后都是固定的了,所以ScrollParentLayout的控件高度和内容高度相等,ScrollParentLayout不可滑动。同时由于ScrollParentLayout在外层拦截了触摸事件,ScrollChildLayout无法接收到触摸事件,因此也无法响应,所以页面无法滑动。

结合嵌套滑动的机制(NestedScrollingParent,NestedScrollingChild机制),滑动时间需由子控件来接收,然后通过嵌套滑动机制来确定父控件是否消费部分滑动距离,因此ScrollParentLayout需要保证不拦截触摸事件,同时ScrollChildLayout需要接收到触摸事件。

//ScrollParentLayout.kt
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
    return false
}
//ScrollChildLayout.kt
//实现参考了NestedScrollView
//实现参考了NestedScrollView
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
    if (ev == null) return false
    val action = ev.action
    if (action == MotionEvent.ACTION_MOVE && isBeingDragged) {
        return true
    }
    var currY = ev.y
    when (action) {
        MotionEvent.ACTION_MOVE -> {
            if (abs(currY - lastY) >= touchSlop) {
                isBeingDragged = true
                val parent = parent
                parent?.requestDisallowInterceptTouchEvent(true)
            }
        }
        MotionEvent.ACTION_DOWN -> {
            isBeingDragged = false
            //开始嵌套滑动,注意不是startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL)
            startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH)
        }
        MotionEvent.ACTION_CANCEL,
        MotionEvent.ACTION_UP,
        -> {
            //结束嵌套滑动
            isBeingDragged = false
            stopNestedScroll()
        }
    }
    return isBeingDragged
}

重写onInterceptTouchEvent()中,我们默认不拦截触摸事件,只有当View表现为正在滑动时才进行拦截,以处理滑动,并在开始滑动时调用startNestedScroll(),手指抬起时调用stopNestedScroll(),由于一个事件序列中会有多个ACTION_MOVE事件,而startNestedScroll()仅仅只在第一次判定为滑动时调用,所以引入了isBeingDragged变量,用以判断当前是否已经在嵌套滑动了,如果是则直接返回true,对应的逻辑为下边的代码。

    if (action == MotionEvent.ACTION_MOVE && isBeingDragged){
        return true
    }

经过处理后子View可以正常滑动了。

嵌套Scroll

ScrollChildLayout实现NestedScrollChild3接口

嵌套滑动机制中为我们提供了NestedScrollingChildHelper工具类,封装了基本的子ScrollView向父ScrollView传递滑动事件的操作,我们只需要NestedScrollingChildHelper对应的方法即可。注意NestedScrollingChildHelper要手动设置isNestedScrollingEnabled为ture。

private val childHelper = NestedScrollingChildHelper(this).apply {
    //注意要手动设置isNestedScrollingEnabled为ture,只有开启此开关,嵌套滑动才有效
    isNestedScrollingEnabled = true
}

override fun startNestedScroll(axes: Int, type: Int): Boolean {
    return childHelper.startNestedScroll(axes, type)
}

override fun stopNestedScroll(type: Int) {
    return childHelper.stopNestedScroll(type)
}

override fun hasNestedScrollingParent(type: Int): Boolean {
    return childHelper.hasNestedScrollingParent(type)
}

override fun dispatchNestedScroll(
    dxConsumed: Int,
    dyConsumed: Int,
    dxUnconsumed: Int,
    dyUnconsumed: Int,
    offsetInWindow: IntArray?,
    type: Int,
    consumed: IntArray,
) {
    childHelper.dispatchNestedScroll(dxConsumed,
        dyConsumed,
        dxUnconsumed,
        dyUnconsumed,
        offsetInWindow,
        type,
        consumed)
}

override fun dispatchNestedScroll(
    dxConsumed: Int,
    dyConsumed: Int,
    dxUnconsumed: Int,
    dyUnconsumed: Int,
    offsetInWindow: IntArray?,
    type: Int,
): Boolean {
    return childHelper.dispatchNestedScroll(dxConsumed,
        dyConsumed,
        dxUnconsumed,
        dyUnconsumed,
        offsetInWindow,
        type)
}

override fun dispatchNestedPreScroll(
    dx: Int,
    dy: Int,
    consumed: IntArray?,
    offsetInWindow: IntArray?,
    type: Int,
): Boolean {
    return childHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type)
}

override fun dispatchNestedFling(
    velocityX: Float,
    velocityY: Float,
    consumed: Boolean,
): Boolean {
    return childHelper.dispatchNestedFling(velocityX, velocityY, consumed)
}

override fun dispatchNestedPreFling(velocityX: Float, velocityY: Float): Boolean {
    return childHelper.dispatchNestedPreFling(velocityX, velocityY)
}

ScrollParentLayout实现NestedScrollParent3接口

嵌套滑动机制中也提供了NestedScrollingParentHelper工具类,我们可以使用此工具类来实现onNestedScrollAccepted()和onStopNestedScroll(),其他很多接口需要我们自行根据业务需要实现。

private val parentHelper = NestedScrollingParentHelper(this)

override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {
    //判断是否处理嵌套滑动
    return axes and ViewCompat.SCROLL_AXIS_VERTICAL != 0
}

override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
    parentHelper.onNestedScrollAccepted(child, target, axes, type)
}

override fun onStopNestedScroll(target: View, type: Int) {
    parentHelper.onStopNestedScroll(target, type)
}

override fun onNestedScroll(
    target: View,
    dxConsumed: Int,
    dyConsumed: Int,
    dxUnconsumed: Int,
    dyUnconsumed: Int,
    type: Int,
    consumed: IntArray,
) {

}

override fun onNestedScroll(
    target: View,
    dxConsumed: Int,
    dyConsumed: Int,
    dxUnconsumed: Int,
    dyUnconsumed: Int,
    type: Int,
) {

}

override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
    //TODO
}

override fun onNestedFling(
    target: View,
    velocityX: Float,
    velocityY: Float,
    consumed: Boolean
): Boolean {
    //TODO
}

override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean {
    //TODO
}

让嵌套Scroll生效

上边的onInterceptTouchEvent()中我们通过在TOUCH_DOWN事件中调用了startNestedScroll()方法,开启了嵌套滑动,此方法主要用于确定嵌套滑动的NestedScrollingParent是谁。

接下来就需要由ScrollChildLayout来在滑动时将事件分发给ScrollParentLayout。滑动事件在onTouchEvent()的ACTION_MOVE事件中处理,这里将其抽离出来单独放在handleScroll()方法中。

override fun handleScroll(currX: Float, currY: Float) {
    val deltaX = currX - lastX
    val deltaY = currY - lastY
    var realDeltaY = deltaY.toInt()

    if (dispatchNestedPreScroll(0,
            realDeltaY,
            scrollConsumed,
            scrollOffset,
            ViewCompat.TYPE_TOUCH)
    ) {
        realDeltaY -= scrollConsumed[1]
    }
    if (canScrollVertically(1) || canScrollVertically(-1)) {
        //防止滑出边界
        realDeltaY = limitRange(realDeltaY, scrollY, -getScrollRange() + scrollY)
        scrollBy(0, -realDeltaY)
    }
}

上面代码中,利用嵌套滑动机制,首先dispatchNestedPreScroll()将滑动距离交由ScrollParentLayout来处理,ScrollParentLayout来先消费一部分距离,将剩下未消费的距离交由ScrollChildLayout继续处理,

ScrollChildLayout在判断了是否滑出边界后,调用scrollBy()方法处理剩下的滑动距离。

然后ScrollParentLayout也需要配合完成相应的滑动操作,ScrollParentLayout在onNestedPreScroll()方法中接收到对应的嵌套滑动距离,判断自身是否要消费。

回顾下目前布局结构是:

-ScrollParentLayout

-TopView

-ScrollChildLayout

ScrollParentLayout有两种常见处理方式:

  1. TopView和ScrollChildLayout同步调用layout(left,top,right,bottom)方法,TopView更新top和bottom,ScrollChildLayout更新top。(实现时遇到些问题,暂未采用)
  2. ScrollParentLayout调用scrollBy方法()整体滑动。看起来比较简单,下边代码即采用此方案。但需要先改造onMeasure()方法,让其能计算出其内容的高度(包括所有可滑动的子View内容的高度)作为其view的height属性
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    //visibleHeight为控件可见高度
    visibleHeight = measuredHeight
    if (orientation == VERTICAL) {
        var totalLength = paddingTop + paddingBottom
        for (child in children) {
            totalLength += child.marginTop + child.measuredHeight + child.marginBottom
        }
        totalHeight = totalLength
    }
    //将measureHeight设置为内容的高度
    setMeasuredDimension(measuredWidth, totalHeight)
}

在onNestedPreScroll中,我们需要计算出ScrollParentLayout需要消费的滑动距离,主要要保证最后交由ScrollParentLayout处理的滑动的最终位置在[0, topViewHeight]范围内(即保证TopView可见或刚好不可见的部分才交由ScrollParentLayout处理)。

override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
    var consumedY = 0
    //scrollY以向下为正向,整体相对于初始位置的偏移 -topViewHeight <= scrollY <= 0
    if (target == scrollChildLayout) {
        //下滑 && TopView还能再下滑(在初始位置之上)
        if (dy > 0 && scrollY > 0 && !scrollChildLayout.canScrollVertically(-1)) {
            consumedY = Math.min(scrollY, dy)
        //上滑 && TopView还能向上滑(TopView还可见)
        } else if (dy < 0 && scrollY < topViewHeight) {
            consumedY = Math.max(-topViewHeight + scrollY, dy)
        }
    }
    if (consumedY != 0) {
        scrollBy(0, -consumedY)
        consumed[1] = consumedY
    }
}

问题1:嵌套滑动距离小于手指滑动距离,滑动抖动

这个问题由于MotionEvent所对应的View(ScrollChildLayout)移动了所导致的,正常的跟手滑动为ScrollChildLayout不动,则每次滑动的deltaY = currY - lastY。currY和lastY都是通过event.getY()获取到的,event.getY()获取到的y值是相对于当前View(ScrollChildLayout)的Y值。由于当前View也朝相同方向滑动了,这导致计算出来的deltaY偏小,从而导致嵌套滑动距离小于手指滑动距离。(TODO滑动抖动)

解决办法(参考NestedScrollView):

我们需要获取到在ScrollParentLayout滑动时ScrollChildLayout的偏移量,查看dispatchNestedPreScroll()方法,可以使用offsetInWindow这个参数来获取ScrollParentLayout此次嵌套滑动的偏移量,然后在最后赋值lastY = currY - offsetInWindow[1]来校准偏移量。

/**
 * 在滑动之前,将滑动值分发给NestedScrollingParent
 * @param dx 水平方向消费的距离
 * @param dy 垂直方向消费的距离
 * @param consumed 输出坐标数组,consumed[0]为NestedScrollingParent消耗的水平距离、
 * consumed[1]为NestedScrollingParent消耗的垂直距离,此参数可空。
 * @param offsetInWindow 含有View从此方法调用之前到调用完成后的屏幕坐标偏移量,
 * 可以使用这个偏移量来调整预期的输入坐标(即上面4个消费、剩余的距离)跟踪,此参数可空。
 * @return 返回NestedScrollingParent是否消费部分或全部滑动值
 */
boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
        int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow,
        @NestedScrollType int type);

至此就可以流畅的嵌套Scroll了~。

嵌套Fling

回顾前文中非嵌套的fling,通过OverScroller来实现滑动。OverScroller需配合computeScroll()方法一起处理fling动作。

NestedScrollChild接口提供了对应的dispatchNestedFling()和dispatchNestedPreFling()方法,NestedScrollParent接口也提供了对应的onNestedFlin()和onNestedPreFling()方法。由于目前还没想到使用的时机,暂时不知道咋用。。所以暂不使用这两个。通过scroll相关的接口也可以实现嵌套fling的效果。

fling事件一般在ACTION_UP事件中处理,先通过overScroller开始fling,然后开启嵌套滑动,注意嵌套滑动的类型是ViewCompat.TYPE_NON_TOUCH,代表的就是fling类型。

//ScrollChildLayout.kt
override fun touchUp() {
    velocityTracker.computeCurrentVelocity(1000, maxFlingVelocity.toFloat())
    val yVelocity = velocityTracker.yVelocity
    if (abs(yVelocity) >= minFlingVelocity) {
        flingWithOverScroller(-yVelocity)
        startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH)
        lastScrollY = scrollY
        ViewCompat.postInvalidateOnAnimation(this)
    }
}

同时computeScroll()方法中也要配合实现嵌套滑动,在子View调用scrollBy()方法的之前先通过dispatchNestedPreScroll()询问父View是否需要处理嵌套滑动事件,然后子View再消耗剩下的滑动距离,实现方法类似处理ACTION_MOVE事件中的嵌套滑动处理。但要注意滑动的类型是ViewCompat.TYPE_NON_TOUCH。

//ScrollChildLayout.kt
override fun computeScroll() {
    if (overScroller.computeScrollOffset()) {
        val deltaY = overScroller.currY - lastScrollY
        var unconsumed = deltaY
        lastScrollY = overScroller.currY
        if (dispatchNestedPreScroll(0,
                unconsumed,
                scrollConsumed,
                null,
                ViewCompat.TYPE_NON_TOUCH)
        ) {
            unconsumed -= scrollConsumed[1]
            totalParentConsumeScrollY += scrollConsumed[1]
        }
        if (unconsumed != 0 && canScrollVertically(1) || canScrollVertically(-1)) {
            //防止滑出边界
            val selfConsume = getRealScrollDistance(unconsumed)
            scrollBy(0, -selfConsume)
        }
    }
    if (!overScroller.isFinished) {
        ViewCompat.postInvalidateOnAnimation(this)
    } else {
        stopNestedScroll(ViewCompat.TYPE_NON_TOUCH)
    }
    awakenScrollBars()

}

之所以能通过startNestedScroll()的方式来处理嵌套fling,是因为嵌套scroll本质上是在调用scrollBy()方法之前询问父View是否要消费滑动距离,而ACTION_MOVE中的跟手滑动和fling中的惯性滑动,都是调用的scrollBy()方法,所以都可以通过startNestedScroll()来处理嵌套滑动。

问题1:嵌套fling的滑动距离明显不够,比预期的要短

这个问题的原因类似于嵌套Scroll中的嵌套滑动距离过短,它们都是由于当前View(ScrollChildLayout)的位置也发生了变化,导致了计算的手指移动距离过短而导致的。由于fling事件需要通过velocityTracker.addMovement(event)事先添加该次触摸事件序列中的所有事件,然后根据所有的event来计算出速度,由于event不加处理的情况下,会由于View(ScrollChildLayout)的滑动导致event的位置不准确,这样计算出的速度也是不准确的。我们可以使用类似上边处理嵌套滑动的手段计算出当前View(ScrollChildLayout)滑动的偏差。然后将event加上对应的偏差值,然后再添加到velocityTracker中即可校准速度。

//ScrollChildLayout.kt

override fun handleScroll(currX: Float, currY: Float) {
    
    ...
    
    if (dispatchNestedPreScroll(0,
            unconsumed,
            scrollConsumed,
            scrollOffset,
            ViewCompat.TYPE_TOUCH)
    ) {
        unconsumed -= scrollConsumed[1]
        //计算此次滑动事件序列的总偏差值,用于校正fling的速度
        nestedYOffset += scrollOffset[1]
        lastY -= scrollOffset[1]
    }
    
    ...
}


override fun onTouchEvent(event: MotionEvent?): Boolean {

    ...
    
    val offsetEvent = MotionEvent.obtain(event)
    //根据总的嵌套滑动偏移量,校正速度
    offsetEvent.offsetLocation(0f, nestedYOffset.toFloat())
    velocityTracker.addMovement(offsetEvent)
    offsetEvent.recycle()
    
    ...
}

未解决的问题:

  1. 嵌套滑动时,可滑动边界判断不准,滑动到底部还多出一段空白(高度等于TopView)。

原因:ScrollChildLayout的可滑动范围=totalHeight - visibleHeight,初始时visibleHeight= ScrollParentLayout.visibleHeight - TopViewHeight,而随着ScrollChildLayout的向上滑动,其visibleHeight会慢慢增加,直到等于ScrollParentLayout.visibleHeight。目前ScrollChildLayout.visibleHeight未动态修改。

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值