Android触摸机制与自定义 View 实战

1、深入解析 Android 摇杆控件 JoystickView

在 Android 游戏或无人机遥控等应用中,摇杆(Joystick)是一种常见的人机交互控件。它模拟了游戏手柄的摇杆操作,通过触摸滑动来控制方向与力度。virtual-joystick-android 是一个轻量级、高度可定制的开源摇杆库,代码简洁且功能完善。本文将以该库的 JoystickView 源码为核心,深入剖析 Android 触摸事件分发机制、自定义 View 的测量绘制流程,并详解如何实现一个高性能、可伸缩的摇杆控件。全文将结合源码片段,帮助读者掌握自定义 View 的高级技巧。

2、Android 触摸机制基础

在分析 JoystickView 之前,必须先理解 Android 触摸事件的核心概念。

2.1 MotionEvent 与事件类型

触摸事件的每一次交互都由 MotionEvent 对象封装,包含触摸位置(X/Y)、触摸压力、事件类型等信息。常见事件类型有:

  • ACTION_DOWN:第一个手指按下。

  • ACTION_MOVE:手指在屏幕上移动。

  • ACTION_UP:最后一个手指抬起。

  • ACTION_POINTER_DOWN:已有手指按下时,另一手指按下。

  • ACTION_POINTER_UP:多指情况下某一手指抬起。

  • ACTION_CANCEL:事件被父视图拦截或系统取消。

2.2 事件分发机制

Android 事件分发由三个关键方法协作完成:

  • dispatchTouchEvent(MotionEvent ev):负责事件分发,返回 true 表示事件被消费,否则继续传递。

  • onInterceptTouchEvent(MotionEvent ev)(仅 ViewGroup 有效):决定是否拦截事件,拦截后事件不再向下传递,直接交给自己的 onTouchEvent

  • onTouchEvent(MotionEvent ev):实际处理事件,返回 true 表示消费事件。

对于自定义 View(非 ViewGroup),通常只需要重写 onTouchEventJoystickView 继承自 View,因此直接在其 onTouchEvent 中处理所有触摸逻辑。

2.3 多点触控与指针索引

MotionEvent 支持多点触摸,通过 getPointerCount() 获取当前触控点数,getActionIndex() 获取触发当前事件的手指索引,getX(int pointerIndex) / getY(int pointerIndex) 获取特定手指的坐标。JoystickView 利用多点触控实现了双指长按回调(OnMultipleLongPressListener)。

2.4 事件坐标体系

触摸坐标分为绝对坐标(相对于屏幕)和相对坐标(相对于当前 View)。getX() 返回的是相对于当前 View 左上角的坐标。JoystickView 正是利用相对坐标来计算摇杆按钮的位置。

3、自定义 View 的基本步骤

继承 View 并实现一个完整功能的自定义控件通常需要以下步骤:

  1. 定义自定义属性 :在 res/values/attrs.xml 中声明属性,构造函数中通过 TypedArray 读取。

  2. 重写 onMeasure:测量 View 的宽高,决定尺寸。

  3. 重写 onSizeChanged:在尺寸确定后计算子元素或绘制参数(如半径)。

  4. 重写 onDraw :进行绘制,使用 CanvasPaint 等。

  5. 处理触摸事件 :重写 onTouchEvent,实现交互逻辑。

  6. 提供回调接口:将状态变化通过监听器暴露给外部。

JoystickView 严格遵循了这些步骤,下面逐一剖析。

4、JoystickView 源码深度解析

4.1 自定义属性定义与解析

JoystickViewattrs.xml 中定义了大量可配置属性(源码中未给出该文件,但从 TypedArray 读取的属性名可推断)。主要包括:

  • JV_buttonColor:按钮颜色,默认黑色。

  • JV_borderColor:边框颜色,默认透明。

  • JV_borderAlpha:边框透明度,0-255。

  • JV_backgroundColor:背景圆颜色。

  • JV_borderWidth:边框宽度。

  • JV_buttonImage:按钮图片,使用 Drawable

  • JV_buttonSizeRatio:按钮半径占控件宽高的比例,默认 0.25。

  • JV_backgroundSizeRatio:背景圆半径占控件宽高的比例,默认 0.75。

  • JV_fixedCenter:摇杆中心是否固定,默认 true。

  • JV_autoReCenterButton:松手后是否自动回中,默认 true。

  • JV_buttonStickToBorder:按钮是否始终吸附在边界上,默认 false。

  • JV_enabled:是否启用,默认 true。

  • JV_buttonDirection:限制移动方向(both/horizontal/vertical)。

构造函数中通过 styledAttributes 获取这些值并初始化对应的 Paint 对象或成员变量。例如:

