嵌套 ScrollView 滑动冲突实战:实现内部优先滚动

前言

最近有一个小需求,有两个嵌套的滚动容器,要实现内部优先,内层容器到顶部或底部后,再把事件交给外层容器处理。

虽然知道大概走向,但还真没动手做过,既然如此,来实操一下吧。

需要了解触摸反馈与事件分发的原理,可以看我的这篇博客

首先我们要确定,应该替换外层容器还是内层,其实应该是内层。

因为在内层才能判断,当前是否滑到了顶/底部,同时内层也能通过 parent?.requestDisallowInterceptTouchEvent() 请求外层的容器不要拦截事件。

处理策略

我们先来说一下处理策略:

  1. 在手指按下(ACTION_DOWN)时,禁止父布局拦截,否则后续的事件都会交给父布局处理,不会轮到我们。
  2. 手指抬起的时候,恢复父布局的拦截能力,让它能处理后续的其他手势。
  3. 关键在手指移动时,如果还能滑动,就还要禁止父布局拦截。当滑动到顶部还往下拖或滑动到底部还往上拖时,就放开父布局的拦截,让外层接管。

如果是嵌套的 NestedScrollView 就无需这么麻烦,默认效果就是内部优先。它自带了 Nested Scrolling 协作机制。

代码实现

先定义出基本的骨架代码,为了确保万无一失,我们把事件的拦截策略统一放在 dispatchTouchEvent 中处理。

kotlin 复制代码
/**
 * 内部日志优先滚动容器
 */
class NestedLogScrollView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ScrollView(context, attrs, defStyleAttr) {
    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
        if (ev == null) return super.dispatchTouchEvent(ev)
        return super.dispatchTouchEvent(ev)
    }
}

在手指按下时禁止父布局拦截事件,然后在手指抬起时,恢复父布局拦截:

kotlin 复制代码
class NestedLogScrollView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ScrollView(context, attrs, defStyleAttr) {

    // 记录上一次手指的绝对 Y 坐标
    private var lastRawY = 0f

    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
        if (ev == null) return super.dispatchTouchEvent(ev)

        when (ev.actionMasked) {
            MotionEvent.ACTION_DOWN -> {
                // 按下时,记录绝对坐标
                lastRawY = ev.rawY
                // 并让父布局不要拦截事件,否则它一旦开始拦截,我们将无法收到后续事件
                parent?.requestDisallowInterceptTouchEvent(true)
            }

            MotionEvent.ACTION_MOVE -> {
                ...
            }

            MotionEvent.ACTION_UP,
            MotionEvent.ACTION_CANCEL -> {
                // 手势结束(抬起或被取消),恢复父布局拦截
                parent?.requestDisallowInterceptTouchEvent(false)
            }
        }

        return super.dispatchTouchEvent(ev)
    }
}

现在,我们就完成大半了,只剩下手指移动部分。

kotlin 复制代码
// 获取系统标准的触发距离,过滤手指按下时的微小抖动,避免内外层来回抢手势
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop

MotionEvent.ACTION_MOVE -> {
    val currentRawY = ev.rawY
    val dy = currentRawY - lastRawY

    // 位移过小看作是抖动,不改变拦截的策略
    if (abs(dy) > touchSlop) {
        // dy > 0 代表手势向下划(试图查看上面被遮挡的内容)
        val isScrollingDown = dy > 0

        val canScrollFurther = if (isScrollingDown) {
            canScrollVertically(-1) // 检查上方是否还能滚
        } else {
            canScrollVertically(1)  // 检查下方是否还能滚
        }

        if (!canScrollFurther) {
            // 已经滑到边界了,放开拦截,把事件还给外层
            parent?.requestDisallowInterceptTouchEvent(false)
        } else {
            // 还能继续滚动,抓住事件不放
            parent?.requestDisallowInterceptTouchEvent(true)
        }

        // 更新坐标
        lastRawY = currentRawY
    }
}

现在这个需求就完成了,是不是很简单?

