摇杆控制View

功能特性:

  • 支持360度方向检测

  • 摇杆手柄跟随触摸移动

  • 手柄限制在摇杆圆盘范围内

  • 使用协程实现高频率回调(60fps)

  • 提供X/Y偏移百分比、角度、幅度等信息

正文

复制代码
RobotRocker
Kotlin 复制代码
package com.example.dsbridge_demo

import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import kotlinx.coroutines.*
import kotlin.math.*

/**
 * 摇杆控制View
 * 功能特性:
 * - 支持360度方向检测
 * - 摇杆手柄跟随触摸移动
 * - 手柄限制在摇杆圆盘范围内
 * - 使用协程实现高频率回调(60fps)
 * - 提供X/Y偏移百分比、角度、幅度等信息
 */
class RobotRocker @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    // ==================== 位置和尺寸 ====================
    /** 圆盘中心X坐标 */
    private var centerX = 0f
    /** 圆盘中心Y坐标 */
    private var centerY = 0f
    /** 圆盘半径 */
    private var baseRadius = 0f
    /** 手柄半径 */
    private var hatRadius = 0f
    /** 手柄当前位置X坐标 */
    private var currentX = 0f
    /** 手柄当前位置Y坐标 */
    private var currentY = 0f

    // ==================== 画笔 ====================
    /** 圆盘画笔 */
    private val basePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.LTGRAY
        style = Paint.Style.FILL
    }
    /** 手柄画笔 */
    private val hatPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.DKGRAY
        style = Paint.Style.FILL
    }

    // ==================== 监听和协程 ====================
    /** 摇杆监听器 */
    private var listener: RockerListener? = null
    /** 协程任务 */
    private var rockerJob: Job? = null
    /** 协程作用域 */
    private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
    
    /** 回调间隔(毫秒),16ms约60fps */
    private val callbackInterval = 16L

    /**
     * View尺寸改变时调用,重新计算圆盘和手柄的参数
     */
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        // 圆盘中心位于View中心
        centerX = width / 2f
        centerY = height / 2f
        // 初始化手柄位置为圆盘中心
        currentX = centerX
        currentY = centerY
        // 圆盘半径取View宽度和高度的最小值的1/3
        baseRadius = (minOf(width, height) / 3f)
        // 手柄半径取View宽度和高度的最小值的1/6
        hatRadius = (minOf(width, height) / 6f)
    }

    /**
     * 绘制View
     */
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // 绘制摇杆圆盘(灰色圆形)
        canvas.drawCircle(centerX, centerY, baseRadius, basePaint)
        // 绘制摇杆手柄(深灰色圆形)
        canvas.drawCircle(currentX, currentY, hatRadius, hatPaint)
    }

    /**
     * 处理触摸事件
     */
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        event ?: return false
        
        return when (event.action) {
            MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
                handleTouch(event.x, event.y, event.action == MotionEvent.ACTION_DOWN)
                true
            }
            MotionEvent.ACTION_UP -> {
                resetRocker()
                true
            }
            else -> super.onTouchEvent(event)
        }
    }
    
    /**
     * 处理触摸位置
     * @param x 触摸点X坐标
     * @param y 触摸点Y坐标
     * @param isActionDown 是否为按下事件
     */
    private fun handleTouch(x: Float, y: Float, isActionDown: Boolean) {
        // 计算触摸点到圆心的距离
        val distance = sqrt((x - centerX).pow(2) + (y - centerY).pow(2))
        
        // 限制手柄在圆盘范围内
        if (distance <= baseRadius) {
            currentX = x
            currentY = y
        } else {
            // 触摸点超出范围,将手柄限制在圆盘边缘
            val angle = atan2(y - centerY, x - centerX)
            currentX = centerX + cos(angle) * baseRadius
            currentY = centerY + sin(angle) * baseRadius
        }
        
        invalidate()
        
        // 按下时启动协程回调
        if (isActionDown && listener != null) {
            startCallbackLoop()
        }
    }
    
    /**
     * 启动回调循环
     */
    private fun startCallbackLoop() {
        rockerJob?.cancel()
        rockerJob = coroutineScope.launch {
            while (isActive) {
                val xPercent = (currentX - centerX) / baseRadius
                val yPercent = (currentY - centerY) / baseRadius
                val angle = Math.toDegrees(atan2(-yPercent.toDouble(), -xPercent.toDouble())).toFloat()
                val magnitude = sqrt(xPercent.pow(2) + yPercent.pow(2)) * 100f
                listener?.onRockerMoved(xPercent, yPercent, angle, magnitude)
                delay(callbackInterval)
            }
        }
    }
    
    /**
     * 重置摇杆到中心位置
     */
    private fun resetRocker() {
        currentX = centerX
        currentY = centerY
        invalidate()
        listener?.onRockerMoved(0f, 0f, 0f, 0f)
        rockerJob?.cancel()
        rockerJob = null
    }

    /**
     * 设置摇杆监听器
     * @param listener 摇杆监听器
     */
    fun setRockerListener(listener: RockerListener?) {
        this.listener = listener
    }
    
    /**
     * View从窗口分离时调用,清理资源
     */
    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        rockerJob?.cancel()
        coroutineScope.cancel()
    }

    /**
     * 摇杆监听器接口
     */
    interface RockerListener {
        /**
         * 摇杆移动回调
         * @param xPercent X轴偏移百分比(-1到1)
         * @param yPercent Y轴偏移百分比(-1到1)
         * @param angle 角度(-180到180度)
         * @param magnitude 移动幅度(0到100)
         */
        fun onRockerMoved(xPercent: Float, yPercent: Float, angle: Float, magnitude: Float)
    }
}
复制代码
DirectionalDiskActivity
Kotlin 复制代码
package com.example.dsbridge_demo

