8方向控制圆盘View

下载demo:

复制代码
8方向控制圆盘-android

功能特性:

  • 支持8个方向检测(上、右上、右、右下、下、左下、左、左上)

  • 支持触摸交互,实时检测触摸位置方向

  • 支持连续发送模式(按住时持续发送方向信息)

  • 支持自定义颜色和文字大小(XML和代码两种方式)

  • 支持显示/隐藏方向文字和标记点(XML和代码两种方式)

  • 使用Kotlin协程实现定时发送,避免内存泄漏

正文

DirectionalDiskView

Kotlin 复制代码
package com.example.myapplication

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

/**
 * 8方向控制圆盘View
 * 
 * 功能特性:
 * - 支持8个方向检测(上、右上、右、右下、下、左下、左、左上)
 * - 支持触摸交互,实时检测触摸位置方向
 * - 支持连续发送模式(按住时持续发送方向信息)
 * - 支持自定义颜色和文字大小(XML和代码两种方式)
 * - 支持显示/隐藏方向文字和标记点(XML和代码两种方式)
 * - 使用Kotlin协程实现定时发送,避免内存泄漏
 * 
 * 使用示例:
 * ```
 * // 基础用法
 * directionalDiskView.onDirectionChangeListener = { direction, directionName ->
 *     Log.d("Direction", "当前方向: $directionName")
 * }
 * 
 * // 启用连续发送
 * directionalDiskView.enableContinuousSending = true
 * directionalDiskView.continuousSendInterval = 200L  // 每200ms发送一次
 * 
 * // 动态控制显示/隐藏
 * directionalDiskView.showDirectionText = false  // 隐藏文字
 * directionalDiskView.showDirectionMarkers = false  // 隐藏标记点
 * ```
 * 
 * XML属性:
 * - app:diskColor - 圆盘颜色
 * - app:directionColor - 方向标记点颜色
 * - app:indicatorColor - 方向指示器颜色
 * - app:textColor - 文字颜色
 * - app:textSize - 文字大小
 * - app:showDirectionText - 是否显示方向文字(boolean)
 * - app:showDirectionMarkers - 是否显示方向标记点(boolean)
 */