这里有一个细节,我们获取的坐标是 ev.rawY(即屏幕绝对坐标),而不是 ev.y(相对容器顶部的坐标),因为当容器发生滚动时,这个位置一直在变,使用它可能会导致坐标跳变,使得内外层来回抢夺手势,造成页面上下抽搐。

此外,判断是否滚动的关键在于 canScrollVertically() 方法,参数传入负数,表示检查顶部是否还有内容,传入正数表示检查底部。

它内部的逻辑也很清晰:首先获取当前滚动的偏移值,初始为 0,往下滑值增大,它代表了内容已经向上滑出去的距离

然后获取完整内容 的总高度,以及容器可见区域的高度,得出最大的可滚动距离 range,接下来的拆解放到代码中。

java 复制代码
public boolean canScrollVertically(int direction) {
    final int offset = computeVerticalScrollOffset();
    final int range = computeVerticalScrollRange() - computeVerticalScrollExtent();
    // 可滚动距离为 0,说明内容刚好填满容器的可视区域,直接返回无法滚动
    if (range == 0) return false;
    if (direction < 0) { // 检查顶部方向
        // offset > 0,说明已经向下滚动了一段距离,顶部有内容被遮挡了,所以还可以向上滚回去
        return offset > 0;
    } else { // 检查底部方向
        // 偏移还没到最大滚动值,说明底部还有内容
        return offset < range - 1; // -1 是为了边界容错
    }
}

另外,range 的值可以为负数,这说明内容不足。如果 direction > 0,会进到 else 分支中,返回 false,即不可滚动,语义还是正确的。

为了方便直接使用,这里贴上 NestedLogScrollView 的完整代码:

kotlin 复制代码
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.ViewConfiguration
import android.widget.ScrollView
import kotlin.math.abs

class NestedLogScrollView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ScrollView(context, attrs, defStyleAttr) {
    private var lastRawY = 0f
    private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop

    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
        if (ev == null) return super.dispatchTouchEvent(ev)

        when (ev.actionMasked) {
            MotionEvent.ACTION_DOWN -> {
                lastRawY = ev.rawY
                parent?.requestDisallowInterceptTouchEvent(true)
            }

            MotionEvent.ACTION_MOVE -> {
                val currentRawY = ev.rawY
                val dy = currentRawY - lastRawY

                if (abs(dy) > touchSlop) {
                    val isScrollingDown = dy > 0

                    val canScrollFurther = if (isScrollingDown) {
                        canScrollVertically(-1)
                    } else {
                        canScrollVertically(1)
                    }

                    if (!canScrollFurther) {
                        parent?.requestDisallowInterceptTouchEvent(false)
                    } else {
                        parent?.requestDisallowInterceptTouchEvent(true)
                    }

                    lastRawY = currentRawY
                }
            }

            MotionEvent.ACTION_UP,
            MotionEvent.ACTION_CANCEL -> {
                parent?.requestDisallowInterceptTouchEvent(false)
            }
        }

        return super.dispatchTouchEvent(ev)
    }
}

避坑指南

为什么要把逻辑全写在 dispatchTouchEvent 中?我们不应该在 onTouchEvent 中消费事件吗?

首先 "子 View 消费了事件,父 ViewGroup 就不能消费",这在普通的容器里是正确的,但在 ScrollView 里是错误的。

当你按住 ScrollView 中的 Button 开始滑动时,一开始 ACTION_DOWN 被 Button 消费了,但只要手指的滑动距离超过了阈值(TouchSlop),ScrollView 内部的拦截机制(onInterceptTouchEvent)就会自动给 Button 发送一个 ACTION_CANCEL 事件,把后续的手势强行抢过来,开始自身的滚动。

如果我们把内部优先的逻辑放在 onTouchEvent,看看会发生什么?

如果子 View 消费了事件,那么我们的 onTouchEvent 根本就不会收到 ACTION_DOWN,何谈通过返回 true 来接收后续的 MOVE 事件。事件只会被外层的容器无情抢走,我们内部优先的逻辑就会失效。

