定制视图与触摸事件

定制视图与触摸事件:保留第四版正文机制,以所有者—状态—结果合同、事件轨迹和章专属故障完成可重放验收。

学习目标

  • 能沿“定制视图与触摸事件”的用户事件解释Android组件、状态所有者、线程与销毁边界。
  • 能围绕“从零实现自定义 View——掌握 onMeasure/onDraw/onTouchEvent 三大核心、Canvas 绘图 API、触摸手势处理与自定义属性。”改出一个可运行结果,并用前后状态而非组件数量验收。
  • 能在旋转、进程重建、拒权、离线或无效输入中选择适用反例,定位首个状态分叉。
  • 能用输入序列、模型状态、渲染边界、取消和reduced-motion终态独立重放结论,并标明第四版机制与现代targetSdk政策的边界。

从零造轮子——为什么要写自定义 View

Android 自带的控件(Button、TextView、ImageView)能覆盖 80% 的需求。但当你需要做一个圆形进度条、一个可拖拽的色温调节器、一个手写签名区域——这些都没有现成的控件。答案只有一个:自己画

自定义 View 是"造轮子"的技能。你不再依赖 XML 拼控件,而是直接跟 Canvas 打交道——想画什么就画什么,想怎么响应触摸就怎么响应。这是一个 Android 开发者从"组装工"变成"制造者"的分水岭。

画布与画笔的类比:Android 的 View 系统就像给了你一张画布(Canvas)和一套画笔(Paint),你决定在上面画什么——圆形、矩形、文字、路径——然后决定用户的手指碰到画布时发生什么。

分步1 / 3

① onMeasure 测量尺寸

父容器通过 MeasureSpec(包含模式 EXACTLY/AT_MOST/UNSPECIFIED 和尺寸值)将约束传给子 View。你需要覆写 onMeasure(),解析父容器的约束,计算自己需要的宽高,最后必须调用 setMeasuredDimension() 将结果上报——否则系统会抛出 IllegalStateException

1. 自定义 View 的三板斧:测量、绘制、触摸

每个自定义 View 都围绕三个核心方法:

方法职责类比
onMeasure()测量自己应该多大决定画布尺寸
onDraw()把自己画出来在画布上作画
onTouchEvent()响应用户触摸处理手指在画布上的动作

执行顺序:onMeasure()onLayout()onDraw()。父容器先 measure 孩子,再 layout 孩子,最后 draw 孩子。

onMeasure——决定自己有多大

Android 用 <Term def="父容器传递给子 View 的尺寸约束模式:精确值(EXACTLY)、上限(AT_MOST)、无限制(UNSPECIFIED)">MeasureSpec</Term> 来描述父子之间的尺寸协商。每个 MeasureSpec 包含两个信息:模式(mode)尺寸(size)

三种模式:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    // 1. 解析父容器给的约束
    val widthMode = MeasureSpec.getMode(widthMeasureSpec)
    val widthSize = MeasureSpec.getSize(widthMeasureSpec)
    val heightMode = MeasureSpec.getMode(heightMeasureSpec)
    val heightSize = MeasureSpec.getSize(heightMeasureSpec)
 
    // 2. 根据模式决定自己的尺寸
    val desiredWidth = when (widthMode) {
        MeasureSpec.EXACTLY -> widthSize         // 精确值:如 match_parent 或具体 dp
        MeasureSpec.AT_MOST -> min(200, widthSize) // 上限:如 wrap_content
        MeasureSpec.UNSPECIFIED -> 200            // 无限制:如 ScrollView 内的 View
        else -> 200
    }
    val desiredHeight = 200  // 简化:固定高度
 
    // 3. 必须调用 setMeasuredDimension
    setMeasuredDimension(desiredWidth, desiredHeight)
}

onDraw——在 Canvas 上画一切