class DirectionalDiskView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    companion object {
        /** 无方向 */
        const val DIRECTION_NONE = -1
        /** 上方向 */
        const val DIRECTION_UP = 0
        /** 右上方向 */
        const val DIRECTION_UP_RIGHT = 1
        /** 右方向 */
        const val DIRECTION_RIGHT = 2
        /** 右下方向 */
        const val DIRECTION_DOWN_RIGHT = 3
        /** 下方向 */
        const val DIRECTION_DOWN = 4
        /** 左下方向 */
        const val DIRECTION_DOWN_LEFT = 5
        /** 左方向 */
        const val DIRECTION_LEFT = 6
        /** 左上方向 */
        const val DIRECTION_UP_LEFT = 7
    }

    // ==================== 颜色和样式属性 ====================
    /** 圆盘颜色,默认蓝色 */
    private var diskColor = "#4285F4".toColorInt()
    /** 方向标记点颜色,默认绿色 */
    private var directionColor = "#34A853".toColorInt()
    /** 方向指示器颜色,默认红色 */
    private var indicatorColor = "#EA4335".toColorInt()
    /** 文字颜色,默认白色 */
    private var textColor = Color.WHITE
    /** 文字大小,默认36sp */
    private var textSize = 36f
    /** 背景色缓存 */
    private val backgroundColor = "#FAFAFA".toColorInt()

    // ==================== 显示控制属性 ====================
    /**
     * 是否显示方向文字,默认true
     * 支持XML和代码动态设置,修改后会自动重绘
     * 
     * 使用示例:
     * - XML: app:showDirectionText="false"
     * - 代码: directionalDiskView.showDirectionText = false
     */
    var showDirectionText = true
        set(value) {
            field = value
            invalidate()  // 自动重绘View
        }
    
    /**
     * 是否显示方向标记点,默认true
     * 支持XML和代码动态设置,修改后会自动重绘
     * 
     * 使用示例:
     * - XML: app:showDirectionMarkers="false"
     * - 代码: directionalDiskView.showDirectionMarkers = false
     */
    var showDirectionMarkers = true
        set(value) {
            field = value
            invalidate()  // 自动重绘View
        }
    
    // ==================== 画笔 ====================
    /** 圆盘画笔 */
    private val diskPaint = Paint(Paint.ANTI_ALIAS_FLAG)
    /** 方向标记点画笔 */
    private val directionPaint = Paint(Paint.ANTI_ALIAS_FLAG)
    /** 文字画笔 */
    private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG)
    /** 方向指示器画笔 */
    private val indicatorPaint = Paint(Paint.ANTI_ALIAS_FLAG)
    /** 中心点画笔 */
    private val centerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.WHITE
        style = Paint.Style.FILL
    }

    // ==================== 数据和UI ====================
    /** 8个方向的中文名称数组 */
    private val directionNames = arrayOf("上", "右上", "右", "右下", "下", "左下", "左", "左上")
    
    /** 圆盘中心点X坐标 */
    private var centerX = 0f
    /** 圆盘中心点Y坐标 */
    private var centerY = 0f
    /** 圆盘半径 */
    private var diskRadius = 0f
    
    /** 当前选中的方向(只读) */
    var currentDirection = DIRECTION_NONE
        private set

    /** 方向变化监听器,参数:方向常量、方向名称 */
    var onDirectionChangeListener: ((Int, String) -> Unit)? = null
    
    // ==================== 连续发送相关属性 ====================
    /** 是否启用连续发送模式,默认false(触摸一次只发送一次) */
    var enableContinuousSending = false
    /** 连续发送时间间隔(毫秒),默认200ms */
    var continuousSendInterval = 200L
    
    /** 协程作用域,用于管理协程生命周期 */
    private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
    /** 连续发送任务Job */
    private var continuousJob: Job? = null

    init {
        setupAttributes(attrs)
        setupPaints()
    }

    /**
     * 从XML属性中读取配置
     * 支持自定义:diskColor, directionColor, indicatorColor, textColor, textSize, 
     * showDirectionText, showDirectionMarkers
     */
    private fun setupAttributes(attrs: AttributeSet?) {
        attrs?.let {
            context.withStyledAttributes(it, R.styleable.DirectionalDiskView) {

                diskColor = getColor(
                    R.styleable.DirectionalDiskView_diskColor,
                    diskColor
                )
                directionColor = getColor(
                    R.styleable.DirectionalDiskView_directionColor,
                    directionColor
                )
                indicatorColor = getColor(
                    R.styleable.DirectionalDiskView_indicatorColor,
                    indicatorColor
                )
                textColor = getColor(
                    R.styleable.DirectionalDiskView_textColor,
                    textColor
                )
                textSize = getDimension(
                    R.styleable.DirectionalDiskView_textSize,
                    textSize
                )
                showDirectionText = getBoolean(
                    R.styleable.DirectionalDiskView_showDirectionText,
                    showDirectionText
                )
                showDirectionMarkers = getBoolean(
                    R.styleable.DirectionalDiskView_showDirectionMarkers,
                    showDirectionMarkers
                )

            }
        }
    }

    /**
     * 初始化各个画笔的属性
     */
    private fun setupPaints() {
        diskPaint.apply {
            color = diskColor
            style = Paint.Style.FILL
        }
        
        directionPaint.apply {
            color = directionColor
            style = Paint.Style.FILL
        }
        
        textPaint.apply {
            color = textColor
            textSize = this@DirectionalDiskView.textSize
            textAlign = Paint.Align.CENTER
            typeface = Typeface.DEFAULT_BOLD
        }
        
        indicatorPaint.apply {
            color = indicatorColor
            style = Paint.Style.FILL
        }
    }

    /**
     * View尺寸改变时调用,重新计算圆盘的中心点和半径
     */
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        // 圆盘圆心位于View中心
        centerX = w / 2f
        centerY = h / 2f
        // 圆盘半径取宽度和高度的最小值的三分之一
        diskRadius = minOf(w, h) / 3f
    }

    /**
     * 绘制View的主要方法
     * 绘制顺序:背景 -> 圆盘 -> 方向指示器 -> 方向标记 -> 中心点
     */
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        
        // 绘制背景
        canvas.drawColor(backgroundColor) // 不再每帧解析字符串为颜色
        
        // 绘制圆盘
        canvas.drawCircle(centerX, centerY, diskRadius, diskPaint)
        
        // 如果有选中的方向,绘制方向指示器
        if (currentDirection != DIRECTION_NONE) {
            drawDirectionIndicator(canvas, currentDirection)
        }
        
        // 绘制8个方向的标记点和文字
        drawDirectionMarkers(canvas)
        
        // 绘制中心白色圆点
        drawCenterPoint(canvas)
    }

    /**
     * 绘制8个方向的标记点和文字
     * 标记点位于半径的0.7倍位置,成8等分圆形分布
     */
    private fun drawDirectionMarkers(canvas: Canvas) {
        // 如果标记点和文字都不显示,则直接返回
        if (!showDirectionMarkers && !showDirectionText) {
            return
        }
        
        for (i in 0 until 8) {
            // 计算每个标记点的角度位置(逆时针从上方开始)
            val angle = i * PI / 4
            // 计算标记点的坐标
            val x = centerX + (diskRadius * 0.7f * sin(angle).toFloat())
            val y = centerY - (diskRadius * 0.7f * cos(angle).toFloat())
            
            // 绘制方向标记点(如果启用)
            if (showDirectionMarkers) {
                canvas.drawCircle(x, y, 15f, directionPaint)
            }
            
            // 绘制方向文字(如果启用,相对于标记点稍微下移)
            if (showDirectionText) {
                canvas.drawText(directionNames[i], x, y + 12f, textPaint)
            }
        }
    }

    /**
     * 绘制当前选中方向的指示器(红色圆点)
     * 位于半径的0.4倍位置
     */
    private fun drawDirectionIndicator(canvas: Canvas, direction: Int) {
        // 计算指示器的角度
        val angle = direction * PI / 4
        // 指示器距离圆心的距离是半径的0.4倍
        val indicatorRadius = diskRadius * 0.4f
        // 计算指示器的坐标
        val x = centerX + (indicatorRadius * sin(angle).toFloat())
        val y = centerY - (indicatorRadius * cos(angle).toFloat())
        
        // 绘制红色指示器圆点
        canvas.drawCircle(x, y, 30f, indicatorPaint)
    }

    /**
     * 绘制中心白色圆点
     */
    private fun drawCenterPoint(canvas: Canvas) {

        canvas.drawCircle(centerX, centerY, 20f, centerPaint)
    }

    /**
     * 处理触摸事件
     * - ACTION_DOWN/ACTION_MOVE: 处理触摸位置,检测方向
     * - ACTION_UP: 重置方向,触发"无"方向回调
     */
    override fun onTouchEvent(event: MotionEvent): Boolean {
        return when (event.action) {
            MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
                handleTouch(event.x, event.y)
                true
            }
            MotionEvent.ACTION_UP -> {
                resetDirection()
                true
            }
            else -> super.onTouchEvent(event)
        }
    }

    /**
     * 处理触摸位置,检测方向
     * @param x 触摸点X坐标
     * @param y 触摸点Y坐标
     */
    private fun handleTouch(x: Float, y: Float) {
        // 计算触摸点到圆心的距离
        val distance = sqrt((x - centerX).pow(2) + (y - centerY).pow(2))
        
        // 如果触摸点在圆盘范围内
        if (distance <= diskRadius) {
            // 计算触摸点相对于圆心的角度(角度范围0-2π)
            val angle = atan2(x - centerX, centerY - y).let { 
                if (it < 0) it + 2f * PI.toFloat() else it 
            }
            // 将角度转换为8个方向之一(0-7)
            val direction = (angle / (PI.toFloat() / 4f)).roundToInt() % 8
            
            // 如果方向发生变化
            if (currentDirection != direction) {
                currentDirection = direction
                invalidate()  // 重绘View,显示新的指示器
                
                if (enableContinuousSending) {
                    // 启用连续发送模式
                    startContinuousSending(direction)
                } else {
                    // 单次发送模式,只调用一次监听器
                    onDirectionChangeListener?.invoke(direction, directionNames[direction])
                }
            } else if (enableContinuousSending && currentDirection == direction && currentDirection != DIRECTION_NONE) {
                // 如果方向没有变化,但需要连续发送,确保协程正在运行
                if (continuousJob == null || !continuousJob!!.isActive) {
                    startContinuousSending(direction)
                }
            }
        } else {
            // 触摸点在圆盘外,重置方向
            resetDirection()
        }
    }

    /**
     * 重置方向为"无",并停止连续发送
     */
    private fun resetDirection() {
        stopContinuousSending()
        if (currentDirection != DIRECTION_NONE) {
            currentDirection = DIRECTION_NONE
            invalidate()  // 重绘View,隐藏指示器
            onDirectionChangeListener?.invoke(DIRECTION_NONE, "无")
        }
    }
    
    /**
     * 启动连续发送任务(使用协程)
     * 立即发送一次,然后每隔[continuousSendInterval]毫秒发送一次
     * @param direction 要发送的方向
     */
    private fun startContinuousSending(direction: Int) {
        // 停止之前的发送任务
        stopContinuousSending()
        
        // 立即发送一次
        onDirectionChangeListener?.invoke(direction, directionNames[direction])
        
        // 使用协程启动连续发送任务
        continuousJob = coroutineScope.launch {
            // 循环条件:方向仍然是该方向,且有方向,且协程处于活动状态
            while (currentDirection == direction && currentDirection != DIRECTION_NONE && isActive) {
                delay(continuousSendInterval)
                // 再次检查方向是否还是相同的方向(可能在delay期间发生了变化)
                if (currentDirection == direction && isActive) {
                    onDirectionChangeListener?.invoke(direction, directionNames[direction])
                }
            }
        }
    }
    
    /**
     * 停止连续发送任务
     */
    private fun stopContinuousSending() {
        continuousJob?.cancel()
        continuousJob = null
    }
    
    /**
     * View从窗口分离时调用,清理资源,防止内存泄漏
     */
    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        // 确保在View销毁时停止协程和定时器
        stopContinuousSending()
        coroutineScope.cancel()  // 取消整个协程作用域
    }
}
复制代码
attrs.xml
html 复制代码
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="DirectionalDiskView">
        <attr name="diskColor" format="color"/>
        <attr name="directionColor" format="color"/>
        <attr name="indicatorColor" format="color"/>
        <attr name="textColor" format="color"/>
        <attr name="textSize" format="dimension"/>
        <!-- 是否显示方向文字,默认true -->
        <attr name="showDirectionText" format="boolean"/>
        <!-- 是否显示方向标记点,默认true -->
        <attr name="showDirectionMarkers" format="boolean"/>
    </declare-styleable>
