惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Forbes - Security
Forbes - Security
T
Troy Hunt's Blog
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
H
Hacker News: Front Page
TaoSecurity Blog
TaoSecurity Blog
PCI Perspectives
PCI Perspectives
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
V
Vulnerabilities – Threatpost
博客园 - 【当耐特】
V
Visual Studio Blog
N
News and Events Feed by Topic
Security Archives - TechRepublic
Security Archives - TechRepublic
V
V2EX
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Recent Commits to openclaw:main
Recent Commits to openclaw:main
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
WordPress大学
WordPress大学
T
Tor Project blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
美团技术团队
P
Proofpoint News Feed
A
Arctic Wolf
C
CERT Recently Published Vulnerability Notes
Last Week in AI
Last Week in AI
T
Tenable Blog
K
Kaspersky official blog
爱范儿
爱范儿
S
Security @ Cisco Blogs
Spread Privacy
Spread Privacy
大猫的无限游戏
大猫的无限游戏
Jina AI
Jina AI
博客园 - 三生石上(FineUI控件)
T
Tailwind CSS Blog
S
SegmentFault 最新的问题
Security Latest
Security Latest
T
The Exploit Database - CXSecurity.com
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
www.infosecurity-magazine.com
www.infosecurity-magazine.com
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Project Zero
Project Zero
W
WeLiveSecurity
L
LINUX DO - 最新话题
Hacker News: Ask HN
Hacker News: Ask HN
阮一峰的网络日志
阮一峰的网络日志
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
C
Cyber Attacks, Cyber Crime and Cyber Security
小众软件
小众软件
Webroot Blog
Webroot Blog
Hacker News - Newest:
Hacker News - Newest: "LLM"
Attack and Defense Labs
Attack and Defense Labs

博客园 - y丶innocence

临时111 RecyclerView 数据多时无法滑动:ConstraintLayout 约束高度修复笔记 Android Kotlin OkHttp3 WebSocket 长连接与 Gson 数据解析系统笔记 Android + Kotlin + OkHttp WebSocket 相关概念与使用流程笔记(TLS/证书 + 鉴权/会话) AI对话导出markdown格式流程 代理转发 分享文件 面向全球的app的excel导出和kotlin IO原理 安卓导出笔记(未整理) java&kotlin listener 0.7 动画 0.4 View 工作流程 13. Jetpack 0. 安卓开发艺术探索参考资料 12. Material Design 7. 持久化技术 5. Fragment java 基础 4. UI 开发 3. Activity 2.2 Kotlin 面向对象 2.3 Kotlin高级 2.1 Kotlin基础 1. Android简介 [OpenJudge] 反正切函数的应用 (枚举)(数学) [OpenJudge] 摘花生 (模拟)
0.3 view 滑动冲突
y丶innocence · 2026-01-20 · via 博客园 - y丶innocence

Android 触摸事件分发与滑动冲突处理 — 完整文档

在 Android 事件分发机制中,拦截顺序是:Activity → ViewGroup → View。

这是一个责任链模式,如果中间某个环节拦截,事件就不会继续向下传递。

  1. 关键函数与调用顺序

当手指触摸屏幕产生一个 ACTION_DOWN 事件时,系统按以下顺序调用:

sequenceDiagram participant A as Activity participant VG as ViewGroup participant V as View Note over A,V: 事件分发开始 (ACTION_DOWN) A->>A: dispatchTouchEvent() A->>VG: 调用 super.dispatchTouchEvent() VG->>VG: onInterceptTouchEvent() alt 不拦截 VG->>V: child.dispatchTouchEvent() V->>V: onTouchEvent() V-->>VG: 返回 true/false else 拦截 VG->>VG: onTouchEvent() end VG-->>A: 返回 true/false

  1. 三个层级的关键函数详解

Activity

· dispatchTouchEvent():事件进入 Android 系统的第一站。通常不重写,默认会传递给根 View(DecorView)。
· 没有 onInterceptTouchEvent,因为 Activity 不是 View,没有拦截概念,它只负责分发。

ViewGroup

· dispatchTouchEvent():核心分发逻辑。决定是遍历子 View 分发,还是交给 onTouchEvent 处理。
· onInterceptTouchEvent():拦截关键。在 dispatchTouchEvent 内部被调用。
· 返回 true:拦截事件,停止向下传递,后续交由本 ViewGroup 的 onTouchEvent 处理。
· 返回 false(默认):不拦截,事件继续向下分发到子 View。

View