onDraw(canvas: Canvas) 是自定义 View 的灵魂。Canvas 提供了完整的 2D 绘图 API:

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
 
    val paint = Paint().apply {
        isAntiAlias = true  // 抗锯齿,让边缘平滑
    }
 
    // 1. 画矩形
    paint.color = Color.BLUE
    paint.style = Paint.Style.FILL
    canvas.drawRect(50f, 50f, 150f, 150f, paint)
 
    // 2. 画圆形
    paint.color = Color.RED
    paint.style = Paint.Style.STROKE  // 空心
    paint.strokeWidth = 8f
    canvas.drawCircle(300f, 100f, 60f, paint)
 
    // 3. 画文字
    paint.color = Color.BLACK
    paint.style = Paint.Style.FILL
    paint.textSize = 48f
    canvas.drawText("Hello View", 50f, 250f, paint)
 
    // 4. 画路径(直线 + 圆弧)
    paint.color = Color.GREEN
    paint.style = Paint.Style.STROKE
    paint.strokeWidth = 5f
    val path = Path().apply {
        moveTo(50f, 300f)
        lineTo(200f, 300f)
        lineTo(200f, 400f)
    }
    canvas.drawPath(path, paint)
 
    // 5. 画图片
    val bitmap = BitmapFactory.decodeResource(resources, R.drawable.ic_launcher)
    canvas.drawBitmap(bitmap, 300f, 250f, paint)
}

的方法全部"画在"View 的坐标系里——原点 (0, 0) 在 View 的左上角,x 轴向右,y 轴向下。

对象控制"怎么画"。每次改变 Paint 属性后,后续的 draw 调用都使用新属性。

坐标系变换

Canvas 支持平移、旋转、缩放——这对于做图表、仪表盘非常有用:

// 把原点平移到 View 中心
canvas.save()                       // 保存当前状态
canvas.translate(width / 2f, height / 2f)
canvas.rotate(45f)                  // 旋转 45 度
paint.color = Color.BLUE
canvas.drawRect(-50f, -50f, 50f, 50f, paint)  // 以新原点为中心画矩形
canvas.restore()                    // 恢复之前的状态

save() / restore() 是一对——save() 把当前变换矩阵和裁剪区域压入栈,restore() 恢复到压栈时的状态。

2. 触摸事件处理——让 View "活"起来

MotionEvent 基础

当手指触碰到 View 时,Android 调用 onTouchEvent(event: MotionEvent)。每个事件包含:

  • 动作类型event.action——ACTION_DOWN(按下)、ACTION_MOVE(移动)、ACTION_UP(抬起)
  • 坐标event.x / event.y——相对于当前 View 左上角的坐标
override fun onTouchEvent(event: MotionEvent): Boolean {
    val x = event.x
    val y = event.y
 
    when (event.action) {
        MotionEvent.ACTION_DOWN -> {
            // 手指按下:记录起始位置
            startX = x
            startY = y
            return true  // 返回 true 表示"我要处理这个事件",后续的事件也会发过来
        }
        MotionEvent.ACTION_MOVE -> {
            // 手指移动:计算偏移量
            dx = x - startX
            dy = y - startY
            invalidate()  // 触发重绘
        }
        MotionEvent.ACTION_UP -> {
            // 手指抬起:处理点击或动作结束
            performClick()
        }
    }
    return super.onTouchEvent(event)
}
可交互
dispatchTouchEvent → onInterceptTouchEvent → onTouchEventView 画布(onTouchEvent 返回 true 才能收到后续事件)01020304当前 MotionEventACTION_DOWNACTION_MOVEACTION_UPevent.x / event.y(56, 196)(168, 96)(276, 152)(320, 56)▲ 手指按下

第 1 / 4 步 · ① ACTION_DOWN:手指按下,记录起点 (x, y)。onTouchEvent 返回 true 消费手势,后续 MOVE / UP 才会继续回调到本 View

点击播放,看一次「按下 → 拖动 → 拖动 → 抬起」被拆成一串 MotionEvent 回调;可暂停、单步、拖进度逐帧观察。

触摸事件序列:一次手势 = 一个 ACTION_DOWN + 若干 ACTION_MOVE + 一个 ACTION_UP。 DOWN 时 onTouchEvent 必须返回 true 消费事件,否则后续 MOVE / UP 不会再发来; 事件先经 dispatchTouchEvent → onInterceptTouchEvent 分发,才落到 onTouchEvent。