ini 复制代码
mButtonSizeRatio = styledAttributes.getFraction(R.styleable.JoystickView_JV_buttonSizeRatio, 1, 1, 0.25f);

mBackgroundSizeRatio = styledAttributes.getFraction(R.styleable.JoystickView_JV_backgroundSizeRatio, 1, 1, 0.75f);

注意 getFraction 可以处理百分数(如 25%)和浮点数。

4.2 测量与尺寸计算

JoystickView 希望自身是一个正方形,因此重写 onMeasure

java 复制代码
@Override

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

    int d = Math.min(measure(widthMeasureSpec), measure(heightMeasureSpec));

    setMeasuredDimension(d, d);

}

measure() 方法处理 MeasureSpec 模式:

  • 如果是 UNSPECIFIED(未指定尺寸),返回默认大小 DEFAULT_SIZE = 200(像素)。

  • 否则返回 MeasureSpec.getSize(measureSpec)

这种实现保证控件始终为正方形,边长取父容器给出的宽度和高度的最小值。

4.3 尺寸变化时的参数重算

onSizeChanged 中,根据最终的宽高计算按钮半径、边框半径和背景圆半径:

ini 复制代码
int d = Math.min(w, h); // 实际边长

mButtonRadius = (int) (d / 2 * mButtonSizeRatio);

mBorderRadius = (int) (d / 2 * mBackgroundSizeRatio);

mBackgroundRadius = mBorderRadius - (mPaintCircleBorder.getStrokeWidth() / 2);
  • mButtonRadius:摇杆按钮的半径。

  • mBorderRadius:摇杆活动区域的边界半径(也是背景圆的半径)。

  • mBackgroundRadius:仅为绘制背景填充圆时减去描边宽度的一半,避免边框被覆盖。

如果设置了按钮图片,还会将 Bitmap 缩放到 mButtonRadius * 2 的大小。

4.4 绘制实现

onDraw 方法执行三层绘制:

scss 复制代码
// 1. 绘制背景圆(填充)

canvas.drawCircle(mFixedCenterX, mFixedCenterY, mBackgroundRadius, mPaintBackground);

// 2. 绘制边框圆(描边)

canvas.drawCircle(mFixedCenterX, mFixedCenterY, mBorderRadius, mPaintCircleBorder);

// 3. 绘制摇杆按钮(图片或圆形)

if (mButtonBitmap != null) {

    canvas.drawBitmap(mButtonBitmap, ...);

} else {

    canvas.drawCircle(mPosX + mFixedCenterX - mCenterX, mPosY + mFixedCenterY - mCenterY, mButtonRadius, mPaintCircleButton);

}

注意 mFixedCenterXmFixedCenterY 是 View 的中心坐标,而 mCenterX/mCenterY 是摇杆的有效中心(在固定中心模式下两者相等;在动态中心模式下,mCenterX/mCenterY 会在每次 ACTION_DOWN 时被设置到触摸点,但不会超出边界)。按钮的圆心坐标由 mPosXmPosY 确定,它们记录了相对于有效中心的偏移量。

绘制时实际按钮圆心位置为:

ini 复制代码
buttonCenterX = mPosX + mFixedCenterX - mCenterX

buttonCenterY = mPosY + mFixedCenterY - mCenterY

这个变换稍显复杂,但本质是:无论有效中心在何处(动态模式),按钮显示始终在 View 的固定中心区域平移。实际上,当 mFixedCenter = true 时,mCenterX = mFixedCenterX,公式简化为 mPosXmPosY 直接作为按钮圆心。

4.5 触摸事件处理 ------ 核心交互

onTouchEvent 是整个控件的灵魂,处理了手指滑动、边界限制、方向限制、自动回中以及多点长按检测。我们逐步解析。

4.5.1 基本禁用逻辑

mEnabled == false,直接返回 true 但不做任何移动,避免消耗。

4.5.2 根据方向限制更新坐标

ini 复制代码
mPosY = mButtonDirection < 0 ? mCenterY : (int) event.getY();

mPosX = mButtonDirection > 0 ? mCenterX : (int) event.getX();
  • 当方向限制为水平(mButtonDirection < 0),Y 坐标固定为中心,只允许 X 变化。

  • 当方向限制为垂直(mButtonDirection > 0),X 坐标固定为中心。

  • BUTTON_DIRECTION_BOTH(0),同时允许 X/Y 变化。

4.5.3 ACTION_UP 时的处理

当手指抬起时:

  • 停止线程循环(mThread.interrupt())。

  • 如果启用自动回中,调用 resetButtonPosition()mPosX/mPosY 设回 mCenterX/mCenterY,并立即通过回调通知 angle=0, strength=0。

  • 如果不自动回中,则不清零,但之后用户再次触摸时会从当前位置开始。