· dispatchTouchEvent():View 自己的分发入口。通常会调用 onTouchListener(若存在)或 onTouchEvent。
· onTouchEvent():处理事件的实际逻辑。
· 返回 true:消费事件。
· 返回 false(默认):不消费,事件会回传给父 ViewGroup 的 onTouchEvent。

  1. 特殊规则:ACTION_DOWN 的重要性

事件分发有一个关键机制:一个事件序列(DOWN → MOVE... → UP)由最初消费 DOWN 的 View 全权处理。

· 如果 onInterceptTouchEvent 在 DOWN 事件时返回 false 且子 View 消费了 DOWN,那么后续的 MOVE/UP 事件会直接跳过该 ViewGroup 的 onInterceptTouchEvent,除非子 View 调用 requestDisallowInterceptTouchEvent(true) 改变行为。

  1. 总结流程图
           Activity.dispatchTouchEvent()
                    |
                    ↓
        根 ViewGroup.dispatchTouchEvent()
                    |
        +-----------↓-----------+
        | onInterceptTouchEvent? |
        +-----------↑-----------+
             | false         | true (拦截)
             ↓               ↓
    子 View.dispatch     ViewGroup.onTouchEvent
             |
             ↓
    View.onTouchEvent (消费/不消费)

记住:onInterceptTouchEvent 只在 ViewGroup 中存在,Activity 和 View 都没有这个方法。拦截的决策权完全掌握在父容器手中。

如果你想了解如何通过 requestDisallowInterceptTouchEvent 让子 View 临时夺回控制权,我可以进一步说明。

一、核心知识点总结

1. 事件分发流程(Event Flow)

  • 事件传递路径:Activity → Window (DecorView) → ViewGroup → View
  • 若所有子 View 均未消费事件,最终会回传到 Activity.onTouchEvent()
  • 一个完整事件序列 = 1 个 ACTION_DOWN + 多个 ACTION_MOVE + 1 个 ACTION_UP

2. 关键规则

  • 通常一个事件序列应由同一个 View 消费到底。
  • 若 View 在 ACTION_DOWN 返回 false,则后续事件不会交给它。
  • ViewGroup 默认不拦截事件(onInterceptTouchEvent 返回 false)。
  • 只有当 View 的 clickablelongClickabletrue 时,onTouchEvent 才会返回 true(即消费事件)。
  • OnTouchListener.onTouch() 优先于 onTouchEvent() 被调用;若其返回 true,则 onTouchEvent() 不再执行。
  • 子 View 可通过 parent.requestDisallowInterceptTouchEvent(true/false) 动态控制父容器是否拦截。

3. 滑动冲突类型与解决思路

  • 类型1:外层水平滑动(如 ViewPager),内层垂直滑动(如 ListView)
    → 根据 |Δx| > |Δy| 判断方向,决定由谁拦截。
  • 类型2:内外均为垂直滑动
    → 依赖业务逻辑(如是否滚动到顶部/底部)。
  • 类型3:嵌套上述两种情况。
  • 解决方法
    • 外部拦截法:在父容器的 onInterceptTouchEvent 中判断是否拦截。
    • 内部拦截法:子 View 主动调用 requestDisallowInterceptTouchEvent() 控制父容器行为。只能拦截一次,控制权回到父容器后再也无法拿到

4. 滑动实现方式

  • 三种滑动方式
    1. scrollTo / scrollBy:改变内容位置(非 View 本身)
    2. 属性动画(如 ObjectAnimator.ofFloat(view, "translationX", ...)
    3. 修改 LayoutParams 并调用 requestLayout()
  • 弹性滑动实现
    • 使用 Scroller + computeScroll()(推荐)
    • 使用 ValueAnimator 动态更新 scroll 值
    • 使用 Handler + postDelayed(较少用)

5. 辅助工具

  • VelocityTracker:计算滑动速度(用于 fling 判断)
  • ViewConfiguration.get(context).scaledTouchSlop:系统认定的最小滑动距离(防误触)

二、代码文件(带完整注释)

1. ViewActivity.kt

package com.shakespace.artofandroid.chapter03

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.shakespace.artofandroid.R
import com.shakespace.artofandroid.chapter03.basic.ViewCoordinateActivity
import com.shakespace.artofandroid.chapter03.conflict.ConflictActivity
import com.shakespace.firstlinecode.global.start
import kotlinx.android.synthetic.main.activity_view.*

/**
 * 主入口 Activity,用于跳转到两个子功能模块:
 * - View 坐标系演示
 * - 滑动冲突演示
 */
class ViewActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_view)

        // 点击跳转到坐标系演示页面
        tv_coordinate.setOnClickListener {
            start(ViewCoordinateActivity::class.java)
        }

        // 点击跳转到滑动冲突演示页面
        tv_conflict.setOnClickListener {
            start(ConflictActivity::class.java)
        }
    }
}

