解决各类滚动冲突

11/3/2022 滚动冲突

# 用NestedScrollingParent与NestedScrollingChild配合解决滚动冲突

# 1. 用事件拦截法处理的弊端

事件拦截法一般采用外部拦截或内部拦截的方法,但两种拦截都离不开一个问题,就是当外层需要拦截处理后,内层将收到一个EVENT_CANCEL事件,并不再会收到后续的事件分发,这种情况下处理滚动只能接管事件的分发自行计算与再分发,而使用NestedScrollingParent与NestedScrollingChild解决嵌套滚动则简单的多

# 2. NestedScrollingParent与NestedScrollingChild配合的流程

nested-scrolling.jpg

# 3. 解决冲突方案

由于RecyclerView、ScrollView和NestedScrollView都实现了NestedScrollingChild接口,所以针对它们嵌套在其他可滚动的布局而产生的冲突,只需要重写外层的布局,并实现NestedScrollingParent即可完美解决冲突问题,需要注意的是对外层抛的动作需要做一些特殊处理,因为在外层触发的触摸事件只能与它的上层进行分享,无法与下层进行交互,因此要做的就是将当前层处理后剩余的加速度传递给下层继续进行处理

# NestedScrollView嵌套NestedScrollingChild

# 1. 布局情况

非常典型的广告栏+Tab标题+列表的形式,其中Tab标题和列表组装成组件放入Fragment中,要实现的效果就是Tab标题(黄色区域)在列表往上拖动到顶部时能够吸顶,且吸顶后NestedScrollView不能向下拖动,另外在广告栏区域向上做快速抛的动作时,能够带动下面的RecyclerView(可替换为ScrollView或NestedScrollView)一起做惯性滚动

simple-layout.jpg

这里也再提一下,如何做到吸顶,计较简单,就是将内层的高度设置为外层高度减去Tab标题的高度,在onMeasure过程中就可以设置

override fun measureChildWithMargins(child: View?, parentWidthMeasureSpec: Int, widthUsed: Int,
    parentHeightMeasureSpec: Int, heightUsed: Int) {
    super.measureChildWithMargins(child, parentWidthMeasureSpec, widthUsed,
        parentHeightMeasureSpec, heightUsed)

    findShownViewWithTag(childTag)?.let { recyclerView ->
        // 找到内层后进行计算
        val atMostHeight = MeasureSpec.getSize(parentHeightMeasureSpec)
        val usedHeight = getUsedHeight(recyclerView)
        recyclerView.layoutParams.height = atMostHeight - usedHeight
    }
}

// 由于使用的线性布局,可以逐层累加直到找到内层为止
fun getUsedHeight(targetView: View): Int {
    val viewGroup = targetView.parent as ViewGroup
    var usedHeight = 0
    for (i in 0 until viewGroup.childCount) {
        val measuredChild = viewGroup.getChildAt(i)
        if (measuredChild == targetView) {
            break
        } else {
            usedHeight += measuredChild.measuredHeight
        }
    }

    return usedHeight
}

# 2. 处理拖动

// 实现onNestedPreScroll方法,先于RecyclerView进行判断
// 当向上拖动时,如果NestedScrollView还有空间就把这部分空间的距离先消费掉
override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
    if (dy >= 0) {
        val oldScrollY = scrollY
        scrollBy(0, dy)
        // 当NestedScrollView没有空间可以滚动时,myConsumed始终为 0
        val myConsumed = scrollY - oldScrollY
        consumed[1] += myConsumed
    }
}

# 3. 处理吸顶后不允许RecyclerView外的控件进行拖动