4.5.4 ACTION_DOWN 时的处理

每次新的触摸按下时,会启动一个独立的线程 mThread 来循环调用回调方法。该线程每隔 mLoopInterval 毫秒(默认 50ms)在主线程中执行一次 onMove。这实现了即使手指不移动也能持续发送当前摇杆状态(对于需要连续控制的应用非常有用)。

4.5.5 多点长按检测

JoystickView 提供了一个独特的 OnMultipleLongPressListener,用于检测双指长按(通常用于穿戴设备中替代传统长按退出)。相关逻辑在 switch(event.getActionMasked()) 中:

  • ACTION_POINTER_DOWN:当第二根手指按下时,利用 Handler 延迟执行 mRunnableMultipleLongPress,延迟时间为 ViewConfiguration.getLongPressTimeout()*2

  • ACTION_MOVE:当移动发生时,递减 mMoveTolerance,一旦累计移动超过 10 像素(MOVE_TOLERANCE),就取消长按回调,防止误触发。

  • ACTION_POINTER_UP:当手指数量减少到 2 以下时,移除回调。

这种设计允许双指长按作为特殊操作(如打开菜单或退出),而单指滑动依然正常控制摇杆。

4.5.6 边界限制算法 ------ 关键点

摇杆按钮不能超出 mBorderRadius 定义的圆环范围。原代码使用欧几里得距离 abs

scss 复制代码
double abs = Math.sqrt((mPosX - mCenterX)*(mPosX - mCenterX) + (mPosY - mCenterY)*(mPosY - mCenterY));

if (abs > mBorderRadius || (mButtonStickToBorder && abs != 0)) {

    mPosX = (int) ((mPosX - mCenterX) * mBorderRadius / abs + mCenterX);

    mPosY = (int) ((mPosY - mCenterY) * mBorderRadius / abs + mCenterY);

}

逻辑解析:

  • 当距离大于半径时,将点投影到边界圆上(通过缩放因子 mBorderRadius / abs)。

  • 如果启用了 mButtonStickToBorder,即使距离小于半径,也会将按钮强制吸附到边界上(此时 abs != 0 排除中心点)。这种模式适合需要离散方向控制的场景。

重要缺陷 :上述算法忽略了按钮本身的半径。当按钮半径较大时,按钮会部分超出边界的视觉效果(即用户之前遇到的问题)。正确的做法应该是 maxRadius = mBorderRadius - mButtonRadius。稍后将给出修复方案。

4.5.7 动态中心模式

mFixedCenter = false 时,每次 ACTION_DOWN 会将有效中心 (mCenterX, mCenterY) 设置为手指按下的位置,但限制在 View 范围内(通过 Math.min/max 隐含,实际代码未显式限制,因为 mPosX/mPosY 初始值为 event.getX/Y(),而 mCenterX 被直接赋值为 mPosX,可能靠近边界导致背景圆绘制超出 View?实际上 onDraw 中的 mCenterX 只影响按钮偏移,背景圆始终以 mFixedCenterX 为中心绘制,这导致动态中心模式下背景与按钮不同心,视觉上奇怪。这是库的一个设计不足,通常动态中心模式应重新绘制背景圆的位置,但作者可能认为更简单的交互是合理的,因为背景圆不可见时(透明)影响不大。)。

4.6 角度与强度计算

  • getAngle():返回从中心指向按钮的方位角,数学上使用 atan2(deltaY, deltaX),Android 坐标系 Y 轴向下,因此需要 mCenterY - mPosY 来得到标准数学角度,并转换到 0~360 度范围。

  • getStrength():计算距离 dmBorderRadius 的比值,作为百分比。

强度百分比公式:

arduino 复制代码
return (int) (100 * d / mBorderRadius);

该公式同样忽略了按钮半径,当按钮刚好在边界圆上时,d = mBorderRadius,强度为 100。但由于按钮本身会超出边界,实际应该限制为 maxRadius

4.7 回调线程与性能

JoystickView 开启了一个 Thread 循环发送 onMove 事件。这种设计虽然简单,但存在一些问题:

  • 每次 ACTION_DOWN 都会 new Thread,频繁创建线程有开销。

  • 线程中使用 Thread.sleep 精度不高,且需要处理中断。

  • 更优雅的做法是使用 Handler + RunnableChoreographer 配合帧回调。但对于简单摇杆场景,此实现足以满足需求。

4.8 坐标标准化

getNormalizedX()getNormalizedY() 方法将按钮位置映射到 0~100 之间,方便对接其他协议。这两方法考虑了按钮半径,因此在边界内返回 0 到 100 的整数。

5、常见问题与解决方案

5.1 按钮超出控件边界