2. basic/DemoView.kt

package com.shakespace.artofandroid.chapter03.basic

import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.Scroller

/**
 * 自定义 View,演示三种滑动方式及弹性滑动实现
 */
class DemoView @JvmOverloads constructor(
    context: Context?,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    // Scroller 用于实现平滑滚动(弹性滑动)
    val scroller = Scroller(context)

    /**
     * 使用 Scroller 实现平滑滚动到目标位置
     * 注意:Scroller 本身不执行滚动,需配合 computeScroll()
     */
    fun smoothScrollTo(destX: Int, destY: Int) {
        val deltaX = destX - scrollX
        // 保存滚动参数(起始值、偏移量、持续时间)
        scroller.startScroll(scrollX, scrollY, deltaX, destY - scrollY, 1000)
        // 触发重绘,从而调用 computeScroll()
        invalidate()
    }

    /**
     * 关键方法:在每次绘制前被调用,用于更新滚动位置
     * 若滚动未完成,则继续 scrollTo 并请求重绘
     */
    override fun computeScroll() {
        if (scroller.computeScrollOffset()) {
            // 更新当前滚动位置
            scrollTo(scroller.currX, scroller.currY)
            // 继续触发下一次绘制
            postInvalidate()
        }
    }

    /**
     * 方式1:使用 scrollTo/scrollBy
     * - scrollX = View 左边缘 - 内容左边缘
     * - 向右滑动 → scrollX < 0
     */

    /**
     * 方式2:使用属性动画
     */
    fun moveByAnimation() {
        // 改变 View 的实际位置(视觉位移,不影响布局)
        ObjectAnimator.ofFloat(this, "translationX", 0f, 100f)
            .setDuration(100)
            .start()

        // 使用 ValueAnimator + scrollTo 实现弹性滑动
        val startX = scrollX
        val deltaX = 100
        ValueAnimator.ofInt(0, 100).apply {
            duration = 1000
            addUpdateListener { animator ->
                val fraction = animator.animatedFraction // [0,1]
                scrollTo(startX + (deltaX * fraction).toInt(), 0)
            }
            start()
        }
    }

    /**
     * 方式3:修改 LayoutParams
     */
    fun moveByChangeLayoutParam() {
        // 直接修改宽高(仅作演示,实际中需考虑父容器约束)
        layoutParams.width += 100
        layoutParams.height += 100
        this.layoutParams = layoutParams // 触发 requestLayout()
    }
}

3. basic/EventDemoView.kt

package com.shakespace.artofandroid.chapter03.basic

import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup

/**
 * 自定义 ViewGroup,用于演示事件分发机制
 * 注:此处重写了 dispatchTouchEvent 但最终仍调用 super,仅用于理解流程
 */
class EventDemoView @JvmOverloads constructor(
    context: Context?,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        // 空实现,实际使用需布局子 View
    }

    /**
     * 事件分发入口
     * 正常流程:
     *   - 先调用 onInterceptTouchEvent 判断是否拦截
     *   - 若拦截,则调用自己的 onTouchEvent
     *   - 否则,将事件分发给子 View
     */
    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
        // 示例逻辑(注释掉,实际走默认流程)
        // var consume = false
        // if (onInterceptTouchEvent(ev)) {
        //     consume = onTouchEvent(ev)
        // } else {
        //     consume = child.dispatchTouchEvent(ev)
        // }
        // return consume

        // 实际使用默认分发机制
        return super.dispatchTouchEvent(ev)
    }

    /**
     * 是否拦截事件
     * 默认返回 false(不拦截)
     */
    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
        return super.onInterceptTouchEvent(ev)
    }

    /**
     * 处理触摸事件
     * 若设置了 OnTouchListener,会先调用其 onTouch 方法
     */
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        return super.onTouchEvent(event)
    }

    private val touchListener = object : View.OnTouchListener {
        override fun onTouch(v: View?, event: MotionEvent?): Boolean {
            // TODO: 实现具体逻辑
            return false
        }
    }
}

4. basic/ViewCoordinateActivity.kt

package com.shakespace.artofandroid.chapter03.basic