GestureDetector——识别常见手势

自己从 MotionEvent 里识别双击、长按、快速滑动,太麻烦了。Android 提供了 GestureDetector

class MyCustomView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
 
    private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
        override fun onDown(e: MotionEvent): Boolean {
            return true  // 必须返回 true,否则所有手势都不触发
        }
 
        override fun onSingleTapUp(e: MotionEvent): Boolean {
            // 单击
            showToast("单击了")
            return true
        }
 
        override fun onDoubleTap(e: MotionEvent): Boolean {
            // 双击
            showToast("双击了")
            return true
        }
 
        override fun onLongPress(e: MotionEvent) {
            // 长按
            showToast("长按了")
        }
 
        override fun onFling(
            e1: MotionEvent?, e2: MotionEvent?,
            velocityX: Float, velocityY: Float
        ): Boolean {
            // 快速滑动
            if (abs(velocityX) > abs(velocityY)) {
                showToast("水平快速滑动")
            }
            return true
        }
 
        override fun onScroll(
            e1: MotionEvent?, e2: MotionEvent?,
            distanceX: Float, distanceY: Float
        ): Boolean {
            // 拖动
            scrollX += distanceX.toInt()
            invalidate()
            return true
        }
    })
 
    override fun onTouchEvent(event: MotionEvent): Boolean {
        // 先把事件传给 GestureDetector,再处理自己的逻辑
        val handled = gestureDetector.onTouchEvent(event)
        return handled || super.onTouchEvent(event)
    }
}

3. 自定义属性——让 View 可以像原生控件一样在 XML 配置

res/values/attrs.xml 中声明自定义属性:

<declare-styleable name="MyCustomView">
    <attr name="circleColor" format="color" />
    <attr name="circleRadius" format="dimension" />
    <attr name="labelText" format="string" />
</declare-styleable>

在 View 的构造方法中读取:

class MyCustomView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
    var circleColor: Int = Color.BLUE
    var circleRadius: Float = 60f
    var labelText: String = ""
 
    init {
        context.obtainStyledAttributes(attrs, R.styleable.MyCustomView).apply {
            circleColor = getColor(R.styleable.MyCustomView_circleColor, Color.BLUE)
            circleRadius = getDimension(R.styleable.MyCustomView_circleRadius, 60f)
            labelText = getString(R.styleable.MyCustomView_labelText) ?: ""
            recycle()  // 用完后记得 recycle,否则内存泄漏
        }
    }
}

布局中这样使用:

<com.example.MyCustomView
    android:layout_width="200dp"
    android:layout_height="200dp"
    app:circleColor="#FF0000"
    app:circleRadius="40dp"
    app:labelText="自定义" />

4. requestLayout vs invalidate——什么时候该调用哪个

这是一个很容易搞混的问题:

方法触发流程何时调用
requestLayout()onMeasure() → onLayout() → onDraw()View 的尺寸位置发生变化
invalidate()只触发 onDraw()只有视觉内容变化,尺寸没变

容易踩的坑

小结

  • 自定义 View 三大核心:onMeasure(测量尺寸)、onDraw(绘制内容)、onTouchEvent(处理触摸)
  • MeasureSpec 三种模式:EXACTLY(精确值)、AT_MOST(上限/wrap_content)、UNSPECIFIED(无限制)
  • Canvas API 提供 drawRect/drawCircle/drawText/drawPath/drawBitmap;Paint 控制颜色、样式、线宽等
  • GestureDetector 封装了单击、双击、长按、快速滑动等常见手势识别
  • 自定义属性通过 declare-styleable 声明,让 View 在 XML 里像原生控件一样配置
  • requestLayout() 触发布局重测(尺寸变了)→ 重;invalidate() 只触发重绘(视觉变了)→ 快

练习

问题 1:“第30章 Custom Views and Touch Events”覆盖哪些正式节点和项目主线?

问题 2:怎样建立本页最小可执行实验?

问题 3:为什么只在正常点击路径运行不能证明完成?