所以我们要把逻辑上提到 dispatchTouchEvent 中,这是为了能随时接管事件。

示例代码

示例布局:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/outerScrollView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fillViewport="true">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:padding="16dp">

        <TextView
            android:id="@+id/tvTopContent"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text=""
            android:textSize="14sp" />

        <Button
            android:id="@+id/btnAppendLogs"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="12dp"
            android:text="追加日志" />

        <Button
            android:id="@+id/btnClearLogs"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:text="清空日志" />

        <com.example.demoview.widget.NestedLogScrollView
            android:id="@+id/logScrollView"
            android:layout_width="match_parent"
            android:layout_height="180dp"
            android:layout_marginTop="12dp"
            android:background="#EEEEEE"
            android:fillViewport="true"
            android:scrollbars="vertical">

            <TextView
                android:id="@+id/logText"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:fontFamily="monospace"
                android:hint="日志区域"
                android:padding="12dp"
                android:textSize="13sp" />
        </com.example.demoview.widget.NestedLogScrollView>

        <TextView
            android:id="@+id/tvBottomContent"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="12dp"
            android:layout_marginBottom="24dp"
            android:textSize="14sp" />
    </LinearLayout>
</ScrollView>

示例代码:

kotlin 复制代码
class NestedLogScrollDemoActivity : AppCompatActivity() {

    private lateinit var logScrollView: NestedLogScrollView
    private lateinit var logText: TextView
    private var logIndex = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_nested_log_scroll_demo)

        logScrollView = findViewById(R.id.logScrollView)
        logText = findViewById(R.id.logText)

        findViewById<TextView>(R.id.tvTopContent).text = getLines(count = 15)
        findViewById<TextView>(R.id.tvBottomContent).text = getLines(count = 30)

        findViewById<Button>(R.id.btnAppendLogs).setOnClickListener { appendLogs(count = 5) }
        findViewById<Button>(R.id.btnClearLogs).setOnClickListener {
            logIndex = 0
            logText.text = ""
        }

        appendLogs(count = 12)
    }

    private fun getLines(count: Int): String {
        val sb = StringBuilder()
        repeat(times = count) { line ->
            sb.append("Line ${line + 1}\n")
        }
        return sb.toString()
    }

    private fun appendLogs(count: Int) {
        val sb = StringBuilder()
        repeat(times = count) {
            logIndex++
            sb.append("log #$logIndex\n")
        }
        logText.append(sb.toString())
        logScrollView.post { logScrollView.fullScroll(ScrollView.FOCUS_DOWN) }
    }
}

运行效果:


相关推荐
AFinalStone1 小时前
Android 7系统异常问题排查(四)Framework层(上)—System Server Watchdog
android·系统异常
宝杰X73 小时前
Android Room3 多平台数据库
android·数据库
huibin1478523695 小时前
热缓解学习记录
android·学习
小田的博客8 小时前
SAP MM 供应商银行主数据更新报错!message R1228!
android·java·服务器
一笑的小酒馆9 小时前
Android智能猫砂盆视频加载慢卡顿问题分析
android
只会cv的小前端11 小时前
七巧低代码服务端脚本使用方法
android·低代码·rxjava
11 小时前
Android 自定义 View 实战:从零打造工业级全向摇杆控件(OmniJoystickView)
android·kotlin·自定义view·摇杆控件
雨白11 小时前
深入理解 Kotlin 协程 (十一):以逸待劳,探秘 select 多路复用与并发安全策略
android·kotlin
hunterandroid14 小时前
[Android 从零到一] Android 深度链接与 App Links:从 URI Scheme 到可验证的应用跳转
android
我命由我1234516 小时前
Jetpack Compose - Material Design 断点范围、WindowSizeClass、针对不同屏幕尺寸创建预览、四类导航栏
android·java·开发语言·java-ee·kotlin·android jetpack·android runtime