import android.os.Bundle
import android.util.Log
import android.view.MotionEvent
import android.view.VelocityTracker
import android.view.ViewConfiguration
import androidx.appcompat.app.AppCompatActivity
import com.shakespace.artofandroid.R
import com.shakespace.firstlinecode.global.TAG
import kotlinx.android.synthetic.main.activity_view_coordinate.*

/**
 * 演示 View 坐标系、touchSlop、VelocityTracker 等基础概念
 */
class ViewCoordinateActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_view_coordinate)

        // 显示静态坐标说明
        val memo = """
            left = getLeft()
            right = getRight()
            top = getTop()
            bottom = getBottom()
            width = right - left
            height = bottom - top
        """.trimIndent()
        tv_coordinate.text = memo

        // 显示移动后的坐标关系
        val memo2 = """
            当发生位移时(left/top/right/bottom 不变):
            x = left + translationX
            y = top + translationY
        """.trimIndent()
        tv_coordinate_move.text = memo2

        // 获取系统最小滑动距离(防误触阈值)
        val scaledTouchSlop = ViewConfiguration.get(this).scaledTouchSlop
        val other = "scaledTouchSlop = $scaledTouchSlop"
        tv_memo.text = other

        // 测试 disabled View 的触摸行为
        tv_disable.setOnTouchListener { _, _ ->
            Log.e(TAG, "onCreate: tv_disable onTouch")
            false
        }
        tv_disable.setOnClickListener {
            Log.e(TAG, "onCreate: tv_disable onClick")
        }
    }

    /**
     * 演示 VelocityTracker 的基本用法
     * 注意:此处每次 MOVE 都新建 tracker,实际应复用
     */
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        val velocityTracker = VelocityTracker.obtain()
        when (event?.action) {
            MotionEvent.ACTION_MOVE -> {
                velocityTracker.addMovement(event)
                velocityTracker.computeCurrentVelocity(100) // 100ms 单位
                Log.e(TAG, "onTouchEvent: ${velocityTracker.xVelocity} -- ${velocityTracker.yVelocity}")
            }
        }
        velocityTracker.clear()
        velocityTracker.recycle()
        return super.onTouchEvent(event)
    }
}

5. conflict/ConflictActivity.kt

package com.shakespace.artofandroid.chapter03.conflict

import android.graphics.Color
import android.os.Bundle
import android.view.View
import android.widget.ArrayAdapter
import android.widget.ListView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import com.shakespace.artofandroid.R
import com.shakespace.firstlinecode.global.screenWidth
import kotlinx.android.synthetic.main.activity_confilct.*

/**
 * 滑动冲突演示主界面
 * 创建 3 个页面,每个页面包含一个 ListView
 */
class ConflictActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_confilct)

        // 添加 3 个页面到水平滑动容器
        for (i in 0..2) {
            val view = layoutInflater.inflate(R.layout.confilct_test_layout, horizontal_view, false)
            view.layoutParams.width = screenWidth

            val tvTitle: TextView = view.findViewById(R.id.title)
            tvTitle.text = "Page $i"

            // 设置背景色(避免全白)
            view.setBackgroundColor(Color.rgb(255 / (i + 1), 255 / (i * i + 1), 255 / (i * i * i * i + 1)))

            createList(view)
            horizontal_view.addView(view)
        }
    }

    /**
     * 为每个页面创建 ListView 数据
     */
    private fun createList(view: View) {
        val listView = view.findViewById<ListView>(R.id.list_view)
        val dataList = ArrayList<String>().apply {
            for (i in 0..50) {
                add("item $i")
            }
        }
        val adapter = ArrayAdapter<String>(
            this,
            android.R.layout.simple_list_item_1,
            android.R.id.text1,
            dataList
        )
        listView.adapter = adapter
    }
}

6. conflict/ConflictDemoView.kt

package com.shakespace.artofandroid.chapter03.conflict

import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup

/**
 * 演示两种滑动冲突处理方法:
 * - 方法1:外部拦截(重写 onInterceptTouchEvent)
 * - 方法2:内部拦截(子 View 调用 requestDisallowInterceptTouchEvent)
 */
class ConflictDemoView @JvmOverloads constructor(
    context: Context?,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        // 空实现
    }

    // ==================== 方法2:内部拦截法 ====================
    /**
     * 在子 View 的 dispatchTouchEvent 中控制父容器是否拦截
     * 注意:此方法需配合父容器的 onInterceptTouchEvent 使用
     */
    override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
        when (ev.action) {
            MotionEvent.ACTION_DOWN -> {
                // 允许自己先处理
                requestDisallowInterceptTouchEvent(true)
            }
            MotionEvent.ACTION_MOVE -> {
                // 根据业务逻辑决定是否允许父容器拦截
                // 此处简化为始终允许(实际应判断滑动方向)
                requestDisallowInterceptTouchEvent(false)
            }
            MotionEvent.ACTION_UP -> {
                // 无特殊处理
            }
        }
        return super.dispatchTouchEvent(ev)
    }

    // ==================== 方法1:外部拦截法 ====================
    var flag = true // 控制是否拦截(演示用)
    var lastX: Float = 0f
    var lastY: Float = 0f

    /**
     * 父容器判断是否拦截事件
     */
    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        var intercepted = false
        when (ev.action) {
            MotionEvent.ACTION_DOWN -> {
                intercepted = false // 必须放行 DOWN,否则子 View 收不到
            }
            MotionEvent.ACTION_MOVE -> {
                intercepted = flag // 根据业务逻辑决定
            }
            MotionEvent.ACTION_UP -> {
                intercepted = false // 通常不拦截 UP
            }
        }
        lastX = ev.x
        lastY = ev.y
        return intercepted
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        return super.onTouchEvent(event)
    }

    private val touchListener = object : View.OnTouchListener {
        override fun onTouch(v: View?, event: MotionEvent?): Boolean {
            return false
        }
    }
}

7. conflict/HorizontalScrollViewEx.kt

package com.shakespace.artofandroid.chapter03.conflict

import android.content.Context
import android.util.AttributeSet
import android.util.Log
import android.view.MotionEvent
import android.view.VelocityTracker
import android.view.View
import android.view.ViewGroup
import android.widget.Scroller
import com.shakespace.firstlinecode.global.TAG

/**
 * 自定义水平滑动容器,使用【外部拦截法】处理滑动冲突
 * 适用于 ViewPager + ListView 场景
 */
class HorizontalScrollViewEx @JvmOverloads constructor(
    context: Context?,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {

    private var childrenSize = 0
    private var childWidth = 0
    private var childIndex = 0
    private var lastX = 0
    private var lastY = 0
    private var lastXInIntercept = 0
    private var lastYInIntercept = 0
    private val scroller = Scroller(context)
    private val velocityTracker = VelocityTracker.obtain()

    /**
     * 判断是否拦截事件(外部拦截法核心)
     */
    override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
        var intercepted = false
        val eventX = ev.x.toInt()
        val eventY = ev.y.toInt()

        when (ev.action) {
            MotionEvent.ACTION_DOWN -> {
                // 如果正在滚动中,中断并接管事件
                if (!scroller.isFinished) {
                    scroller.abortAnimation()
                    intercepted = true
                } else {
                    intercepted = false
                }
                lastXInIntercept = eventX
                lastYInIntercept = eventY
            }
            MotionEvent.ACTION_MOVE -> {
                val deltaX = eventX - lastXInIntercept
                val deltaY = eventY - lastYInIntercept
                // 水平滑动为主 → 父容器拦截
                intercepted = Math.abs(deltaX) > Math.abs(deltaY)
            }
            MotionEvent.ACTION_UP -> {
                intercepted = false
            }
        }

        Log.e(TAG, "onInterceptTouchEvent: intercepted=$intercepted")
        lastX = eventX
        lastY = eventY
        return intercepted
    }

    /**
     * 处理触摸事件,实现水平滑动和自动吸附
     */
    override fun onTouchEvent(ev: MotionEvent): Boolean {
        velocityTracker.addMovement(ev)
        val eventX = ev.x.toInt()
        val eventY = ev.y.toInt()

        when (ev.action) {
            MotionEvent.ACTION_DOWN -> {
                if (!scroller.isFinished) {
                    scroller.abortAnimation()
                }
            }
            MotionEvent.ACTION_MOVE -> {
                val deltaX = eventX - lastX
                scrollBy(-deltaX, 0) // 水平滚动
            }
            MotionEvent.ACTION_UP -> {
                velocityTracker.computeCurrentVelocity(1000)
                val xVelocity = velocityTracker.xVelocity

                // 根据速度或位置自动吸附到最近页面
                if (Math.abs(xVelocity) >= 50) {
                    childIndex += if (xVelocity < 0) 1 else -1
                } else {
                    childIndex = (scrollX + childWidth / 2) / childWidth
                }

                childIndex = Math.max(0, Math.min(childIndex, childrenSize - 1))
                val dx = childIndex * childWidth - scrollX
                smoothScrollBy(dx, 0)

                velocityTracker.clear()
            }
        }

        lastX = eventX
        lastY = eventY
        return true
    }

    // --- 测量与布局 ---
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        measureChildren(widthMeasureSpec, heightMeasureSpec)

        val widthSize = MeasureSpec.getSize(widthMeasureSpec)
        val heightSize = MeasureSpec.getSize(heightMeasureSpec)
        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
        val heightMode = MeasureSpec.getMode(heightMeasureSpec)

        if (childCount == 0) {
            setMeasuredDimension(0, 0)
            return
        }

        val firstChild = getChildAt(0)
        val childWidth = firstChild.measuredWidth
        val childHeight = firstChild.measuredHeight

        when {
            widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST ->
                setMeasuredDimension(childWidth * childCount, childHeight)
            heightMode == MeasureSpec.AT_MOST ->
                setMeasuredDimension(widthSize, childHeight)
            widthMode == MeasureSpec.AT_MOST ->
                setMeasuredDimension(childWidth * childCount, heightSize)
        }
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        var childLeft = 0
        childrenSize = childCount
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            if (child.visibility != View.GONE) {
                childWidth = child.measuredWidth
                child.layout(childLeft, 0, childLeft + childWidth, child.measuredHeight)
                childLeft += childWidth
            }
        }
    }

    private fun smoothScrollBy(dx: Int, dy: Int) {
        scroller.startScroll(scrollX, scrollY, dx, dy, 500)
        invalidate()
    }

    override fun computeScroll() {
        if (scroller.computeScrollOffset()) {
            scrollTo(scroller.currX, scroller.currY)
            postInvalidate()
        }
    }

    override fun onDetachedFromWindow() {
        velocityTracker.recycle()
        super.onDetachedFromWindow()
    }
}

