定制视图与触摸事件
定制视图与触摸事件:保留第四版正文机制,以所有者—状态—结果合同、事件轨迹和章专属故障完成可重放验收。
学习目标
- 能沿“定制视图与触摸事件”的用户事件解释Android组件、状态所有者、线程与销毁边界。
- 能围绕“从零实现自定义 View——掌握 onMeasure/onDraw/onTouchEvent 三大核心、Canvas 绘图 API、触摸手势处理与自定义属性。”改出一个可运行结果,并用前后状态而非组件数量验收。
- 能在旋转、进程重建、拒权、离线或无效输入中选择适用反例,定位首个状态分叉。
- 能用输入序列、模型状态、渲染边界、取消和reduced-motion终态独立重放结论,并标明第四版机制与现代targetSdk政策的边界。
从零造轮子——为什么要写自定义 View
Android 自带的控件(Button、TextView、ImageView)能覆盖 80% 的需求。但当你需要做一个圆形进度条、一个可拖拽的色温调节器、一个手写签名区域——这些都没有现成的控件。答案只有一个:自己画。
自定义 View 是"造轮子"的技能。你不再依赖 XML 拼控件,而是直接跟 Canvas 打交道——想画什么就画什么,想怎么响应触摸就怎么响应。这是一个 Android 开发者从"组装工"变成"制造者"的分水岭。
用画布与画笔的类比:Android 的 View 系统就像给了你一张画布(Canvas)和一套画笔(Paint),你决定在上面画什么——圆形、矩形、文字、路径——然后决定用户的手指碰到画布时发生什么。
① 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 轴向下。
↡保存绘图属性的对象——颜色、样式(FILL/STROKE)、线宽、字号、抗锯齿等对象控制"怎么画"。每次改变 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)
}第 1 / 4 步 · ① ACTION_DOWN:手指按下,记录起点 (x, y)。onTouchEvent 返回 true 消费手势,后续 MOVE / UP 才会继续回调到本 View
点击播放,看一次「按下 → 拖动 → 拖动 → 抬起」被拆成一串 MotionEvent 回调;可暂停、单步、拖进度逐帧观察。
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、设备配置和初始状态,触发“按下、移动、抬起、多指、旋转与键盘替代操作”
冻结入口: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
定制视图与触摸事件:事件轨迹
不变量:每个手势只提交一个合法 box,重绘和状态恢复不依赖旧 Canvas
交付证据:MotionEvent 序列、pointer ID、box 快照、invalidate 区域和语义树
实验三:章专属反例与同输入恢复
注入“忽略 pointer ID,多指切换后 box 突然跳到另一根手指”,保存第一个偏离点;撤销后以完全相同的 SDK、设备状态和用户事件重放。只有MotionEvent 序列、pointer ID、box 快照、invalidate 区域和语义树一起恢复才算修复。
Fault · cancel · restore
定制视图与触摸事件:反例与恢复
故障:忽略 pointer ID,多指切换后 box 突然跳到另一根手指
第 1 次使用相同 SDK、设备配置、初始状态与用户事件
保持正常输入不变,仅注入“忽略 pointer ID,多指切换后 box 突然跳到另一根手指”
每个手势只提交一个合法 box,重绘和状态恢复不依赖旧 Canvas
MotionEvent 序列、pointer ID、box 快照、invalidate 区域和语义树