// 从入口方法开始
override fun dispatchNestedPreScroll(dx: Int, dy: Int, consumed: IntArray?, offsetInWindow: IntArray?, type: Int): Boolean {
    // 找到内层的NestedScrollingChild布局,我这边采用tag和isShown进行查找
    val view = findShownViewWithTag(childTag)
    // 根据内层当前的滚动情况判断是否要拦截此次拖动事件
    return if (((view as? RecyclerView)?.computeVerticalScrollOffset()
            ?: 0) > 0 || (view?.scrollY ?: 0) > 0) {
        // 判断需要拦截,直接消费掉所有的距离
        consumed?.set(1, dy)
        true
    } else {
        return super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type)
    }
}

# 4. 处理fling

// 从入口方法开始
override fun dispatchNestedPreFling(velocityX: Float, velocityY: Float): Boolean {
    val view = findShownViewWithTag(childTag)
    // 找到内层后先交给内层处理
    return if (
        (view as? RecyclerView)?.fling(velocityX.toInt(), velocityY.toInt()) == true
        ||
        (view as? NestedScrollView)?.let {
            it.fling(velocityY.toInt())
            true
        } == true
        || (view as? ScrollView)?.let {
            it.fling(velocityY.toInt())
            true
        } == true
    ) {
        true
    } else {
        return super.dispatchNestedPreFling(velocityX, velocityY)
    }
}

这里需要注意当fling交给内层处理后,内层会再次回调dispatchNestedFling找到外层并调用其onNestedPreFling,而NestedScrollView源码中发现该方法又调用了自身的dispatchNestedPreFling方法也就是我们上面重写的方法,所以还需要重写

override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean {
    return false
}

以保证不会出现死循环,另外从源码还发现fling内部仍然会走滚动的逻辑,所以外层仍然可以优先判断和消费

// RecyclerView源码
public boolean fling(int velocityX, int velocityY) {
    ...
    if (!dispatchNestedPreFling(velocityX, velocityY)) {
        final boolean canScroll = canScrollHorizontal || canScrollVertical;
        // 这里又会找到外层再调用
        dispatchNestedFling(velocityX, velocityY, canScroll);
        ...
        if (canScroll) {
            ...
            // 这里会再走滚动的相关逻辑,所以向上抛时,仍然是外层先判断和消费
            startNestedScroll(nestedScrollAxis, TYPE_NON_TOUCH);

            velocityX = Math.max(-mMaxFlingVelocity, Math.min(velocityX, mMaxFlingVelocity));
            velocityY = Math.max(-mMaxFlingVelocity, Math.min(velocityY, mMaxFlingVelocity));
            mViewFlinger.fling(velocityX, velocityY);
            return true;
        }
    }
    return false;
}

# ListView嵌套RecyclerView

# 1. 布局情况

ListView通过addHeaderView添加广告栏,通过addFooterView添加组件Fragment

simple-layout-listview.jpg

# 2. 处理拖动

// 接管所有滚动相关,避免事件再往再外层传递
override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int,
    dyUnconsumed: Int) {
    onNestedScrollInternal(dyUnconsumed, null)
}

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

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

// 先于内层进行判断和消费
override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
    if (dy >= 0) {
        onNestedScrollInternal(dy, consumed)
    }
}

private fun onNestedScrollInternal(dyUnconsumed: Int, consumed: IntArray?) {
    val motionIndex = childCount / 2
    val motionView = getChildAt(motionIndex)
    val oldTop = motionView.top
    scrollListBy(dyUnconsumed)
    // 找到中间的子view判断其高度来确定实际的消费距离
    var myConsumed = oldTop - motionView.top
    // 下拉时如果顶部还有空间未展示,滚动会进入异步,此时需要消费掉所有的距离
    // 否则fling时会认为还在消费中而停止
    if (dyUnconsumed < 0 && firstVisiblePosition > 0) {
        myConsumed = dyUnconsumed
    }
    if (consumed != null) {
        consumed[1] += myConsumed
    }
}

# 3. 处理吸顶后不允许RecyclerView外的控件进行拖动

与NestedScrollView嵌套NestedScrollingChild的逻辑相同

# 4. 处理fling

与NestedScrollView嵌套NestedScrollingChild的逻辑相同