如上所述,JoystickView 原始的边界限制未减去按钮半径,导致按钮图片超出背景圆。修复方法:

ini 复制代码
int maxRadius = mBorderRadius - mButtonRadius;

if (maxRadius < 0) maxRadius = 0;

if (abs > maxRadius || (mButtonStickToBorder && abs != 0 && maxRadius > 0)) {

    mPosX = (int) ((mPosX - mCenterX) * maxRadius / abs + mCenterX);

    mPosY = (int) ((mPosY - mCenterY) * maxRadius / abs + mCenterY);

}

同时修正 getStrength() 分母为 maxRadius

5.2 动态中心模式下的绘制错误

由于背景圆始终以 View 几何中心绘制,而有效中心可能偏离,导致按钮在背景圆外。解决方案:要么禁用动态中心下的背景圆绘制,要么在动态中心时重新定位背景圆中心。

5.3 多线程回调导致的 ANR 风险

在线程中直接调用 post(new Runnable()) 是安全的,但频繁的 post 可能导致消息队列积压。可以改用 onDraw 的最后一帧回调或 ValueAnimator 驱动。更简单的改进:只在移动时(ACTION_MOVE)调用回调,而不是单独开线程。

5.4 与 ViewPager 等滑动控件冲突

由于 JoystickView 会消费触摸事件,当它嵌入在滑动容器中时,可能导致父容器无法滑动。解决方案:在 onTouchEvent 中调用 getParent().requestDisallowInterceptTouchEvent(true) 来阻止父容器拦截。

6、编写自定义 View 的最佳实践总结

通过分析 JoystickView,我们可以提炼出编写高质量自定义 View 的若干原则:

  1. 属性驱动:使用自定义属性让布局文件可配置,提高复用性。

  2. 遵循测量规范 :正确处理 MeasureSpec 的三种模式,明确 wrap_contentmatch_parent 的行为。

  3. 缓存计算结果 :在 onSizeChanged 中计算半径等常量,避免在 onDraw 中重复运算。

  4. 高效绘制 :使用 PaintsetAntiAlias 平滑边缘;避免在 onDraw 中创建对象。

  5. 触摸反馈 :合理处理 ACTION_DOWNACTION_MOVEACTION_UP,及时调用 invalidate() 刷新。

  6. 边界检查:对用户输入进行限制,避免绘制超出区域。

  7. 回调优化 :使用 Handler 或线程回调时要小心内存泄漏,注意取消。

  8. 多点触控支持 :根据需求决定是否支持多指,并正确处理 ACTION_POINTER_DOWN/UP

  9. 可访问性 :添加 contentDescription 等支持。

  10. 性能调优 :避免过度绘制,使用 clipRect 或不绘制不可见部分。

7、扩展与展望

virtual-joystick-android 作为一个轻量级库,已经覆盖了绝大多数摇杆需求。但它仍有改进空间:

  • 支持触控力度的映射(压力敏感)。

  • 支持自定义背景图并跟随动态中心。

  • 改用 GestureDetector 简化多点长按逻辑。

  • 增加惯性滑动(flick)效果。

作为开发者,理解其实现原理后,可以轻松地对其扩展或修复,例如上文提到的按钮边界问题,只需修改几行代码即可完美解决。

8、结语

Android 自定义 View 是 UI 开发中的高级技能,它要求开发者深刻理解触摸机制、绘图系统以及性能调优。本文以 JoystickView 为蓝本,逐行解读其实现,并指出了其中的设计亮点与不足。希望通过这篇文章,读者不仅能够掌握摇杆控件的开发,更能举一反三,实现各种复杂的交互控件。触摸是移动设备的核心交互方式,而高质量的自定义 View 则是优秀用户体验的基石。

相关推荐
Dabei2 小时前
Android TV 焦点处理详解:遥控器与空鼠
android·前端
悠哉清闲2 小时前
裁剪SurfaceView
android
常利兵2 小时前
Android字体字重设置全攻略:XML黑科技+Kotlin动态实现,告别.ttf臃肿
android·xml·科技
therese_100863 小时前
安卓-IPC
android
沙粒03 小时前
Mac 使用 scrcpy 局域网无线投屏指南
android
过期动态4 小时前
MySQL中的约束
android·java·数据库·spring boot·mysql
牛蛙点点申请出战5 小时前
IconFontViewer -- 一个可以在 Android Studio 中实时预览 IconFont 的插件
android·前端·intellij idea
努力努力再努力wz6 小时前
【MySQL 进阶系列】拒绝滥用root:从 mysql.user 到权限校验,带你彻底理解用户管理与授权机制!
android·c语言·开发语言·数据结构·数据库·c++·mysql
HaiXCoder6 小时前
AndroidAutoSize 框架原理分析与核心问题
android