自定义 View 真正的坑,几乎都来自坐标系和事件流没对齐。
你写了一个自定义 View,布局里量得好好的尺寸,真机上却对不齐;你做了一个侧滑删除,发现手指一碰,点击和滑动就打架,菜单还和上一行同时开着;你实现了"点输入框外面收起键盘",结果键盘弹起后,明明点在了输入框外,键盘却死活不收。
这三件事看起来互不相干,但其实共用同一个根:自定义 View 的复杂度,从来不在绘制那几个 API 有多难,而在你拿到的坐标、收到的事件,到底属于哪一套坐标系、哪一条事件流。本文把三个真实控件拆开讲——电量图标讲"测量到绘制"的对齐,侧滑删除讲"触摸分发"的博弈,键盘收起讲"坐标错位"的坑。读完你会发现,三关的本质是同一句话:坐标系和事件流没有对齐,控件就会集体失控。
一、画出来:电量控件的测量与绘制关
电量图标是自定义 View 最经典的练手题:一个圆角矩形外壳、右侧一个正极小头、内部一条按电量比例填充的色块,电量低了变红。乍看是"两个矩形叠一起",真正用 Canvas 从零画,才会撞上第一关——你画的东西,凭什么是这个尺寸、在这个位置?
第一道坎:onDraw 用的是像素,布局写的是 dp
自定义 View 的第一步,是接受这样一个事实:onDraw(Canvas) 里所有坐标、宽高、线宽,单位都是像素(px);而布局文件里你写的是 dp。两者之间的桥梁是屏幕密度 density = dpi / 160。同一句 dp(28),在高密度屏上会被换算成更多像素,低密度屏上更少,视觉大小才一致。
class BatteryView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyle: Int = 0
) : View(context, attrs, defStyle) {
private val density = resources.displayMetrics.density
private fun dp(v: Int): Int = (v * density + 0.5f).toInt()
}
这里 density 直接来自 displayMetrics,+ 0.5f 是四舍五入——因为像素必须是整数,不四舍五入会在某些密度下整体偏移半个像素,长期累积会让细线描边发虚。把换算收成一个 dp() 工具,后面外壳、正极、内边距全部走它,任何一处尺寸都不许再手写裸像素,这是"画出来还对得齐"的前提。
边界情况值得展开:很多人只在 onDraw 里换算,却忘了 onMeasure 也该用同一套密度。如果测量阶段用裸数字、绘制阶段用 dp(),两个环节的密度基准不一致,控件在 wrap_content 时就会出现"测量给的尺寸"和"画出来的内容"对不上,外部布局跟着错位。这条经验可以推广:凡是在自定义 View 里出现尺寸的地方,必须统一经过同一个密度换算入口。
把属性暴露给布局:attrs.xml 与 TypedArray
一个只活在代码里的控件没有复用价值。要让布局文件能写 app:level="80"、app:innerColor="#ff0000",得先在 attrs.xml 声明,再在构造里用 TypedArray 读取。
// attrs.xml 里声明
// <declare-styleable name="BatteryView">
// <attr name="level" format="integer" />
// <attr name="outerColor" format="color" />
// <attr name="innerColor" format="color" />
// </declare-styleable>
init {
val ta = context.obtainStyledAttributes(attrs, R.styleable.BatteryView)
level = ta.getInteger(R.styleable.BatteryView_level, 50)
outerColor = ta.getColor(R.styleable.BatteryView_outerColor, Color.DKGRAY)
innerColor = ta.getColor(R.styleable.BatteryView_innerColor, Color.GREEN)
ta.recycle() // 必须回收
}
两个易错点:第一,obtainStyledAttributes 拿到的 TypedArray 是复用对象,不 recycle() 会污染后续同类型的属性读取,这是自定义 View 里最容易被忽略的资源泄漏;第二,默认值要选得有语义——电量默认 50、外壳默认深灰、内部默认绿,都是"看得见但不过分"的安全值,避免布局漏写属性时画出全黑或全透明。
onDraw 三步走:外壳、正极与电量填充
绘制分三步,顺序不能乱:先外壳,再正极,最后填充。填充画在最上层,露出的部分就是"已用电量"。
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val w = width
val h = height
// 正极:右侧小圆角矩形
val tip = RectF(w - dp(6), h * 0.3f, w.toFloat(), h * 0.7f)
paint.color = outerColor
canvas.drawRoundRect(tip, dp(2).toFloat(), dp(2).toFloat(), paint)
// 外壳:去掉正极那块的圆角矩形
val shell = RectF(0f, 0f, w - dp(6), h.toFloat())
paint.style = Paint.Style.STROKE
paint.strokeWidth = dp(2).toFloat()
paint.color = outerColor
canvas.drawRoundRect(shell, dp(4).toFloat(), dp(4).toFloat(), paint)
// 电量填充:按比例
val ratio = (level.coerceIn(0, 100)) / 100f
val innerPad = dp(3)
val inner = RectF(
innerPad.toFloat(), innerPad.toFloat(),
(w - dp(6) - innerPad) * ratio,
(h - innerPad).toFloat()
)
paint.style = Paint.Style.FILL
paint.color = innerColorFor(level) // 低电量变红
canvas.drawRoundRect(inner, dp(2).toFloat(), dp(2).toFloat(), paint)
}
这里的对齐细节是这关最磨人的地方:
- 外壳宽度是
w - dp(6),正好给右侧正极留出6dp的位置,两者共用同一条右边界,外壳和正极才不会重叠或留缝。 - 电量填充宽度按
* ratio缩放,但高度和内边距保持固定,所以填充是"从左边按比例长出来",而不是整体缩放。整体缩放会让低电量时电池芯被压扁,不符合用户对"电量格"的直觉。 level先coerceIn(0, 100),防止外部传了 120 或 -5 时填充矩形宽度变成负的,Canvas 对负宽高矩形会直接不画或画出反向区域,是隐性 bug。
颜色别写死:低电量变红的策略函数
电量低了变红,这个逻辑不能硬塞进 onDraw。一旦写死,明天产品说"低于 15% 才红、中间加个黄色预警",你就得改绘制代码。正确做法是抽成独立映射函数,绘制只负责"拿到颜色就画"。
private fun innerColorFor(level: Int): Int = when {
level < 20 -> LOW_COLOR // 红
level < 50 -> MID_COLOR // 橙
else -> HIGH_COLOR // 绿
}
阈值 20 和 50 是业务语义,不是绘制细节:低于 20 切红、低于 50 切橙、其余绿。把它和 onDraw 解耦,好处是单元测试可以直接 innerColorFor(10) == RED,不用真的去渲染一块 Canvas。这步看似简单,却是"自定义 View 能不能长期维护"的分水岭——所有随状态变化的视觉,都应该能脱离绘制单独求值。
onMeasure 才是布局对齐的关键(写死尺寸的坑)
前面都在 onDraw 里忙活,但控件放进父布局后"到底占多大",是由 onMeasure 决定的。电量控件通常是固定小图标,但 wrap_content 时也必须给个合理默认,否则系统会按 match_parent 处理。
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val defaultSize = dp(28) // 默认 28dp
val w = resolveSize(defaultSize, widthMeasureSpec)
val h = resolveSize(defaultSize, heightMeasureSpec)
setMeasuredDimension(w, h)
}
resolveSize(defaultSize, spec) 会根据父布局给的 MeasureSpec 模式决定最终尺寸。MeasureSpec 有三种模式,理解它们是这一关的终点:
| 模式 | 含义(父布局的意图) | resolveSize 的实际行为 | 直接写死尺寸的坑 |
|---|---|---|---|
| EXACTLY | 父布局给了确定值(如 100dp 或 match_parent 已确定) | 直接用父布局指定的 size | 写死会忽略父布局约束,溢出或留白 |
| AT_MOST | 父布局给了上限(如 wrap_content 在有限父容器内) | 取 min(defaultSize, 上限),不超过父布局 | 写死会无视上限,撑破父容器边界 |
| UNSPECIFIED | 父布局不限制(如 ScrollView、嵌套 RecyclerView) | 用 defaultSize 作为最终尺寸 | 写死反而安全,但依旧丢失自适应能力 |
很多人图省事,直接在 onMeasure 里 setMeasuredDimension(dp(28), dp(28)) 写死:
// 错误示例:直接写死尺寸,不支持 wrap_content 也不跟随父布局
override fun onMeasure(w: Int, h: Int) {
setMeasuredDimension(dp(28), dp(28)) // 父布局给 AT_MOST 时也强制 28dp
}
问题就在这里:当父布局是 wrap_content 或只给了 AT_MOST 上限,写死的 28dp 会无视父布局的测量约束——要么在紧凑布局里撑出多余空白,要么在受限容器里溢出,导致这一行的高度和其他控件对不齐。这正是"写死尺寸会让布局对不齐"的真实场景:onMeasure 是控件和父布局协商尺寸的唯一入口,协商的姿势错了,后面 onDraw 画得再准也救不回整体错位。resolveSize 才是把"我的默认期望"和"父布局的限制"正确合并的标准答案。
下面这张图串起"测量→绘制"的完整生命周期,也是电量控件这一关的主线:
flowchart TD
C["构造器读取 attrs.xml<br/>TypedArray 自定义属性"] --> M["onMeasure 被父布局调用"]
M --> M1{"父布局 MeasureSpec 模式"}
M1 -->|"EXACTLY"| M2["采用父布局指定尺寸"]
M1 -->|"AT_MOST"| M3["取 默认与上限 的较小值"]
M1 -->|"UNSPECIFIED"| M4["采用默认尺寸 dp 28"]
M2 --> SD["setMeasuredDimension 定稿"]
M3 --> SD
M4 --> SD
SD --> D["onDraw Canvas 绘制"]
D --> D1["画外壳圆角矩形 STROKE"]
D --> D2["画正极 tip 圆角矩形"]
D --> D3["画电量填充 按 ratio 变宽"]
二、摸得准:侧滑删除的触摸分发关
第一关解决"画出来",第二关解决"摸得准"。列表 Item 左滑露出删除键,今天看是标配,但从零实现自绘侧滑菜单,坑一个比一个隐蔽:点击和滑动怎么区分?长按和侧滑怎么共存?快速滑出去该不该回弹?列表里好几行,滑开这一行,上一行怎么自动合上?
从 ViewGroup 说起:菜单层在下,内容层在上
一个列表项,内容区之上盖着一层菜单区(删除、编辑按钮),平时藏在右侧屏幕外,手指左滑时露出。整体是一个自定义 ViewGroup,里面放了两个子布局:菜单层在下、内容层在上。
class SwipeLayout : ViewGroup {
private var menuView: View // 底层菜单(藏在右侧)
private var contentView: View // 顶层内容(可左移)
var isOpen = false
private set
}
问题从事件分发那一刻就开始——你以为手指按下去事件就直接给你了,其实中间隔着三层"谁该处理"的博弈。
事件分发的三层博弈
侧滑 Item 放在一个可上下滑动的列表里,于是同一个手势既可能被列表当成"上下滑",也可能被我们当成"左右滑"。Android 的事件分发链路里,父容器(列表)拥有优先拦截权:事件先到父容器,父容器 onInterceptTouchEvent 返回 true 就截走,返回 false 才往下传给 Item。
第一层,列表在 onInterceptTouchEvent 判断:如果是横向滑动(dx > dy),说明是侧滑,交给 Item 处理,列表自己不拦;如果是纵向滑动,列表截走去做滚动。
// 列表层的拦截逻辑(示意)
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
if (ev.actionMasked == MotionEvent.ACTION_MOVE) {
val dx = ev.rawX - lastX
val dy = ev.rawY - lastY
// 横向为主 => 让给 Item 处理侧滑
if (abs(dx) > abs(dy) && abs(dx) > touchSlop) return false
}
return super.onInterceptTouchEvent(ev)
}
第二层,Item 自己在 dispatchTouchEvent / onInterceptTouchEvent 里判断:自己的内容层在左移,如果是横向拖动就自己消费;如果是点击、纵向拖动,就交给内容层。
第三层是滑动边界:内容已经滑到最左,继续左滑没意义,此时要把事件还回去,让列表能继续上下滚动。
这三层博弈的核心原则是:能识别出手势意图,就把事件交出去;识别不出来,宁可先拦下来观察。 大量控件 bug 出在"识别时机太晚"(手指都滑出去了才想起该自己处理,结果被列表滚走了)或"过早拦截"(手指刚按下就 return true,点击和侧滑全乱套)。下面这张图把三层博弈画清楚:
flowchart TD
A["触摸事件到达列表"] --> B{"纵向滑动为主?<br/>dy 大于 dx"}
B -->|"是"| C["列表拦截 onInterceptTouchEvent<br/>自己上下滚动"]
B -->|"否 横向为主"| D{"Item 已滑到边界?"}
D -->|"是"| E["事件还给列表<br/>继续上下滚"]
D -->|"否"| F["Item 消费事件<br/>处理侧滑位移"]
F --> G["内容层 translationX 左移<br/>露出菜单"]
点击、长按、侧滑的三方共存:一个不可逆状态机
列表项通常还要支持点击(进详情)和长按(弹菜单)。于是同一个手指按下,究竟是准备点、准备长按、还是准备滑?
- 按下后几乎没有移动,抬手 → 判定为点击。
- 按住不动超过 500ms → 判定为长按。
- 按住后横向移动超过阈值(touchSlop) → 判定为侧滑,取消点击和长按的资格。
实现上,用一个状态机在 ACTION_DOWN 进入"待定",每次 ACTION_MOVE 更新状态,一旦横向位移超过阈值就不可逆地进入滑动态——此后所有后续事件都当作滑动处理,绝不再触发点击。这步"不可逆"是关键:如果允许从滑动态退回点击态,用户快速一划,松手时又被判成点击,就跳进了详情页,体验崩掉。
private var touchState = STATE_PENDING
override fun onTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> touchState = STATE_PENDING
MotionEvent.ACTION_MOVE -> {
val dx = ev.x - downX
if (abs(dx) > touchSlop) {
touchState = STATE_SLIDING // 一旦滑动,终身不可再点
}
}
MotionEvent.ACTION_UP -> {
if (touchState == STATE_PENDING) performClick() // 只有没滑过才点
}
}
return true
}
touchSlop 是系统定义的"误触容差",通过 ViewConfiguration.get(context).scaledTouchSlop 获取,不同屏幕密度下数值不同——别自己拍一个常量,否则高密度屏上轻微手抖就被判成滑动。500ms 的长按阈值同理,尽量走系统 ViewConfiguration 的标准值,和全系统手势保持一致。
松手去哪:位移过半 + 速度双判据
手指松开时,内容层可能滑到半路。判断它该"弹回关闭"还是"顺势展开",有两个判据:
- 位移过半:滑出距离超过总距离一半,就展开,否则关闭。
- 滑动速度:用速度追踪器记录手指松开瞬间的速率,向左速度快(比如超过每秒 1000 像素)就直接展开,向右快就关闭——哪怕位移没过半。
速度判据解决的是"用户甩了一下"的场景:人家明明是想打开,只是手指松得快、位移还没过半,如果只看位移就会误关。反过来快速右甩该立刻关。1000 px/s 是个经验阈值,太灵敏会误触,太迟钝又没手感,需要真机调。
回弹动画用属性动画加一个过冲插值器,让内容先略微过头一点再停住,视觉上有"duang"一下的弹性:
fun toggle(open: Boolean) {
val target = if (open) maxSlide else 0f
valueAnimator = ValueAnimator.ofFloat(translation, target).apply {
interpolator = OvershootInterpolator() // 过冲回弹
addUpdateListener { anim ->
contentView.translationX = anim.animatedValue as Float
}
start()
}
}
速度追踪本身需要一点样板代码,源文把它藏在"速度追踪器"几个字里,这里补上,免得你以为有魔法:
private val velocityTracker = VelocityTracker.obtain()
// 在 ACTION_MOVE 里持续喂入事件
velocityTracker.addMovement(ev)
// 在 ACTION_UP 里结算,单位 像素/秒
velocityTracker.computeCurrentVelocity(1000)
val vx = velocityTracker.xVelocity // 向左为负,向右为正
// 判断 abs(vx) 是否超过 1000,以及方向
velocityTracker.clear()
computeCurrentVelocity(1000) 的 1000 是时间单位(毫秒),返回的就是"每秒多少像素",和我们的 1000 px/s 阈值天然同单位。下面这张图把"松手去哪"的分支画全:
flowchart TD
START["手指松开 ACTION_UP"] --> Q1{"位移过半?<br/>offset 大于 maxSlide 除 2"}
Q1 -->|"是"| OPEN["展开 open = true"]
Q1 -->|"否"| Q2{"速度超阈值?<br/>abs vx 大于 1000 像素每秒"}
Q2 -->|"向左 快"| OPEN
Q2 -->|"向右 快"| CLOSE["关闭 open = false"]
Q2 -->|"速度不足"| CLOSE
OPEN --> ANIM["OvershootInterpolator<br/>过冲回弹动画"]
CLOSE --> ANIM
多菜单互斥:全局静态引用与泄漏陷阱
侧滑控件最经典的 bug 场景:列表有十行,你滑开第 3 行,再去滑第 5 行——第 3 行还开着,两个菜单同时露出来,界面瞬间乱了。
解决思路是:全局记住"当前展开的是哪个 Item"。每次 ACTION_DOWN,如果本次按下的不是当前展开的那个 Item,就先把这个展开的关掉:
companion object {
// 全局唯一展开项引用
private var currentOpened: SwipeLayout? = null
}
override fun onTouchEvent(ev: MotionEvent): Boolean {
if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
// 按下别的项时,先合上当前展开的
if (currentOpened != null && currentOpened != this) {
currentOpened?.close()
}
}
// ...
}
注意这里用了静态字段,因为"是不是当前展开项"需要在全局所有 Item 之间共享信息。但这引出一个必须小心的点:静态引用会让 View 无法被回收。
解法是在控件离开窗口时清理——当这个 Item 被滑出屏幕或列表重建,onDetachedFromWindow 被调用,如果静态引用正指向它,就置空:
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
if (currentOpened == this) {
currentOpened = null // 释放静态引用,避免内存泄漏
}
}
漏掉这步的后果很隐蔽:列表滚动复用 Item,旧的 SwipeLayout 本该被回收,却因为静态字段还指着它而常驻内存,页面滚得越多、泄漏越多,内存只涨不跌。所以这个"全局互斥"不是加个静态变量就完事,必须配 onDetachedFromWindow 的清理才闭环。
八年里的教训:一个内部控件从 2016 写到 2024
回到开头那个问题:左滑出删除键,能有多难?一个内部控件从 2016 年写到 2024 年,文件头部的注释就是一部完整的 bug 修复编年史——从"点击误触"到"滚动冲突",从"回弹手感发硬"到"多行同开",再到"列表滚完内存飙升"。每一条教训,都是用户在真机上划出来的。这一节的结论不是"把代码写对",而是"手势处理的每一个分支,都得为假手感和假场景留好退路"。
三、坐标不跑偏:键盘收起的坐标错位关
前两关在控件内部——测量、绘制、事件分发,坐标系都还在你的掌控里。第三关最阴险:控件没写错,事件也对了,但你拿到的坐标,和你以为的那套坐标系,已经悄悄错开了。
朴素实现:点击外部就收起键盘
很多 App 都有这个交互:键盘弹着,用户点输入框外面的区域,键盘收起。实现思路简单——监听全局触摸事件,如果点击点不在输入框范围内,就收起键盘、清掉焦点。
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (ev.action == MotionEvent.ACTION_DOWN) {
val focusView = currentFocus
if (focusView is EditText) {
// 取输入框的全局可见区域
val rect = Rect()
focusView.getGlobalVisibleRect(rect)
// 点击点在区域外,则收起键盘
if (ev.rawX < rect.left || ev.rawX > rect.right ||
ev.rawY < rect.top || ev.rawY > rect.bottom
) {
hideKeyboardAndClearFocus(focusView)
}
}
}
return super.dispatchTouchEvent(ev)
}
逻辑看起来完全正确:取输入框全局区域,判断点击点(rawX/rawY 是屏幕全局坐标)是否在区域内,不在就收键盘。问题出在它假设了"取区域"和"取点击点"用的是同一套坐标系——而这个假设,在键盘弹起后会崩。
Bug 现场:输入框被顶起来后,区域坐标漂了
坑来自 getGlobalVisibleRect。这个方法返回的是 View 的全局可见区域——注意是"可见"的,也就是 View 和其父容器裁剪后的实际显示区域。
当软键盘弹起,系统会把界面往上顶,输入框也被顶上去。这时候坑来了:界面上移之后,getGlobalVisibleRect 返回的区域可能还是"上移之前"的旧位置,或者与真实的屏幕坐标有偏移。而 ev.getRawX()/getRawY() 拿到的是真实的屏幕坐标。
于是出现一个经典错判:用户点输入框外的下方区域,明明 rawY 已经很大了,但 getGlobalVisibleRect 返回的 rect.bottom 因为"没跟上上移"还停留在一个更小的值上,结果 rawY > rect.bottom 这条"在区域外"的判断失败,键盘不收起。用户疯狂点外面,键盘纹丝不动——这种 bug 复现率看机型,排查起来极抓狂。
根因:两种坐标系的错位
一句话总结根因:getGlobalVisibleRect 的"可见区域"和 getRawX/getRawY 的"屏幕坐标"不是同一套坐标系下的量,窗口位移时两者会错位。
getGlobalVisibleRect 返回的是相对屏幕的矩形,但它内部计算时会考虑窗口内 View 的可见性裁剪。当界面被 adjustResize 顶起,View 实际可见范围缩小、位置移动,这个 API 在某些 Android 版本/场景下没有及时反映真实位置,仍返回偏移前的矩形。而 rawX/rawY 来自系统派发的 MotionEvent,是窗口位移后真实的屏幕坐标。一个停在过去,一个活在当下,两者一比,必然错乱。
解法:用屏幕坐标重新校准偏移
修正思路是:不要信任 getGlobalVisibleRect 的单一结果,改用更可靠的屏幕坐标来源来校准。
做法是同时用 getLocationOnScreen 拿到输入框在屏幕上的真实左上角坐标,然后和 getGlobalVisibleRect 的结果对比,如果两者不一致(说明区域发生了位移),就用屏幕坐标修正矩形:
if (ev.action == MotionEvent.ACTION_DOWN) {
val focusView = currentFocus
if (focusView is EditText) {
val rect = Rect()
focusView.getGlobalVisibleRect(rect)
// 关键修正:用屏幕坐标校准
val loc = IntArray(2)
focusView.getLocationOnScreen(loc)
if (loc[1] < rect.top) {
// 输入框被顶起,rect 还没跟上,用屏幕坐标修正
rect.offset(0, loc[1] - rect.top)
}
val inRange = ev.rawX in rect.left..rect.right &&
ev.rawY in rect.top..rect.bottom
if (!inRange) hideKeyboardAndClearFocus(focusView)
}
}
getLocationOnScreen 返回的是 View 在屏幕上的绝对位置,与 rawX/rawY 是同一坐标系,用它来修正 getGlobalVisibleRect 的偏移,就能让"点击点是否在框内"的判断回到正确的语义。rect.offset(0, loc[1] - rect.top) 这一步就是"把冻结在旧位置的矩形,沿着 y 方向挪回真实位置"。
flowchart LR
SUB["输入框被键盘顶起前"] --> S1["getGlobalVisibleRect 等于真实屏幕位置"]
S1 --> S2["rawX rawY 同坐标系<br/>内外判断正确"]
PUB["输入框被键盘顶起后"] --> P1["getGlobalVisibleRect 滞留旧位置"]
P1 --> P2["rawY 已变大 仍判在框内<br/>键盘不收起"]
P2 --> FIX["getLocationOnScreen 校准<br/>rect offset 修正"]
FIX --> OK["判断回到正确语义<br/>点外部可收起"]
三关串起来,坑的对照一目了然:
| 现象 | 根因 | 解法 |
|---|---|---|
| 电量控件在不同屏幕对不齐、wrap_content 留白/溢出 | 测量阶段写死像素、onMeasure 无视 MeasureSpec 模式 | 统一经密度换算入口;onMeasure 用 resolveSize 跟随父布局 |
| 侧滑时点击误触、多行菜单同时开、列表滚动后内存涨 | 手势意图识别时机错、缺全局互斥、静态引用未清理 | 不可逆状态机区分点击/长按/侧滑;静态 currentOpened + onDetachedFromWindow 释放 |
| 键盘弹起后点输入框外,键盘死活不收 | getGlobalVisibleRect 与 rawX/rawY 两套坐标系窗口位移后错位 | 用 getLocationOnScreen 取同坐标系屏幕坐标校准 rect |
再把三关放到一张表里横向看,会更清楚它们为什么是"同一类问题":
| 关 | 核心矛盾 | 关键 API / 工具 | 最容易错的环节 |
|---|---|---|---|
| 测量绘制关 | 布局 dp 与绘制 px、父布局约束与自身期望没对齐 | displayMetrics.density、TypedArray、resolveSize、三种 MeasureSpec | 在 onMeasure 写死尺寸,无视父布局模式 |
| 触摸分发关 | 同一手势的"意图"在列表、Item、边界三层间归属不明 | onInterceptTouchEvent、VelocityTracker、OvershootInterpolator | 识别时机太晚或过早拦截,状态机可回退 |
| 坐标错位关 | 取位置的坐标系与取点击的坐标系不是同一套 | getGlobalVisibleRect、getLocationOnScreen、MotionEvent.rawX/Y | 窗口位移后信任单一"可见区域"坐标 |
横向看,三关的本质都是"对齐"二字:测量关对齐尺寸基准,触摸关对齐事件归属,坐标关对齐坐标系本身。任何一关的"对齐"假设一旦在运行时被打破——密度变了、手势意图模糊了、窗口被键盘顶起了——控件就失控。所以写自定义 View,先别急着画,先想清楚:我手里的每一个数,到底站在哪条基线、哪条事件流上。
四、小结
- 自定义 View 第一关是测量与绘制对齐:布局用 dp、绘制用像素,统一经密度换算;
onMeasure必须resolveSize跟随父布局,写死尺寸会让整体布局错位。 - 第二关是触摸分发的三方博弈:列表、Item、滑动边界谁该拦截要按手势意图动态判断;点击/长按/侧滑靠不可逆状态机区分,松手去向由位移过半 + 速度双判据决定。
- 第三关是坐标系不跑偏:
getGlobalVisibleRect与rawX/rawY在窗口位移后会错位,必须用getLocationOnScreen的同坐标系屏幕坐标校准。 - 三关共享同一根:自定义 View 的复杂度不在绘制的 API,而在"我收到的坐标到底是谁的坐标系、事件流有没有被正确地交给该处理的角色"。
- 排坐标类问题,第一步永远是问:这个值是什么坐标系下的?和我正在比的那个,是不是同一套?
系列导航:Android 基础库系列第 2 篇(共 9 篇)。上一篇《Android 万级数据表格:滚动同步、局部刷新与性能优化》,下一篇《Android UI 复用体系:泛型基类、骨架与插槽》。
你在自定义 View 时,有没有遇到过"明明坐标算对了,真机上却偏了一点"的情况?最后是怎么定位到是哪一套坐标系在捣鬼的?