摇杆控制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>
相关推荐
游戏开发爱好者86 小时前
iOS 抓包工具实战 开发者的工具矩阵与真机排查流程
android·ios·小程序·https·uni-app·iphone·webview
明道源码9 小时前
Kotlin Multiplatform 跨平台方案解析以及热门框架对比
开发语言·kotlin·cocoa
马 孔 多 在下雨15 小时前
安卓开发popupWindow的使用
android
asfdsfgas15 小时前
从 SSP 配置到 Gradle 同步:Android SDK 开发中 Manifest 合并冲突的踩坑记录
android
zhaoyufei13315 小时前
RK3399 11.0关闭调试串口改为普通RS232通信串口
android·驱动开发
消失的旧时光-194315 小时前
Kotlin 协程最佳实践:用 CoroutineScope + SupervisorJob 替代 Timer,实现优雅周期任务调度
android·开发语言·kotlin
错把套路当深情15 小时前
Kotlin保留小数位的三种方法
开发语言·python·kotlin
错把套路当深情15 小时前
Kotlin基础类型扩展函数使用指南
python·微信·kotlin
Frank_HarmonyOS15 小时前
Kotlin 协程之launch、async、suspend 函数和调度器(Dispatchers)
kotlin