问题 4:怎样设计能推翻当前实现的反例?

问题 5:从第4版语境迁移到现代目标SDK时如何控制变量?

问题 6:本页达到独立交接标准需要什么?

名词解释

本章出现的专业名词,用大白话再讲一遍。

MeasureSpec

父容器传给子 View 的尺寸约束。包含模式(EXACTLY/AT_MOST/UNSPECIFIED)和尺寸值。EXACTLY 对应 match_parent 或具体 dp;AT_MOST 对应 wrap_content。

Canvas

Android 2D 绘图的画布。提供 drawRect、drawCircle、drawText、drawPath、drawBitmap 等方法。支持 save/restore 保存和恢复变换状态。

Paint

画笔——保存绘制属性:颜色、样式(FILL/STROKE)、线宽、字号、抗锯齿开关等。每次修改 Paint 属性后,后续的 Canvas 绘制调用均使用新设置。

GestureDetector

手势识别器。封装了常见的触摸手势判断——单击 (onSingleTapUp)、双击 (onDoubleTap)、长按 (onLongPress)、快速滑动 (onFling)、拖动 (onScroll)。简化了从原始 MotionEvent 中识别手势的工作。

MotionEvent

描述一次触摸事件的对象。包含动作类型(ACTION_DOWN/MOVE/UP/CANCEL)和当前触摸点的坐标(x, y)。一次完整的点击序列从 DOWN 开始,中间可能经过若干 MOVE,最后以 UP 结束。

“定制视图与触摸事件”不使用未获授权的纸书正文;InformIT出版信息授权电子版完整目录只用于确认第四版32章、269个正式目录节点和时代语境,第四版官方勘误用于识别工具链变更。下列中文解释、图示、交互、代码与练习均为独立教学重写,平台行为再以Android Developers的一手文档复核。

为什么“定制视图与触摸事件”必须回到可观察状态

“定制视图与触摸事件”的学习结果不是记住类名,而是能预测“从零实现自定义 View——掌握 onMeasure/onDraw/onTouchEvent 三大核心、Canvas 绘图 API、触摸手势处理与自定义属性。”在一次输入、一次重建和一次失败中的不同状态,并指出哪条Android合同产生差异。

第四版机制逐项深读

30. Custom Views and Touch Events

在“定制视图与触摸事件”中,分析“30. Custom Views and Touch Events”跟踪pointerId、actionMasked、坐标空间和父容器拦截,不能把数组索引当稳定手指标识。

Setting Up the DragAndDraw Project

在“定制视图与触摸事件”中,验证“Setting Up the DragAndDraw Project”只改变一个生命周期或外部条件,保存操作、原始日志、状态快照和用户可见断言。

Creating a Custom View

在“定制视图与触摸事件”中,“Creating a Custom View”把MotionEvent序列归约为模型,再由onDraw读取模型绘制;渲染函数不应偷偷改变业务状态。

Handling Touch Events

在“定制视图与触摸事件”中,分析“Handling Touch Events”跟踪pointerId、actionMasked、坐标空间和父容器拦截,不能把数组索引当稳定手指标识。

Rendering Inside onDraw(Canvas)

在“定制视图与触摸事件”中,验证“Rendering Inside onDraw(Canvas)”覆盖多指、取消、越界、旋转和辅助操作,比较模型框、Canvas输出与可访问性节点。

For the More Curious: GestureDetector

在“定制视图与触摸事件”中,“For the More Curious: GestureDetector”把MotionEvent序列归约为模型,再由onDraw读取模型绘制;渲染函数不应偷偷改变业务状态。

Challenge: Saving State

在“定制视图与触摸事件”中,“Challenge: Saving State”用于推翻“从零实现自定义 View——掌握 onMeasure/onDraw/onTouchEvent 三大核心、Canvas 绘图 API、触摸手势处理与自定义属性。”的顺利路径:先写预期,再引入一个边界输入、重建或平台差异,并用状态、日志与用户结果解释首个分叉。

Challenge: Rotating Boxes

