下载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>