</resources>
复制代码
DirectionalDiskActivity
Kotlin 复制代码
package com.example.myapplication

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

class DirectionalDiskActivity : AppCompatActivity() {

    private lateinit var directionDisk: DirectionalDiskView
    private lateinit var advancedDirectionDisk: DirectionalDiskView
    private lateinit var minimalDirectionDisk: DirectionalDiskView
    private lateinit var directionText: TextView

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

        initViews()
        setupListeners()
    }

    private fun initViews() {
        directionDisk = findViewById(R.id.directionDisk)
        advancedDirectionDisk = findViewById(R.id.advancedDirectionDisk)
        minimalDirectionDisk = findViewById(R.id.minimalDirectionDisk)
        directionText = findViewById(R.id.directionText)
    }

    private fun setupListeners() {
        // 启用连续发送功能(按住时持续发送)
        directionDisk.enableContinuousSending = true
        directionDisk.continuousSendInterval = 200L  // 每200ms发送一次
        
        advancedDirectionDisk.enableContinuousSending = true
        advancedDirectionDisk.continuousSendInterval = 200L  // 每200ms发送一次
        
        minimalDirectionDisk.enableContinuousSending = true
        minimalDirectionDisk.continuousSendInterval = 200L  // 每200ms发送一次
        
        // 演示在代码中动态控制显示/隐藏
        // 可以在运行时根据条件动态调整
         directionDisk.showDirectionText = false  // 隐藏文字
         directionDisk.showDirectionMarkers = false  // 隐藏标记点
        
        // 基础圆盘监听
        directionDisk.onDirectionChangeListener = { direction, directionName ->
            Log.d("DirectionalDisk", "基础圆盘 - 方向: $direction, 名称: $directionName")
            updateDirectionText("基础圆盘", direction, directionName)
        }

        // 高级圆盘监听
        advancedDirectionDisk.onDirectionChangeListener = { direction, directionName ->
            Log.d("DirectionalDisk", "高级圆盘 - 方向: $direction, 名称: $directionName")
            updateDirectionText("高级圆盘", direction, directionName)
        }
        
        // 隐藏文字的圆盘监听
        minimalDirectionDisk.onDirectionChangeListener = { direction, directionName ->
            Log.d("DirectionalDisk", "隐藏文字圆盘 - 方向: $direction, 名称: $directionName")
            updateDirectionText("隐藏文字圆盘", direction, directionName)
        }
    }

    private fun updateDirectionText(diskName: String, direction: Int, directionName: String) {
        val directionConstant = when (direction) {
            DirectionalDiskView.DIRECTION_UP -> "DIRECTION_UP (上)"
            DirectionalDiskView.DIRECTION_UP_RIGHT -> "DIRECTION_UP_RIGHT (右上)"
            DirectionalDiskView.DIRECTION_RIGHT -> "DIRECTION_RIGHT (右)"
            DirectionalDiskView.DIRECTION_DOWN_RIGHT -> "DIRECTION_DOWN_RIGHT (右下)"
            DirectionalDiskView.DIRECTION_DOWN -> "DIRECTION_DOWN (下)"
            DirectionalDiskView.DIRECTION_DOWN_LEFT -> "DIRECTION_DOWN_LEFT (左下)"
            DirectionalDiskView.DIRECTION_LEFT -> "DIRECTION_LEFT (左)"
            DirectionalDiskView.DIRECTION_UP_LEFT -> "DIRECTION_UP_LEFT (左上)"
            else -> "DIRECTION_NONE (无)"
        }
        
        directionText.text = "$diskName - 当前方向: $directionName\n常量值: $directionConstant"
    }
}
复制代码
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="8方向控制圆盘"
        android:textSize="24sp"
        android:textColor="#333333"
        android:textStyle="bold"
        android:gravity="center"
        android:layout_marginBottom="32dp" />

    <com.example.myapplication.DirectionalDiskView
        android:id="@+id/directionDisk"
        android:layout_width="300dp"
        android:layout_height="300dp"
        android:layout_gravity="center" />

    <!-- 高级版本支持自定义属性 -->
    <com.example.myapplication.DirectionalDiskView
        android:id="@+id/advancedDirectionDisk"
        android:layout_width="280dp"
        android:layout_height="280dp"
        android:layout_gravity="center"
        android:layout_marginTop="20dp"
        app:diskColor="#FF6D00"
        app:directionColor="#00C853"
        app:indicatorColor="#D50000"
        app:textColor="#FFFFFF"
        app:textSize="32sp"
        android:visibility="gone"/>

    <!-- 隐藏文字的版本 -->
    <com.example.myapplication.DirectionalDiskView
        android:id="@+id/minimalDirectionDisk"
        android:layout_width="260dp"
        android:layout_height="260dp"
        android:layout_gravity="center"
        android:layout_marginTop="20dp"
        app:diskColor="#9C27B0"
        app:directionColor="#FFC107"
        app:indicatorColor="#E91E63"
        app:textColor="#FFFFFF"
        app:textSize="28sp"
        app:showDirectionText="false" />

    <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>
相关推荐
梵得儿SHI5 小时前
Vue 数据绑定深入浅出:从 v-bind 到 v-model 的实战指南
前端·javascript·vue.js·双向绑定·vue 数据绑定机制·单向绑定·v-bind v-model
Moment5 小时前
Electron 发布 39 版本 ,这更新速度也变态了吧❓︎❓︎❓︎
前端·javascript·node.js
自由日记5 小时前
前端学习:选择器的类别
前端·javascript·学习
念念不忘 必有回响5 小时前
Nginx前端配置与服务器部署详解
服务器·前端·nginx
江城开朗的豌豆5 小时前
Webpack打包:从“庞然大物”到“精致小可爱”
前端·javascript
安当加密5 小时前
基于ASP身份认证网关实现Web系统免代码改造的单点登录方案
java·开发语言·前端
JarvanMo5 小时前
Bitrise 自动化发布 Flutter 应用终极指南(一)
前端
代码哈士奇5 小时前
使用vite+vue3+ElementPlus+pinia搭建中后台应用-前端
前端·vue3·管理系统·vite7
亿元程序员5 小时前
当一个Cocos博主被问有没有Unity教程...
前端