8方向控制圆盘View
下载demo:
8方向控制圆盘-android
功能特性:
 - 支持8个方向检测(上、右上、右、右下、下、左下、左、左上)
 - 支持触摸交互,实时检测触摸位置方向
 - 支持连续发送模式(按住时持续发送方向信息)
 - 支持自定义颜色和文字大小(XML和代码两种方式)
 - 支持显示/隐藏方向文字和标记点(XML和代码两种方式)
 - 使用Kotlin协程实现定时发送,避免内存泄漏
正文
 DirectionalDiskView
package com.example.myapplicationimport 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 = trueset(value) {field = valueinvalidate()  // 自动重绘View}/*** 是否显示方向标记点,默认true* 支持XML和代码动态设置,修改后会自动重绘* * 使用示例:* - XML: app:showDirectionMarkers="false"* - 代码: directionalDiskView.showDirectionMarkers = false*/var showDirectionMarkers = trueset(value) {field = valueinvalidate()  // 自动重绘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.WHITEstyle = 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_NONEprivate 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? = nullinit {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 = diskColorstyle = Paint.Style.FILL}directionPaint.apply {color = directionColorstyle = Paint.Style.FILL}textPaint.apply {color = textColortextSize = this@DirectionalDiskView.textSizetextAlign = Paint.Align.CENTERtypeface = Typeface.DEFAULT_BOLD}indicatorPaint.apply {color = indicatorColorstyle = Paint.Style.FILL}}/*** View尺寸改变时调用,重新计算圆盘的中心点和半径*/override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {super.onSizeChanged(w, h, oldw, oldh)// 圆盘圆心位于View中心centerX = w / 2fcenterY = 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 = directioninvalidate()  // 重绘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_NONEinvalidate()  // 重绘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
<?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
package com.example.myapplicationimport android.os.Bundle
import android.util.Log
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivityclass DirectionalDiskActivity : AppCompatActivity() {private lateinit var directionDisk: DirectionalDiskViewprivate lateinit var advancedDirectionDisk: DirectionalDiskViewprivate lateinit var minimalDirectionDisk: DirectionalDiskViewprivate lateinit var directionText: TextViewoverride 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 = truedirectionDisk.continuousSendInterval = 200L  // 每200ms发送一次advancedDirectionDisk.enableContinuousSending = trueadvancedDirectionDisk.continuousSendInterval = 200L  // 每200ms发送一次minimalDirectionDisk.enableContinuousSending = trueminimalDirectionDisk.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
<?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"><TextViewandroid: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.DirectionalDiskViewandroid:id="@+id/directionDisk"android:layout_width="300dp"android:layout_height="300dp"android:layout_gravity="center" /><!-- 高级版本支持自定义属性 --><com.example.myapplication.DirectionalDiskViewandroid: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.DirectionalDiskViewandroid: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" /><TextViewandroid: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>