8. conflict/ListViewEx.kt

package com.shakespace.artofandroid.chapter03.conflict

import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.widget.ListView

/**
 * 自定义 ListView,使用【内部拦截法】处理滑动冲突
 * 在 dispatchTouchEvent 中主动控制父容器是否拦截
 */
class ListViewEx @JvmOverloads constructor(
    context: Context?,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ListView(context, attrs, defStyleAttr) {

    private var lastX = 0
    private var lastY = 0

    override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
        val eventX = ev.x.toInt()
        val eventY = ev.y.toInt()

        when (ev.action) {
            MotionEvent.ACTION_DOWN -> {
                // 先允许自己处理事件
                parent.requestDisallowInterceptTouchEvent(true)
            }
            MotionEvent.ACTION_MOVE -> {
                val deltaX = eventX - lastX
                val deltaY = eventY - lastY
                // 如果是垂直滑动,禁止父容器拦截;否则允许
                val disallowIntercept = Math.abs(deltaX) <= Math.abs(deltaY)
                parent.requestDisallowInterceptTouchEvent(disallowIntercept)
            }
            MotionEvent.ACTION_UP -> {
                // 无特殊处理
            }
        }

        lastX = eventX
        lastY = eventY
        return super.dispatchTouchEvent(ev)
    }
}

注:HorizontalScrollViewEx2.ktHorizontalScrollViewEx.kt 逻辑高度重复,且存在逻辑缺陷(onInterceptTouchEvent 在非 DOWN 事件直接返回 true),故未单独列出。建议以 HorizontalScrollViewEx.kt 为准。

三、文档笔记

z_memo01.txt

1. Event Flow
   Activity --> Window --> View
   if all views not handle the event, will back to Activity

   /**
    * Called when a touch screen event was not handled by any of the views
    * under it.  This is most useful to process touch events that happen
    * outside of your window bounds, where there is no view to receive it.
    *
    * @param event The touch screen event being processed.
    *
    * @return Return true if you have consumed the event, false if you haven't.
    * The default implementation always returns false.
    */
   public boolean onTouchEvent(MotionEvent event) {
       if (mWindow.shouldCloseOnTouch(this, event)) {
           finish();
           return true;
       }
       return false;
   }

   @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023)
   public boolean shouldCloseOnTouch(Context context, MotionEvent event) {
       final boolean isOutside =
               event.getAction() == MotionEvent.ACTION_UP && isOutOfBounds(context, event)
               || event.getAction() == MotionEvent.ACTION_OUTSIDE;
       if (mCloseOnTouchOutside && peekDecorView() != null && isOutside) {
           return true;
       }
       return false;
   }

2. An Event Sequence = a Down + many Move + a Up  
   (事件序列,由一个按下,多个移动,一个抬起组成)

3. Normally, an event should be consumed only by one View  
   (通常一个序列应该由一个 view 处理)

4. In an event sequence, if a view calls onInterceptTouchEvent, then the rest of the event sequence will all be handled by this view and onInterceptTouchEvent will not be called again.

5. If a view returns false in Down Event, then the rest events will not be handled by this view  
   (如果一个 view 没有处理 DOWN,那么也不会处理后续)

6. ViewGroup will not intercept any event by default → onInterceptTouchEvent returns false  
   (ViewGroup 默认不会拦截)

7. If a view clickable = false and longClickable = false, then onTouchEvent will return false.

8. Can call requestDisallowInterceptTouchEvent in child view.

z_memo02 source code.txt

1. Activity
   public boolean dispatchTouchEvent(MotionEvent ev) {
       if (ev.getAction() == MotionEvent.ACTION_DOWN) {
           onUserInteraction(); // empty method
       }
       if (getWindow().superDispatchTouchEvent(ev)) {
           return true;
       }
       return onTouchEvent(ev);
   }

2. PhoneWindow
   @Override
   public boolean superDispatchTouchEvent(MotionEvent event) {
       return mDecor.superDispatchTouchEvent(event);
   }
   // This is the top-level view of the window, containing the window decor.
   private DecorView mDecor;

3. com.android.internal.policy.DecorView  // hide
   /** @hide */
   public class DecorView extends FrameLayout implements RootViewSurfaceTaker, WindowCallbacks {
       private static final String TAG = "DecorView";
       ....
   }

4. ViewGroup: dispatchTouchEvent
   final boolean intercepted;
   if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) {
       final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
       if (!disallowIntercept) {
           intercepted = onInterceptTouchEvent(ev);
           ev.setAction(action); // restore action in case it was changed
       } else {
           intercepted = false;
       }
   }

   - mFirstTouchTarget: if there is a view that has handled the previous event, mFirstTouchTarget would not be null.
   - disallowIntercept: set by requestDisallowInterceptTouchEvent.
     (Except for DOWN event, ViewGroup will call onInterceptTouchEvent in each DOWN event because it resets FLAG_DISALLOW_INTERCEPT.)

   // 请求父类不要拦截:如果 disallowIntercept is true, parent does not intercept → intercepted = false
   // Note: onInterceptTouchEvent may not be called in an entire event sequence.

5. If not intercepted:
   for (int i = childrenCount - 1; i >= 0; i--) {
       final int childIndex = getAndVerifyPreorderedIndex(childrenCount, i, customOrder);
       final View child = getAndVerifyPreorderedView(preorderedList, children, childIndex);

       if (childWithAccessibilityFocus != null) {
           if (childWithAccessibilityFocus != child) {
               continue;
           }
           childWithAccessibilityFocus = null;
           i = childrenCount - 1;
       }

       if (!child.canReceivePointerEvents()
               || !isTransformedTouchPointInView(x, y, child, null)) {
           ev.setTargetAccessibilityFocus(false);
           continue;
       }

       newTouchTarget = getTouchTarget(child);
       if (newTouchTarget != null) {
           newTouchTarget.pointerIdBits |= idBitsToAssign;
           break;
       }

       resetCancelNextUpFlag(child);
       if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
           mLastTouchDownTime = ev.getDownTime();
           if (preorderedList != null) {
               for (int j = 0; j < childrenCount; j++) {
                   if (children[childIndex] == mChildren[j]) {
                       mLastTouchDownIndex = j;
                       break;
                   }
               }
           } else {
               mLastTouchDownIndex = childIndex;
           }
           mLastTouchDownX = ev.getX();
           mLastTouchDownY = ev.getY();
           newTouchTarget = addTouchTarget(child, idBitsToAssign);  // set touchTarget
           alreadyDispatchedToNewTouchTarget = true;
           break;
       }
       ev.setTargetAccessibilityFocus(false);
   }

   // 可见性 和 动画,结合点击点是否在区域内
   // 当 view 可见时,返回 true;当 view 不可见,若设置了动画,也返回 true(因为动画可能使其隐藏??)
   protected boolean canReceivePointerEvents() {
       return (mViewFlags & VISIBILITY_MASK) == VISIBLE || getAnimation() != null;
   }

   private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
                                                View child, int desiredPointerIdBits) {
       final boolean handled;
       final int oldAction = event.getAction();
       if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
           event.setAction(MotionEvent.ACTION_CANCEL);
           if (child == null) {
               handled = super.dispatchTouchEvent(event); // → View.dispatchTouchEvent(ev)
           } else {
               handled = child.dispatchTouchEvent(event);
           }
           event.setAction(oldAction);
           return handled;
       }
   }