在“定制视图与触摸事件”中,验证“Challenge: Rotating Boxes”依次执行覆盖、返回、旋转与进程回收,比较旧实例、新实例和用户可见状态。

Challenge: Accessibility Support

在“定制视图与触摸事件”中,验证“Challenge: Accessibility Support”在大字体、触摸探索和无视觉条件下完成同一任务,并记录首个焦点陷阱或缺失播报。

“定制视图与触摸事件”验收回顾

“定制视图与触摸事件”只有在输入序列、模型状态、渲染边界、取消和reduced-motion终态能够从相同基线再次得到相同断言时才通过;第四版机制与现代平台政策分别记录,不用新API名称掩盖旧行为。

← 上一页:网页浏览 · 下一页:属性动画 →

章专属可重放状态实验

先预测“按下、移动、抬起、多指、旋转与键盘替代操作”发生后,BoxDrawingView、手势状态机与 saved state应怎样改变pointer ID、起止坐标、box 列表、绘制区域和语义节点;再操作三个实验。第四版示例与当前 Android 政策分别记录,实验不把新 API 名称倒填为原书内容。

实验一:所有者—状态—结果合同

选择任一正式目录节点和正常/边界场景,检查它是否真的进入本章状态合同。目录标题只有同时出现在解释、可视状态和交付证据中才算覆盖。

Owner · state · observable result

定制视图与触摸事件:状态合同

把 pointer 手势转换成 Box 几何,同时控制重绘、保存状态与无障碍操作

验证场景

第四版正式目录节点

custom-views-touch · 正常任务

30. Custom Views and Touch Events固定 SDK、设备配置和初始状态,触发“按下、移动、抬起、多指、旋转与键盘替代操作”

状态所有者BoxDrawingView、手势状态机与 saved state
受控状态pointer ID、起止坐标、box 列表、绘制区域和语义节点
触发事件按下、移动、抬起、多指、旋转与键盘替代操作

冻结入口:30. Custom Views and Touch Events

记录BoxDrawingView、手势状态机与 saved state的初始pointer ID、起止坐标、box 列表、绘制区域和语义节点

观察:MotionEvent 序列、pointer ID、box 快照、invalidate 区域和语义树中的“30. Custom Views and Touch Events”轨迹

预期:由BoxDrawingView、手势状态机与 saved state提交pointer ID、起止坐标、box 列表、绘制区域和语义节点,并持续满足“每个手势只提交一个合法 box,重绘和状态恢复不依赖旧 Canvas”

实验二:事件与生命周期轨迹

沿五次转换逐步执行“按下、移动、抬起、多指、旋转与键盘替代操作”。每一步只允许BoxDrawingView、手势状态机与 saved state按职责提交状态,并持续核对“每个手势只提交一个合法 box,重绘和状态恢复不依赖旧 Canvas”。

Deterministic event replay

定制视图与触摸事件:事件轨迹

选择一次状态转换1 / 5

不变量:每个手势只提交一个合法 box,重绘和状态恢复不依赖旧 Canvas

交付证据:MotionEvent 序列、pointer ID、box 快照、invalidate 区域和语义树

实验三:章专属反例与同输入恢复

注入“忽略 pointer ID,多指切换后 box 突然跳到另一根手指”,保存第一个偏离点;撤销后以完全相同的 SDK、设备状态和用户事件重放。只有MotionEvent 序列、pointer ID、box 快照、invalidate 区域和语义树一起恢复才算修复。

Fault · cancel · restore

定制视图与触摸事件:反例与恢复

故障:忽略 pointer ID,多指切换后 box 突然跳到另一根手指

1. 冻结输入一致

第 1 次使用相同 SDK、设备配置、初始状态与用户事件

2. 注入边界一致

保持正常输入不变,仅注入“忽略 pointer ID,多指切换后 box 突然跳到另一根手指”

3. 检查所有者一致

每个手势只提交一个合法 box,重绘和状态恢复不依赖旧 Canvas

4. 核对结果一致

MotionEvent 序列、pointer ID、box 快照、invalidate 区域和语义树

讨论

评论区加载中…