import android.os.Bundle
import android.util.Log
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity

/**
 * 摇杆控制演示Activity
 */
class DirectionalDiskActivity : AppCompatActivity() {

    private lateinit var robotRocker: RobotRocker
    private lateinit var infoText: TextView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_directional_disk)

        initViews()
        setupRockerListener()
    }

    private fun initViews() {
        robotRocker = findViewById(R.id.robotRocker)
        infoText = findViewById(R.id.directionText)
    }

    /**
     * 设置摇杆监听器
     */
    private fun setupRockerListener() {
        robotRocker.setRockerListener(object : RobotRocker.RockerListener {
            override fun onRockerMoved(xPercent: Float, yPercent: Float, angle: Float, magnitude: Float) {
                Log.d("RobotRocker", "X: $xPercent, Y: $yPercent, Angle: $angle, Magnitude: $magnitude")
                
                val direction = getDirectionByAngle(angle, magnitude)
                updateInfoText(xPercent, yPercent, angle, magnitude, direction)
            }
        })
    }

    /**
     * 根据角度和幅度获取方向
     * @param angle 角度(-180到180度)
     * @param magnitude 幅度(0到100)
     * @return 方向文字
     */
    private fun getDirectionByAngle(angle: Float, magnitude: Float): String {
        return when {
            magnitude < 10f -> "无"
            angle >= -22.5 && angle < 22.5 -> "右"
            angle >= 22.5 && angle < 67.5 -> "右上"
            angle >= 67.5 && angle < 112.5 -> "上"
            angle >= 112.5 && angle < 157.5 -> "左上"
            (angle >= 157.5 && angle <= 180) || (angle >= -180 && angle < -157.5) -> "左"
            angle >= -157.5 && angle < -112.5 -> "左下"
            angle >= -112.5 && angle < -67.5 -> "下"
            angle >= -67.5 && angle < -22.5 -> "右下"
            else -> "无"
        }
    }

    /**
     * 更新信息显示
     */
    private fun updateInfoText(
        xPercent: Float,
        yPercent: Float,
        angle: Float,
        magnitude: Float,
        direction: String
    ) {
        infoText.text = buildString {
            appendLine("摇杆状态")
            appendLine("X偏移: ${String.format("%.2f", xPercent * 100)}%")
            appendLine("Y偏移: ${String.format("%.2f", yPercent * 100)}%")
            appendLine("角度: ${String.format("%.1f", angle)}°")
            appendLine("幅度: ${String.format("%.1f", magnitude)}%")
            append("方向: $direction")
        }
    }
}
复制代码
activity_directional_disk
html 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp"
    android:background="#FAFAFA">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="摇杆控制"
        android:textSize="24sp"
        android:textColor="#333333"
        android:textStyle="bold"
        android:gravity="center"
        android:layout_marginBottom="32dp" />

    <com.example.dsbridge_demo.RobotRocker
        android:id="@+id/robotRocker"
        android:layout_width="300dp"
        android:layout_height="300dp"
        android:layout_gravity="center" />

    <TextView
        android:id="@+id/directionText"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="当前方向: 无"
        android:textSize="18sp"
        android:textColor="#666666"
        android:gravity="center"
        android:layout_marginTop="32dp"
        android:padding="16dp"
        android:background="#E0E0E0" />

</LinearLayout>
相关推荐
千里马学框架4 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台4 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone4 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc4 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo4 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077004 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
ai2work4 天前
ch23 综合复刻:从零做一个最小可用版本(capstone)
kotlin
其实防守也摸鱼4 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
ai2work4 天前
ch21 签名、校验与发版
kotlin
AFinalStone4 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui