ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Android虚拟摇杆Joystick自定义控件实现:从触摸事件到方向输出

2026/9/9 5:41:47 拓冰建站 浏览量
Android虚拟摇杆Joystick自定义控件实现:从触摸事件到方向输出 做 Android 方向控制控件这件事我在好几个项目里都碰到过。遥控小车的 App 要一个虚拟摇杆、双人小游戏需要八方向键、自平衡小车想要方向盘式控制甚至有些工业调试工具也需要一个能输出连续方向量的手柄控件。系统自己并没有提供通用的“方向控制控件”网上能搜到的第三方库要么行为固定、要么手感不对、要么维护滞后最后不管怎么选大概率都得自己写或者深度改造。这篇文章把完整的实现过程和大大小小的坑梳理一遍重点讲我用得最多、也最适合大多数项目的虚拟摇杆 Joystick 怎么从头写到能上线。如果你要做的控件包含触摸方向输出、力度反馈、与业务方协议对接这篇内容可以直接照着落地。1. 先从需求角度拆解方向控件到底有哪几种形态1.1 虚拟摇杆、D-pad、方向盘各自的适用场景很多刚接需求的同学第一反应是“直接拖一个按钮不就行了”但实际上方向控制控件远不止按钮。整理下来大致有三种主流形态形态输出特点典型场景实现难度虚拟摇杆Joystick连续角度 连续力度遥控车、坦克、MOBA 走位、无人机中方向键D-pad离散的 4/8 方向模拟器游戏、菜单导航、老式 RPG低方向盘旋转角度 回正赛车游戏、云台转向、摄像机位中高没有哪个形态是绝对最好的要看输出给谁。如果你控制的对象本身是连续运动的比如麦克纳姆轮小车、云台、游戏里的角色移动用虚拟摇杆是最自然的如果你要做的只是在菜单里上下选择、或者模拟 FC 手柄那 D-pad 反而更好用输入更确定、误触更少方向盘则适合那种“需要手感像真实驾驶一样回正”的操作。我在大多数实际需求里选虚拟摇杆因为它覆盖面最广一个控件改改参数就能适配不同产品。1.2 固定摇杆和浮动摇杆怎么选同一个虚拟摇杆还有两个变体固定式和浮动式。固定式摇杆的位置从一开始就定死底盘的圆心不会变手指脱离后杆头回中适合对屏幕位置有肌肉记忆需求的产品比如射击游戏右侧开火、左侧走位玩家不需要找按钮按下去就在那。浮动式摇杆则不同手指在屏幕任意位置按下底盘和杆头都会出现在手指下方之后以按下点为中心进行方向控制适合大范围滑动操作、或者操作区域本身不固定的场景。我自己的建议是如果屏幕不大、控件固定放在左下角用固定摇杆就够如果是平板、折叠屏或者需要全屏无死角操作用浮动摇杆的体验会明显更好。浮动摇杆的实现也只比固定摇杆多一个步骤——在 ACTION_DOWN 的时候把圆心坐标改到按下的位置。代码上可控性很强所以我在工程上往往先实现固定摇杆再把与位置相关的逻辑抽象出来这样两种模式只需要一个配置开关。1.3 明确目标先把一个 Joystick 做到可以上生产我见过不少项目把方向控件直接塞进某个 Activity 里写死结果第二个页面要用时只能复制粘贴后面维护特别痛苦。方向控件的本质是一个“输入设备”最好的做法是把它封装成一个独立的自定义 View对外只暴露方向和力度不关心下游是角色、电机还是摄像头。这篇文章的工程目标就是一个可以放到任何 Android 项目里用的 JoystickView有明确的状态管理、有多点触控保护、能输出连续方向量和力度、能区分“按下拖动”和“松开复位”并且样式可以通过代码或 XML 属性简单定制。先把目标定在这里后面所有代码只围绕这个目标展开不会写一堆用不上的花哨功能。2. 动手前先算清楚坐标、向量和角度输出规则2.1 触摸坐标系和绘制坐标系其实是一套Android 里 View 的 onDraw 拿到的 Canvas 坐标系、以及 onTouchEvent 里 MotionEvent 的 getX/getY 坐标系是同一个坐标系原点在 View 左上角X 轴向右Y 轴向下。这意味着你完全可以用“触摸点坐标 - 摇杆中心坐标”直接计算出偏移量不需要做任何坐标系换算。这是新手最容易误解的地方以为要先拿到屏幕坐标再减去 View 的位置其实只有当你跨 View 消费事件比如在 Fragment 根布局里接收触摸再让子 View 绘制时才需要 getRawX 配合 getLocationOnScreen 做换算。在我推荐的这个方案里方向控件自己消费事件、自己绘制完全不用管屏幕坐标那套东西。有一点必须时刻记住屏幕坐标系 Y 轴是向下的所以如果你用 atan2(dy, dx) 计算角度结果会按顺时针方向增加这与数学课本里“逆时针为正”是反的。很多业务对接问题都出在这个地方。方向控件本身没有对错但你必须在接口注释或文档里明确约定输出角度到底是哪个方向的零度、顺时针还是逆时针否则做遥控车的同事拿过去一跑就发现左右反了。2.2 方向控制的基本投影把触摸位移变成 dx/dy摇杆控件的核心逻辑其实只有几行公式。假设摇杆中心在屏幕上的坐标是 (centerX, centerY)当前触摸点在该坐标系下的坐标是 (touchX, touchY)那么触摸点相对于中心的向量偏移就是deltaX touchX - centerX deltaY touchY - centerY我们把 (deltaX, deltaY) 的模长记为 distance它代表手指拖离中心多远。摇杆要输出的核心控制量就是把这段偏移限制在合法范围内然后除以最大可拖动半径得到归一化偏移dx deltaX / maxRadius dy deltaY / maxRadius这里的 dx、dy 范围是 [-1, 1]。如果只想要方向就拿 dx、dy 去算角度如果下游需要力度力度就等于向量长度 sqrt(dx² dy²)。一个方向控制控件最终给业务的无非就是一组 (dx, dy)或者再加一个力度和角度让下游按需取用。2.3 半径限制为什么要做“归一化”如果没有半径限制手指拖出底盘后偏移量会无限增大下游拿到 dx3、dx5 这种数据就完全没法处理。我见过有人用 if 判断“超出范围就截断”其实真正标准的做法是先算出偏移距离 distance当 distance 超过 maxRadius 时把向量等比例缩短让新向量的长度刚好等于 maxRadiusif (distance maxRadius) { deltaX deltaX / distance * maxRadius deltaY deltaY / distance * maxRadius }除以 distance 再乘 maxRadius 的本质是“保留方向、只改变长度”这样摇杆就具有了一个物理限制手指拖到边界处还能继续沿着任意方向滑动但输出向量始终被投影在单位量的边界上。很多第三方摇杆在这个点上处理得很糙直接判定超界就不更新导致用户拖到边角时输出抖动所以我们自己实现时一定要做这种归一化投影。2.4 死区、按力和手感调校的关系硬件摇杆因为弹簧物理特性回正后往往不能完全居中虚拟摇杆虽然没有物理磨损但手指的微小颤动也会导致输出量忽大忽小。这时候就需要引入“死区”的概念也就是把中心附近一段范围内的偏移当作 0 处理。以我的经验死区设置在 5%~10% 之间比较合适太小了压制不住抖动太大了会让操控变得迟钝尤其是遥控车需要精准微调时死区过大很容易出现“点一下没反应、再点一下跑过头”的体验。力度控制和死区其实是两套逻辑死区决定多小的偏移不响应力度决定偏移和输出之间的关系。多数场景用线性力度就行但赛车类游戏为了让低速区更好控制、高速区又不会过于灵敏会用手柄上常见的“指数曲线”或者“分段线性映射”。实际手感参数我会在每个项目的联调阶段和设备真机上反复调没有万能配置但有两条经验第一死区要在力度为 0 的边界上保证连续不要出现跳变第二所有输出值统一在 0~1 范围内到下游再按业务放缩。3. JoystickView 完整实现可以直接抄的代码3.1 从交互行为倒推类的状态写自定义 View 之前先在纸上列状态比到了 Touch 事件里临时写逻辑要稳得多。Joystick 有这些关键状态摇杆中心位置 centerX、centerY固定模式下就是 View 的中心。当前杆头位置 thumbX、thumbY初始等于中心。最大可拖动半径 maxRadius由 View 实际尺寸决定并留出杆头半径的空间。是否处于拖动状态。当前跟踪的触摸点 pointerId用于多点触控保护。这些状态变量基本对应 onDraw 里要画什么、onTouchEvent 里要改什么。我习惯把对外回调单独定义成一个接口放在自定义 View 文件里让下游只看到 onMove 和 onRelease不暴露任何触摸细节。这种解耦在项目里有多个页面需要摇杆时特别有用塞进 Fragment 还是 Dialog 都不影响控件本身。3.2 绘制先不用花哨两个圆就够了绘制方向控件的视觉可以做得非常复杂透明底盘、渐变杆头、拖尾、方向箭头都可以加但核心只需要画两个圆一个相对大的底盘圆、一个相对小的杆头圆。底盘圆如果做得太实会严重遮挡游戏画面所以一般用半透明杆头是用户手指实际控制的目标可以做半透明白色并加一圈描边。为了保持通用性我在正式代码里把颜色用 Paint 对象存放在字段里后续要改成 XML 自定义属性时直接重绑即可不涉及逻辑改动。关于绘制性能有一点值得注意不要在 onDraw 里 new Paint 或者 new 其他对象尤其是 Path、Bitmap。Android 的绘制每一帧都会调用 onDraw你在 onDraw 里创建对象轻则增加 GC 压力重则导致掉帧。Paint 一次性在构造方法里初始化颜色、阴影、描边宽度都不需要频繁变真正变化的只有 thumbX、thumbY 和是否在拖动。千万不要小看这个点我见过不少项目就是在这种“舒服的封装习惯”里卡出掉帧的。3.3 触控与多点触控处理别让第二根手指抢走摇杆摇杆的单指逻辑很简单按下时记录 pointerId移动时用这个 pointerId 的坐标更新杆头抬起时复位。但如果直接把 ACTION_DOWN 和 ACTION_MOVE 当成单指处理一旦用户在摇杆附近再次按下第二根手指MotionEvent 会插入 ACTION_POINTER_DOWN而 actionIndex 会变化很多初学者用 event.getX() 直接取坐标结果第二根手指按下的一瞬间摇杆就跳过去了。解决方式是用 getPointerId 保存当前手指的标识之后所有更新都用 findPointerIndex(activePointerId) 找到真正属于该手指的坐标其他手指按下不响应。抬起分支里同样要区分第二根手指抬起走的 ACTION_POINTER_UP如果抬起的手指不是 activePointerId直接忽略只有被跟踪的手指抬起才走“复位 回调 onRelease”的逻辑。为什么大多数摇杆项目建议主动消费事件并返回 true因为这样才能通知父布局“这个事件我接手了别拿去做滚动、点击”。如果方向控件上方还有 ScrollView、ViewPager 这类可滚动容器还需要在按下时调用 getParent().requestDisallowInterceptTouchEvent(true)否则父容器可能抢走滑动事件。3.4 完整类代码经过上面所有分析最终可以直接落地的类是下面这个样子。我会在里面保留必要的中文注释方便你直接改造成自己项目里需要的样子。package com.example.remote; import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Canvas; import android.graphics.Paint; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import androidx.annotation.Nullable; public class JoystickView extends View { public interface JoystickListener { /** dx、dy 取值范围 [-1,1]strength 取值范围 [0,1] */ void onMove(float dx, float dy, float angleDeg, float strength); void onRelease(); } private final Paint basePaint new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint baseRingPaint new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint thumbPaint new Paint(Paint.ANTI_ALIAS_FLAG); private final float density getResources().getDisplayMetrics().density; private final float thumbRadius 36 * density; SuppressWarnings(FieldCanBeLocal) private final float baseRadius 58 * density; private float centerX; private float centerY; private float maxRadius; private float thumbX; private float thumbY; private boolean touching; private int activePointerId -1; private float deadZone 0.08f; Nullable private JoystickListener listener; public JoystickView(Context context) { this(context, null); } public JoystickView(Context context, Nullable AttributeSet attrs) { this(context, attrs, 0); } public JoystickView(Context context, Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); basePaint.setColor(0x33000000); baseRingPaint.setStyle(Paint.Style.STROKE); baseRingPaint.setStrokeWidth(3 * density); baseRingPaint.setColor(0x66FFFFFF); thumbPaint.setColor(0xE6FFFFFF); thumbPaint.setStyle(Paint.Style.FILL); thumbPaint.setStrokeWidth(2 * density); thumbPaint.setShadowLayer(6 * density, 0, 2 * density, 0x66000000); resetThumb(); } public void setJoystickListener(Nullable JoystickListener l) { this.listener l; } public void setDeadZone(float deadZone) { this.deadZone Math.max(0f, Math.min(0.3f, deadZone)); } private float dp(float value) { return value * density; } private void resetThumb() { thumbX centerX; thumbY centerY; } Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); centerX w / 2f; centerY h / 2f; maxRadius Math.min(centerX, centerY) - thumbRadius - dp(4); if (maxRadius 1) { maxRadius 1; } resetThumb(); } Override protected void onDraw(Canvas canvas) { // 底盘 canvas.drawCircle(centerX, centerY, baseRadius, basePaint); canvas.drawCircle(centerX, centerY, baseRadius - dp(2), baseRingPaint); // 可拖区域提示用虚线或半透明环来表示画一个略大的圆环 if (touching) { canvas.drawCircle(centerX, centerY, maxRadius, baseRingPaint); } // 摇杆杆头 canvas.drawCircle(thumbX, thumbY, thumbRadius, thumbPaint); } SuppressLint(ClickableViewAccessibility) Override public boolean onTouchEvent(MotionEvent event) { int action event.getActionMasked(); switch (action) { case MotionEvent.ACTION_DOWN: { activePointerId event.getPointerId(event.getActionIndex()); touching true; getParent().requestDisallowInterceptTouchEvent(true); updateThumb(event); return true; } case MotionEvent.ACTION_MOVE: { if (activePointerId -1) { return true; } updateThumb(event); return true; } case MotionEvent.ACTION_POINTER_UP: { int pointerIndex event.getActionIndex(); int pointerId event.getPointerId(pointerIndex); if (pointerId activePointerId) { release(); } return true; } case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: { release(); return true; } } return super.onTouchEvent(event); } private void updateThumb(MotionEvent event) { int pointerIndex event.findPointerIndex(activePointerId); if (pointerIndex -1) { return; } float touchX event.getX(pointerIndex); float touchY event.getY(pointerIndex); float deltaX touchX - centerX; float deltaY touchY - centerY; float distance (float) Math.hypot(deltaX, deltaY); if (distance maxRadius) { deltaX deltaX / distance * maxRadius; deltaY deltaY / distance * maxRadius; } thumbX centerX deltaX; thumbY centerY deltaY; invalidate(); if (listener null) { return; } float dx deltaX / maxRadius; float dy deltaY / maxRadius; float strength (float) Math.hypot(dx, dy); if (strength deadZone) { dx 0f; dy 0f; strength 0f; } // 角度采用屏幕坐标系X 轴向右为 0°顺时针增加。 // 如果需要“0°朝上”可在下游换算finalAngle (angle 90f 360f) % 360f float angle (float) Math.toDegrees(Math.atan2(dy, dx)); listener.onMove(dx, dy, angle, strength); } private void release() { touching false; activePointerId -1; resetThumb(); invalidate(); if (listener ! null) { listener.onRelease(); } } }代码里已经处理了半径限位、死区、多点触控、父容器拦截这几个核心问题。有一点要说明固定摇杆模式下的 centerX 和 centerY 取的是 View 的几何中心所以在布局里通常要把这个 View 设成固定尺寸比如 160dp × 160dp不要用 wrap_content否则 View 尺寸不好预测。onSizeChanged 里用 Math.min(centerX, centerY) 算 maxRadius是为了兼容 View 被拉伸成矩形时不至于溢出虽然实际部署时我还是建议保持正方形布局。3.5 Activity 接入与验证控件写好后在 Activity 里用起来非常简单。假设我们要在一个遥控页面的左下角放一个摇杆然后用 TextView 或自绘小车实时观察输出布局文件里只需要放置控件和一块显示区域。接入时代码如下JoystickView joystick findViewById(R.id.joystick); joystick.setJoystickListener(new JoystickView.JoystickListener() { Override public void onMove(float dx, float dy, float angleDeg, float strength) { String text dx dx dy dy angle angleDeg strength strength; valueView.setText(text); // 这里再把你自己的控制指令下发出去 // robotController.send(dx, dy, strength); } Override public void onRelease() { valueView.setText(松开复位); } });从这个接入方式就看出来控件本身不关心下游是什么它只负责把用户手指的动作翻译成一套干净的数据。真正做业务逻辑的人拿到 dx、dy、angle、strength 这四个值就够了而正因为控件对外接口极简后续换成 D-pad 或方向盘时页面上只需要替换控件实例监听器代码甚至可以保持一致。4. 方向量如何接到业务上协议和扩展形态4.1 你给业务方的不应该只是一个“移动事件”很多第一次做方向控件的人最容易犯的错误是把监听器回调写成了“方向键事件”比如 onUp、onDown、onLeft、onRight然后业务代码里用一堆 if 判断处理。虚拟摇杆天然是连续量你把它强行拆成离散事件等于丢掉了一大半的精度。正确做法是像上面代码那样输出 dx、dy 和 strength让业务方按自己的需求决定离散化还是直接用连续量。比如只在 strength 大于 0.5 时才响应“高速模式”或者把 angle 四舍五入成 8 个方向去驱动方向键逻辑这些都应该放在业务层而不应该放在控件内部。我之前在一个竞速类项目里就把控件回调抽象成“向量输出”回头发现测试同学调车辆越障能力时直接在监听器里做了速度映射完全不需要动控件代码。这种时候尤其能体会到接口小而清晰带来的价值。4.2 遥控车/机械臂/游戏角色里的常见映射方式假设下游是一台双轮差速小车左右轮速度分别是 leftSpeed、rightSpeed常见映射方式是用 dy 控制前后速度、dx 控制转向强度前半油门 前向速度 * (1 转向系数) (这里只是粗略示意实际项目会加限幅)如果我是直接驱动麦克纳姆轮小车就需要把 dx、dy 再结合车体姿态做一次旋转矩阵变换但这一步更应该由底盘驱动 SDK 来做方向的控件只需要输出准确且一致的 dx/dy 即可。与游戏角色对接时最大的坑还是坐标方向游戏世界里的 Y 轴很多是“向上的”而 Android 屏幕坐标系 Y 轴向下。如果你把摇杆向上推动得到的 dy 原样传给游戏引擎角色会向后跑。常见处理方式是“发送给游戏引擎前将 dy 取反”或者在陀螺仪/相机相关的项目里把它换算成“目标移动方向在世界坐标系的向量”。你要做的是在项目文档里明确记录这次对接的坐标系约定不要默认两端一定一致。4.3 D-pad 和方向盘两个变体的实现思路如果遇到只需要方向盘或 D-pad 的需求不需要再从零开写一个类可以在 JoystickView 基础上改。D-pad 最容易把输出量按角度离散化成 8 个方向映射表如下输入角度范围离散结果337.5°~22.5°上22.5°~67.5°右上67.5°~112.5°右112.5°~157.5°右下157.5°~202.5°下202.5°~247.5°左下247.5°~292.5°左292.5°~337.5°左上方向盘变体则需要记录旋转角度的累计值。直接拿手指当前角度控制车轮有一个经典问题角度从 -179° 到 179° 跳变时如果把差值直接相加方向盘的转角会瞬间反转。解决方法是做角度差的最短路径换算diff ((targetAngle - currentAngle 540) % 360) - 180 currentAngle diff这段计算的作用是始终选择两个角度之间小于 180° 的那条路避免跨零跳变。方向盘通常还可以加入回正阻尼和最大转角限制物理手感会比摇杆复杂一些但核心的坐标和触摸状态机与 JoystickView 完全一致。5. 上线前的调校与常见坑5.1 手感调校顺序从逻辑到真机我在实际项目里调手感时一般按照这个顺序先接上输出日志或者 UI 反馈确认控件本身输出的 dx/dy 平滑再让业务方在下游做一个示波器式反馈把力度值实时刷新到界面上找出断点或跳变最后才让更多人上手体验真实控制。这三个层次如果倒过来做一会儿怀疑控件、一会儿怀疑通信、一会儿怀疑业务方向效率会非常低。还有一点想单独说真机调试时尽量多换几台不同分辨率的手机。分辨率差距很大的设备上同样 160dp 的控件实际像素完全不同但因为所有内部计算都是相对 maxRadius 的归一化手感理论上应该一致。如果发现某台机器上摇杆“很轻”或“很重”大概率不是控件逻辑问题而是外层布局把控件做了非等比缩放或者有系统级触摸灵敏度设置干扰。5.2 常见问题速查表我把这两年调试方向控件常见的坑整理成一个表格方便你开发时快速定位现象可能原因处理方法摇杆按下后瞬间跳到最右边或底部没有在 onSizeChanged 里重新计算 centerX/centerY确保宽高