6. In View
   if (onFilterTouchEventForSecurity(event)) {
       if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
           result = true;
       }
       ListenerInfo li = mListenerInfo;
       if (li != null && li.mOnTouchListener != null
               && (mViewFlags & ENABLED_MASK) == ENABLED
               && li.mOnTouchListener.onTouch(this, event)) {
           result = true;
       }
       if (!result && onTouchEvent(event)) {
           result = true;
       }
   }

   - Will call mOnTouchListener.onTouch first.
   - onTouchEvent(ev)

   final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE
           || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
           || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE;

   if ((viewFlags & ENABLED_MASK) == DISABLED) {
       if (action == MotionEvent.ACTION_UP && (mPrivateFlags & PFLAG_PRESSED) != 0) {
           setPressed(false);
       }
       mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
       // A disabled view that is clickable still consumes the touch events, it just doesn't respond to them.
       return clickable;
   }

   if (mTouchDelegate != null) {
       if (mTouchDelegate.onTouchEvent(event)) {
           return true;
       }
   }

   // 可点击 / 长按 / 响应鼠标(或触控笔)就可以消费点击事件
   if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
       ....
   }
   // will return true.

   So: if view is disabled, onTouchListener won't work, but onTouchEvent still gets called.
   Returns true if clickable / longClickable / contextClickable / or (viewFlags & TOOLTIP) == TOOLTIP.

7. In addition:
   public void setOnClickListener(@Nullable OnClickListener l) {
       if (!isClickable()) {
           setClickable(true);
       }
       getListenerInfo().mOnClickListener = l;
   }

   public void setOnLongClickListener(@Nullable OnLongClickListener l) {
       if (!isLongClickable()) {
           setLongClickable(true);
       }
       getListenerInfo().mOnLongClickListener = l;
   }

z_memo03 scroll conflict.txt

处理滑动冲突:

Case 1. Outer horizontal, Inner vertical  
        (e.g., ViewPager + Fragment with ListView)
   - When MOVE is horizontal → OUTER should intercept the event.
   - When MOVE is vertical → INNER should intercept the event.
   - Check using |Δx| > |Δy|.

Case 2. Outer vertical, Inner vertical  
        Depends on business logic (e.g., whether inner is at top/bottom).

Case 3. Nested Case 1 and Case 2.

z_memo04滑动和弹性滑动.txt

// TODO 补充滑动和弹性滑动

1. 滑动的三种方式:
   - scrollTo / scrollBy
   - 属性动画(ObjectAnimator / ValueAnimator)
   - 修改 LayoutParams 并调用 requestLayout()

2. 弹性滑动的三种方式:
   - Scroller + computeScroll()
   - ValueAnimator + update listener
   - Handler + postDelayed(不推荐)
object PeopleCountUtils {
    private var lastX: Int = 0
    private var lastY: Int = 0
    private var eventX: Int = 0
    private var eventY: Int = 0
    private var startX: Int = 0
    private var startY: Int = 0
    fun fixPeopleCountChartViewScroll(view: BarLineChartBase<*>?) {
        if (view == null || view.parent == null) return
        view.onTouchListener =
            (object : BarLineChartTouchListener(view, view.viewPortHandler.matrixTouch, 3f) {
                override fun onTouch(v: View, event: MotionEvent): Boolean {
                    eventX = event.x.toInt()
                    eventY = event.y.toInt()
                    when (event.action) {
                        MotionEvent.ACTION_DOWN -> {
                            startX = event.x.toInt()
                            startY = event.y.toInt()
                            v.parent.requestDisallowInterceptTouchEvent(true)
                        }

                        MotionEvent.ACTION_MOVE -> {
                            // 累积
//                            val deltaX = eventX - startX
//                            val deltaY = eventY - startY

                            // 上次
                            val deltaX = eventX - lastX
                            val deltaY = eventY - lastY
                            // 如果是垂直滑动,禁止父容器拦截;否则允许
                            val disallowIntercept = abs(deltaX) <= abs(deltaY)
                            v.parent.requestDisallowInterceptTouchEvent(disallowIntercept)
                        }

                        MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
                            v.parent.requestDisallowInterceptTouchEvent(false)
                        }
                    }
                    lastX = eventX
                    lastY = eventY
                    return super.onTouch(v, event)
                }
            })

    }
}