基于 Compose & Canvas 的蛛网图组件开发
1. 前言
前几天看到郭霖大神公众号上分享的一篇文章:Android自定义View之蜘蛛网雷达效果 ,正好最近我自己在写一个基于Jetpack Compose的动画和自定义组件相关的库,咱也用compose写一个吧。在此感谢史大拿学长分享的文章和灵感,感谢郭神分享的优质好文
话不多说,先看效果(文末附完整代码)
2. 实现过程
由于我初次看文章时并没有看的太细,感觉这个效果很炫酷就上手做了,完成之后仔细对比各个细节的实现,发现是有一些不同的,不过整体的思路大致是一致的,最后还有彩蛋
2.1 准备工作
2.1.1创建Compose方法,确定参数
新建kotlin文件,输入comp,回车,AS帮我们自动生成compose方法的模板,起个名字就叫 SpiderWebRadarLineDiagram (蛛网雷达折线图)
/**
* @param modifier 修饰符
* @param dataList 需要绘制的数据列表
* @param labelList 数据列表对应的标签
* @param layerNum 绘制蛛网的层数
* @param maxData_ 最外层蛛网代表的最大值,为空则取 dataList 中的最大值
*/
@Composable
fun SpiderWebRadarLineDiagram(
modifier: Modifier,
dataList: List<Float>,
labelList: List<String>,
layerNum: Int = 5,
maxData_: Float? = null
) {
//数据长度和标签长度判断处理,若不相等或为空抛出异常
if (dataList.size != labelList.size || dataList.isEmpty()) {
throw IllegalArgumentException("dataList.size can not be empty,and it must equals to paramList.size!")
}
//计算数据长度,用于确定绘制几边形
val count = dataList.size
//确定最外层代表的数值上限
val maxData = maxData_ ?: dataList.max()
//TODO 绘制
}
我们绘制折线图,需要确定图的大小、数据、标签、网的层数、最大值等,这些基本信息作为参数支持调用者自由定义,后续可以把线的粗细、颜色、字体大小等都提取成参数(AS MAC上智能提取参数快捷键:option + command + P)
另外对参数的输入做合法校验和处理
2.1.2 添加Canvas
在上面的蛛网图方法中的TODO 正文中添加Canvas组件,并传入modifier,Canvas内部就是我们控制绘制的地方了
//...
//绘制
Canvas(modifier = modifier) {
}
2.1.3编写Preview代码,实时预览
输入prev,回车,生成Preview模版,起名 SpiderWebRadarLineDiagramPreview,调用我们刚才创建的蛛网图方法
@Preview(showBackground = true)
@Composable
fun SpiderWebRadarLineDiagramPreview() {
SpiderWebRadarLineDiagram(
modifier = Modifier.fillMaxSize(),
dataList = listOf(4f, 5f, 5f, 4f, 5f),
labelList = listOf("德", "智", "体", "美", "劳")
)
}
这里将 showBackground设为true,显示背景色可以更加接近真实的效果,传入modifier,填满整个布局,传入绘制的数据,build一下,就可以看到,一片空白,因为我们还没有绘制任何东西呢
2.2 绘制任意多边形
2.2.1 绘制辅助圆
根据Canvas当前尺寸宽高的较小者,并预留5%的边距空白,确定辅助圆的半径(预留空白用于后续绘制标签文本)
Canvas(modifier = modifier) {
//计算多边形相接圆的半径
val radius = java.lang.Float.min(size.height, size.width) * 0.45f
//画辅助圆
drawCircle(Color.Cyan, radius, center)
}
2.2.2计算顶点坐标
我们前面根据调用者传递进来的dataList的size,确定了需要绘制的多边形的边数 count,根据count计算出各个顶点分布的角度,结合辅助圆的半径,利用高中学过的 sin 和 cos 三角函数知识,计算出x,y的值,注意方向和正负,这里还涉及到角度转换成弧度制,使用Math.toRadians()
/**
* 根据角度计算坐标
*
* @param rotation 角度
* @param radius 半径
*/
private fun DrawScope.calculateXY(
rotation: Float,
radius: Float
): Pair<Float, Float> {
//将角度单位转换,如180度转换成Pi
val radian = Math.toRadians(rotation.toDouble())
return calculateXYByRadian(radian, radius)
}
/**
* 根据弧度计算坐标
*
* @param radius 半径
* @param radian 弧度
*/
private fun DrawScope.calculateXYByRadian(
radian: Double,
radius: Float
): Pair<Float, Float> {
val x = (radius * cos(radian) + center.x).toFloat()
val y = (radius * sin(radian) + center.y).toFloat()
return Pair(x, y)
}
这里要注意我们计算的是相对于center中心点偏移的 x,y,所以要加上center的x,y值,这里的center是Canvas的DrawScope作用域中的画布中心点
2.2.3 画出各个顶点
我们可以先画出顶点看下计算的位置对不对,封装一个方法
/**
* 绘制多边形顶点
* @param count 边数,也是顶点数
* @param roteStep 相邻顶点的圆心角
* @param radius 相接圆半径
*/
private fun DrawScope.drawSpiderWebPoints(
count: Int,
roteStep: Float,
radius: Float
) {
val pointsList = mutableListOf<Offset>()
(0 until count).forEach {
//计算各个顶点坐标
val (x, y) = calculateXY(roteStep * it, radius)
pointsList.add(Offset(x, y))
}
drawPoints(
pointsList,
PointMode.Points,
Color.Black,
strokeWidth = 15f,
pathEffect = PathEffect.cornerPathEffect(15f)
)
}
为了美观,使用 PathEffect.cornerPathEffect 让点呈圆形,否则默认是方形的。再在Canvas中调用
Canvas(modifier = modifier) {
//...
//计算多边形相邻顶点的圆心角
val roteStep = 360f / count
//画各个顶点
drawSpiderWebPoints(count, roteStep, radius)
}
2.2.4连接相邻顶点,组成多边形
这里也封装一个drawOutSpiderWeb方法,绘制外层网络,用Path来实现,moveTo到第一个点,lineTo下一个点,最后close闭环
/**
* 绘制最外层蛛网多边形
*
* @param count 边数,也是顶点数
* @param roteStep 相邻顶点的圆心角
* @param radius 相接圆半径
*/
private fun DrawScope.drawOutSpiderWeb(
count: Int,
roteStep: Float,
radius: Float
) {
val path = Path()
(0 until count).forEach {
//计算各个顶点坐标
val (x, y) = calculateXY(roteStep * it, radius)
//绘制多边形顶点到圆心的连线
drawLine(Color.Black, Offset(x, y), center)
//相邻顶点连线
if (it == 0) {
path.moveTo(x, y)
} else {
path.lineTo(x, y)
}
if (it == count - 1) {
path.close()
}
}
drawPath(path, Color.Black, style = Stroke())
}
再在Canvas中调用
Canvas(modifier = modifier) {
//...
//画各个顶点
drawSpiderWebPoints(count, roteStep, radius)
//画多边形
drawOutSpiderWeb(count, roteStep, radius)
}
现在是这样的
2.3 绘制蛛网
蛛网其实就是嵌套了几层与不同半径的圆相接的多边形,绘制流程类似,我们可以将上面的绘制一个多边形的代码抽象调整一下,需要绘制的层数作为参数传入,如下
/**
* 绘制蛛网
*
* @param count 顶点数
* @param roteStep 相邻顶点与中心点构成的角度
* @param radius 最外层顶点所在圆的半径
* @param layerNum 总层数
*/
private fun DrawScope.drawSpiderWeb(
layerNum: Int,
count: Int,
roteStep: Float,
radius: Float
) {
(1..layerNum).forEach {
//画每一层网络
drawOneLayerCobweb(count, roteStep, radius, it, layerNum)
}
}
/**
* 绘制蛛网的每一层(多边形)
*
* @param count 顶点数
* @param roteStep 相邻顶点与中心点构成的角度
* @param radius 最外层顶点所在圆的半径
* @param currentLayer 当前层数
* @param layerNum 总层数
*/
private fun DrawScope.drawOneLayerCobweb(
count: Int,
roteStep: Float,
radius: Float,
currentLayer: Int,
layerNum: Int
) {
val path = Path()
(0 until count).forEach {
//计算各个顶点坐标
val (x, y) = calculateXY(roteStep * it, radius * currentLayer / layerNum)
//是最外层时,画顶点与圆心的连线
if (currentLayer == layerNum) {
drawLine(Color.Black, Offset(x, y), center)
}
//相邻顶点连线
if (it == 0) {
path.moveTo(x, y)
} else {
path.lineTo(x, y)
}
if (it == count - 1) {
path.close()
}
}
drawPath(path, Color.Black, style = Stroke())
}
然后我们在Canvas drawScope里直接调用 drawSpiderWeb方法即可
Canvas(modifier = modifier) {
//...
//画辅助圆
drawCircle(Color.Cyan, radius, center)
//画各个顶点
drawSpiderWebPoints(count, roteStep, radius)
//画蛛网
drawSpiderWeb(layerNum, count, roteStep, radius)
}
layerNum层数前面我们默认为5,count是边数,roteStep是相邻顶点的圆心角,radius是半径,效果如下
2.4 绘制标签文本
有了前面的经验,标签文本的绘制就很简单了,计算确定文本位置,然后drawText即可(正好最近Jetpack Compose更新支持了Canvas组件的drawText,来看看怎么用吧)
/**
* 绘制标签文本
*
* @param count 顶点数
* @param roteStep 相邻顶点与中心点构成的角度
* @param radius 当前层顶点所在圆的半径
* @param textMeasurer TextMeasure
* @param labelList 存储标签文本的列表
* @param rotation 当前蛛网图旋转的角度
*/
@OptIn(ExperimentalTextApi::class)
private fun DrawScope.drawParamLabel(
count: Int,
roteStep: Float,
radius: Float,
textMeasurer: TextMeasurer,
labelList: List<String>,
rotation: Float
) {
(0 until count).forEach {
//计算文本需要绘制的坐标
val (x, y) = calculateXYByRadian(
Math.toRadians(roteStep * it.toDouble() + rotation.toDouble()),
radius * 1.05f
)
//计算要绘制的文本的TextLayoutResult
val measuredText = textMeasurer.measure(
AnnotatedString(labelList[it])
)
//绘制文本
drawText(
measuredText,
topLeft = Offset(x - measuredText.size.width / 2, y - measuredText.size.height / 2)
)
}
}
这里的rotation参数因为后续需要实现旋转功能,所以先预留进去
标签文本的坐标位置我们想要实现在对应顶点的延长线上居中显示,但是drawText定位是根据左上角的坐标定位的,因此这里计算延长线居中位置的时候,将radius * 1.05f,延长5%的半径(前面我们绘制辅助圆的时候预留出了5%)。还需要通过textMeasure获取到文本绘制的size,再用 Offset(x - measuredText.size.width / 2, y - measuredText.size.height / 2) 计算出文本左上角的坐标,来让文本在延长线位置居中
在Canvas中调用一下
//...
val textMeasurer = rememberTextMeasurer()
Canvas(modifier = modifier) {
//...
//绘制标签文本
drawParamLabel(count, roteStep, radius, textMeasurer, labelList, 0f)
}
labelList是我们在Preview函数里传入的 德、智、体、美、劳,效果如下
2.5 绘制数据折线
绘制数据折线其实也是一个多边形,只是不一定是正多边形,思路和绘制一层多边形差不多,不同的是要根据数据计算顶点在最大半径上的位置,例如maxData是10,data是5,那这个点所在的圆半径为 radius * 5 /10,看代码
/**
* 绘制数据的线
*
* @param count 顶点数,即dataList的size
* @param roteStep 相邻顶点与中心点构成的角度
* @param dataList 需要绘制的数据列表
* @param radius 最大圆半径
* @param maxData_ 数据范围的最大值,即最外层蛛网代表的值
*/
private fun DrawScope.drawDataLine(
count: Int,
roteStep: Float,
dataList: List<Float>,
radius: Float,
maxData_: Float
) {
val dataPath = Path()
(0 until count).forEach {
val (x, y) = calculateXY(roteStep * it, dataList[it] * radius / maxData_)
//画数据的各个点
drawCircle(Color.Red, 15f, Offset(x, y))
if (it == 0) {
dataPath.moveTo(x, y)
} else {
dataPath.lineTo(x, y)
}
if (it == count - 1) {
dataPath.close()
}
}
drawPath(dataPath, Color(0xCC9CB8F0), style = Fill)
}
这里drawPath使用的style是Fill,填充颜色
再在Canvas中调用
Canvas(modifier = modifier) {
//...
//绘制数据折线
drawDataLine(count, roteStep, dataList, radius, maxData)
}
至此,蛛网图的绘制已经全部实现了,接下来我们看看怎么让它动起来
2.6 手指拖动旋转
这块之前有接触过Compose多点触控,看官方示例Compose手势-多点触控很简单可以实现双指控制旋转或者大小缩放等功能,但是咱们这次挑战一下史大拿学长的单点拖动旋转方式,顺便学习一下Compose拖动手势相关的用法
2.6.1 拖动手势监听
Compose中拖动手势的处理目前主要有两种方式,一是Modifier.draggable,draggable修饰符是向单一方向拖动手势的高级入口点,并且会报告拖动距离(以像素为单位);二是通过Modifier.pointerInput中添加detectDragGesture监听。很明显前者无法满足我们的需求,我们需要用pointerInput监听完整的拖动事件来实现拖动旋转功能,来看下代码
//...
//记录计算的旋转角度
var rotation by remember { mutableStateOf(0f) }
//记录手指每次移动的起始点
var startPoint by remember { mutableStateOf(Offset.Zero) }
//记录手指每次移动的终点
var endPoint by remember { mutableStateOf(Offset.Zero) }
//记录Canvas在大小确定时的中心点
var centerPoint by remember { mutableStateOf(Offset.Zero) }
Canvas(modifier = modifier
.onSizeChanged {
//记录Canvas中心点坐标
centerPoint = Offset(it.width / 2f, it.height / 2f)
}
//手指拖动转动
.pointerInput(Unit) {
detectDragGestures(onDragStart = { point ->
startPoint = point
}) { change, dragAmount ->
endPoint = startPoint + dragAmount
//这里Math.atan2函数对正负做了处理,所以不需要分象限处理
(atan2(endPoint.y - centerPoint.y, endPoint.x - centerPoint.x)
- atan2(startPoint.y - centerPoint.y, startPoint.x - centerPoint.x))
.let { radian ->
//弧度制转换成角度单位
Math
.toDegrees(radian.toDouble())
.toFloat()
.let { rota ->
//旋转角度增加本次Drag的增量
rotation += rota
}
}
startPoint = endPoint
}
}) {
//计算多边形相接圆的半径
val radius = java.lang.Float.min(size.height, size.width) * 0.45f
//计算多边形相邻顶点的圆心角
val roteStep = 360f / count
//旋转画布
rotate(rotation) {
//画各个顶点
drawSpiderWebPoints(count, roteStep, radius)
//画蛛网
drawSpiderWeb(layerNum, count, roteStep, radius)
//绘制数据折线
drawDataLine(count, roteStep, dataList, radius, maxData)
}
//绘制标签文本
drawParamLabel(count, roteStep, radius, textMeasurer, labelList, rotation)
}
这里需要在onDragStart的时候记录一下起始点的位置startPoint,在onDrag方法中,将dragAmount与startPoint相加就是本次drag的endPoint位置,然后用startPoint和endPoint两个点,计算相对于中心点的角度。最后在每次拖动后将endpoint更新给startPoint。
细心的同学应该会发现,绘制文本放在了rotate作用域的外面,并且传入了实际的rotation参数,这样就能实现蛛网图旋转的时候,文本位置能跟随旋转,但是文本方向始终保持用户能正常阅读的方向
现在的效果是这样的(这里开始我把辅助圆去掉了,增加了个中心点)
2.6.2 添加旋转fling
目前旋转功能是实现了,但是整体看起来很生硬,我们现在给它添加个fling的物理惯性效果,看代码
//...
//获取协程作用域
val coroutineScope = rememberCoroutineScope()
//fling开始的速度
var flingStartSpeed by remember { mutableStateOf(0f) }
//手指松开后的惯性旋转角度
val flingRotation = remember { Animatable(0f) }
//定义衰减动画的衰减属性,指数衰减、摩擦力和临界值
val exponentDecay = exponentialDecay<Float>(0.5f, 1f)
//记录上一次onDrag的时间,用于计算两次onDrag的间隔时间
var lastOnDragTime by remember { mutableStateOf(0L) }
Canvas(modifier = modifier
.onSizeChanged {/*...*/}
//手指拖动转动
.pointerInput(Unit) {
detectDragGestures(onDragStart = { point ->
startPoint = point
//新的拖动手势触发时,立刻停止上一次的fling
coroutineScope.launch {
flingRotation.animateDecay(0f, exponentDecay)
}
},
onDragEnd = {
//拖动手势结束时,开始fling
coroutineScope.launch {
flingRotation.animateDecay(flingStartSpeed, exponentDecay)
}
}) { change, dragAmount ->
endPoint = startPoint + dragAmount
//这里Math.atan2函数对正负做了处理,所以不需要分象限处理
(atan2(endPoint.y - centerPoint.y, endPoint.x - centerPoint.x)
- atan2(startPoint.y - centerPoint.y, startPoint.x - centerPoint.x))
.let { radian ->
//弧度制转换成角度单位
Math
.toDegrees(radian.toDouble())
.toFloat()
.let { rota ->
rotation += rota
System
.currentTimeMillis()
.let { currentTime ->
//计算每秒钟旋转的速度
flingStartSpeed = rota * 1000 / (currentTime - lastOnDragTime)
lastOnDragTime = currentTime
}
}
}
startPoint = endPoint
}
}
//点击停止fling转动
.pointerInput(Unit) {
detectTapGestures {
Log.e("SpiderWeb", "detectTapGestures")
coroutineScope.launch {
flingRotation.animateDecay(0f, exponentDecay)
}
}
}) {
//计算多边形相接圆的半径
val radius = min(size.height, size.width) * 0.45f
//计算多边形相邻顶点的圆心角
val roteStep = 360f / count
rotate(rotation + flingRotation.value) {
//画顶点
drawSpiderWebPoints(count, roteStep, radius)
//画蛛网
drawSpiderWeb(layerNum, count, roteStep, radius)
//画data的线
drawDataLine(count, roteStep, dataList, radius, maxData)
}
//画标签文本
drawParamLabel(
count,
roteStep,
radius,
textMeasurer,
labelList,
rotation + flingRotation.value
)
}
这里用到了Animatable.animateDecay()来实现fling惯性的速度衰减效果。另外在需要注意的是,我们需要知道触发onDrag的间隔时间,计算出每秒钟旋转的速度,传给animateDecay。看detectDragGestures的源码,我们可以知道onDrag每次触发是在屏幕帧刷新的时候触发的,所以这里也可以获取屏幕刷新率来免去计算onDrag的间隔时间,但是 LocalContext.current.display.refreshRate 这个接口在API 30才有(大概因为之前Android还没有支持不同刷新率),我就没有用这个接口,而是自己计算间隔时间
另外还增加了点击或新的拖动事件停止fling,体验更好一些
彩蛋
前面参数校验的时候,细心的同学可能会担心,蛛网图要是传入的数据dataList和labelList长度为1 或者 2 怎么办,会不会报错,我们可以试一下
size=2:
@Preview(showBackground = true)
@Composable
fun SpiderWebRadarLineDiagramPreview() {
SpiderWebRadarLineDiagram(
modifier = Modifier.fillMaxSize(),
dataList = listOf(5f, 3f),
labelList = listOf("美", "德")
)
}
size=1:
@Preview(showBackground = true)
@Composable
fun SpiderWebRadarLineDiagramPreview() {
SpiderWebRadarLineDiagram(
modifier = Modifier.fillMaxSize(),
dataList = listOf(3f),
labelList = listOf("德"),
maxData_ = 5f
)
}
size=14
@Preview(showBackground = true)
@Composable
fun SpiderWebRadarLineDiagramPreview() {
SpiderWebRadarLineDiagram(
modifier = Modifier.fillMaxSize(),
dataList = listOf(5f, 3f, 5f, 3f, 5f, 3f, 5f, 3f, 5f, 3f, 5f, 3f, 5f, 3f),
labelList = listOf("美", "德", "美", "德", "美", "德", "美", "德", "美", "德", "美", "德", "美", "德")
)
}
后续可以把线条、点、文字、区域等的颜色、大小等参数抽取出来,让使用者可以更加自由定义样式
至此本篇文章完结了,有不对的地方欢迎指正,一起交流学习,pass~
完整代码
本项目已收录至我的开源Compose动画及组件库:ComposeAnimationKit,欢迎参与或star
github:https://github.com/LiePy/ComposeAnimationKit
gitee:https://gitee.com/lie_py/compose-animation-kit
下面是完整代码,后续可能会有更新改动,最新完整代码请移步至上方任意git仓库
package com.lie.newcomposetest.ui.animationkit
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.PointMode
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.*
import androidx.compose.ui.tooling.preview.Preview
import kotlinx.coroutines.launch
import java.lang.Float.min
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
/**
* @description: 蜘蛛网雷达折线图
* @author: 三石
* @date: 2023/3/13 17:30
*
* @param modifier 修饰符
* @param dataList 需要绘制的数据列表
* @param labelList 数据列表对应的标签
* @param layerNum 绘制蛛网的层数
* @param maxData_ 最外层蛛网代表的最大值,为空则取 dataList 中的最大值
*/
@OptIn(ExperimentalTextApi::class)
@Composable
fun SpiderWebRadarLineDiagram(
modifier: Modifier,
dataList: List<Float>,
labelList: List<String>,
layerNum: Int = 5,
maxData_: Float? = null
) {
//数据长度和标签长度判断处理,若不相等或为空抛出异常
if (dataList.size != labelList.size || dataList.isEmpty()) {
throw IllegalArgumentException("dataList.size can not be empty,and it must equals to paramList.size!")
}
//计算数据长度,用于确定绘制几边形
val count = dataList.size
//确定最外层代表的数值上限
val maxData = maxData_ ?: dataList.max()
//drawText()绘制文本要用到
val textMeasurer = rememberTextMeasurer()
//获取协程作用域
val coroutineScope = rememberCoroutineScope()
//记录计算的旋转角度
var rotation by remember { mutableStateOf(0f) }
//记录手指每次移动的起始点
var startPoint by remember { mutableStateOf(Offset.Zero) }
//记录手指每次移动的终点
var endPoint by remember { mutableStateOf(Offset.Zero) }
//记录Canvas在大小确定时的中心点
var centerPoint by remember { mutableStateOf(Offset.Zero) }
//drag最后一次的速度,作为fling开始的速度
var flingStartSpeed by remember { mutableStateOf(0f) }
//手指松开后的惯性旋转角度
val flingRotation = remember { Animatable(0f) }
//定义衰减动画的衰减属性,指数衰减、摩擦力和临界值
val exponentDecay = exponentialDecay<Float>(0.5f, 1f)
//记录上一次onDrag的时间,用于计算两次onDrag的间隔时间
var lastOnDragTime by remember { mutableStateOf(0L) }
Canvas(modifier = modifier
.onSizeChanged {
//记录Canvas中心点坐标
centerPoint = Offset(it.width / 2f, it.height / 2f)
}
//手指拖动转动
.pointerInput(Unit) {
detectDragGestures(onDragStart = { point ->
startPoint = point
//新的拖动手势触发时,立刻停止上一次的fling
coroutineScope.launch {
flingRotation.animateDecay(0f, exponentDecay)
}
}, onDragEnd = {
//拖动手势结束时,开始fling
coroutineScope.launch {
flingRotation.animateDecay(flingStartSpeed, exponentDecay)
}
}) { change, dragAmount ->
endPoint = startPoint + dragAmount
//这里Math.atan2函数对正负做了处理,所以不需要分象限处理
(atan2(endPoint.y - centerPoint.y, endPoint.x - centerPoint.x) - atan2(
startPoint.y - centerPoint.y, startPoint.x - centerPoint.x
)).let { radian ->
//弧度制转换成角度单位
Math
.toDegrees(radian.toDouble())
.toFloat()
.let { rota ->
rotation += rota
System
.currentTimeMillis()
.let { currentTime ->
//计算每秒钟旋转的速度
flingStartSpeed = rota * 1000 / (currentTime - lastOnDragTime)
lastOnDragTime = currentTime
}
}
}
startPoint = endPoint
}
}
//点击停止fling转动
.pointerInput(Unit) {
detectTapGestures {
coroutineScope.launch {
flingRotation.animateDecay(0f, exponentDecay)
}
}
}) {
//计算多边形相接圆的半径
val radius = min(size.height, size.width) * 0.45f
//计算多边形相邻顶点的圆心角
val roteStep = 360f / count
//画中心点
drawCircle(Color.Black, 7.5f, center)
rotate(rotation + flingRotation.value) {
//画顶点
drawSpiderWebPoints(count, roteStep, radius)
//画蛛网
drawSpiderWeb(layerNum, count, roteStep, radius)
//画data的线
drawDataLine(count, roteStep, dataList, radius, maxData)
}
//画标签文本
drawParamLabel(
count, roteStep, radius, textMeasurer, labelList, rotation + flingRotation.value
)
}
}
/**
* 绘制多边形顶点
* @param count 边数,也是顶点数
* @param roteStep 相邻顶点的圆心角
* @param radius 相接圆半径
*/
private fun DrawScope.drawSpiderWebPoints(
count: Int, roteStep: Float, radius: Float
) {
val pointsList = mutableListOf<Offset>()
(0 until count).forEach {
//计算各个顶点坐标
val (x, y) = calculateXY(roteStep * it, radius)
pointsList.add(Offset(x, y))
}
drawPoints(
pointsList,
PointMode.Points,
Color.Black,
strokeWidth = 15f,
pathEffect = PathEffect.cornerPathEffect(15f)
)
}
/**
* 绘制蛛网
*
* @param count 顶点数
* @param roteStep 相邻顶点与中心点构成的角度
* @param radius 最外层顶点所在圆的半径
* @param layerNum 总层数
*/
private fun DrawScope.drawSpiderWeb(
layerNum: Int, count: Int, roteStep: Float, radius: Float
) {
(1..layerNum).forEach {
//画每一层网络
drawOneLayerCobweb(count, roteStep, radius, it, layerNum)
}
}
/**
* 绘制蛛网的每一层(多边形)
*
* @param count 顶点数
* @param roteStep 相邻顶点与中心点构成的角度
* @param radius 最外层顶点所在圆的半径
* @param currentLayer 当前层数
* @param layerNum 总层数
*/
private fun DrawScope.drawOneLayerCobweb(
count: Int, roteStep: Float, radius: Float, currentLayer: Int, layerNum: Int
) {
val path = Path()
(0 until count).forEach {
//计算各个顶点坐标
val (x, y) = calculateXY(roteStep * it, radius * currentLayer / layerNum)
//是最外层时,画顶点与圆心的连线
if (currentLayer == layerNum) {
drawLine(Color.Black, Offset(x, y), center)
}
//相邻顶点连线
if (it == 0) {
path.moveTo(x, y)
} else {
path.lineTo(x, y)
}
if (it == count - 1) {
path.close()
}
}
drawPath(path, Color.Black, style = Stroke())
}
/**
* 绘制数据的线
*
* @param count 顶点数,即dataList的size
* @param roteStep 相邻顶点与中心点构成的角度
* @param dataList 需要绘制的数据列表
* @param radius 最大圆半径
* @param maxData_ 数据范围的最大值,即最外层蛛网代表的值
*/
private fun DrawScope.drawDataLine(
count: Int, roteStep: Float, dataList: List<Float>, radius: Float, maxData_: Float
) {
val dataPath = Path()
(0 until count).forEach {
val (x, y) = calculateXY(roteStep * it, dataList[it] * radius / maxData_)
//画数据的各个点
drawCircle(Color.Red, 15f, Offset(x, y))
if (it == 0) {
dataPath.moveTo(x, y)
} else {
dataPath.lineTo(x, y)
}
if (it == count - 1) {
dataPath.close()
}
}
drawPath(dataPath, Color(0xCC9CB8F0), style = Fill)
}
/**
* 绘制标签文本
*
* @param count 顶点数
* @param roteStep 相邻顶点与中心点构成的角度
* @param radius 当前层顶点所在圆的半径
* @param textMeasurer TextMeasure
* @param labelList 存储标签文本的列表
* @param rotation 当前蛛网图旋转的角度
*/
@OptIn(ExperimentalTextApi::class)
private fun DrawScope.drawParamLabel(
count: Int,
roteStep: Float,
radius: Float,
textMeasurer: TextMeasurer,
labelList: List<String>,
rotation: Float
) {
(0 until count).forEach {
//计算文本需要绘制的坐标
val (x, y) = calculateXYByRadian(
Math.toRadians(roteStep * it.toDouble() + rotation.toDouble()), radius * 1.05f
)
//计算要绘制的文本的TextLayoutResult
val measuredText = textMeasurer.measure(
AnnotatedString(labelList[it])
)
//绘制文本
drawText(
measuredText,
topLeft = Offset(x - measuredText.size.width / 2, y - measuredText.size.height / 2)
)
}
}
/**
* 根据角度计算坐标
*
* @param rotation 角度
* @param radius 半径
*/
private fun DrawScope.calculateXY(
rotation: Float, radius: Float
): Pair<Float, Float> {
//将角度单位转换,如180度转换成Pi
val radian = Math.toRadians(rotation.toDouble())
return calculateXYByRadian(radian, radius)
}
/**
* 根据弧度计算坐标
*
* @param radius 半径
* @param radian 弧度
*/
private fun DrawScope.calculateXYByRadian(
radian: Double, radius: Float
): Pair<Float, Float> {
val x = (radius * cos(radian) + center.x).toFloat()
val y = (radius * sin(radian) + center.y).toFloat()
return Pair(x, y)
}
@Preview(showBackground = true)
@Composable
fun SpiderWebRadarLineDiagramPreview() {
SpiderWebRadarLineDiagram(
modifier = Modifier.fillMaxSize(),
dataList = listOf(5f, 3f, 5f, 3f, 5f, 3f, 5f, 3f, 5f, 3f, 5f, 3f, 5f, 3f),
labelList = listOf("美", "德", "美", "德", "美", "德", "美", "德", "美", "德", "美", "德", "美", "德")
)
}
参考资料
- Android自定义View之蜘蛛网雷达效果 史大拿(作) 郭霖(转) https://mp.weixin.qq.com/s/UUhE_m5eg6Fh6Ub23z-G4A
- JetPack Compose动画 官方文档 https://developer.android.google.cn/jetpack/compose/animation?hl=zh-cn#decay-animation
- Compose动画之DecayAnimation 树獭非懒 https://juejin.cn/post/7103062895860121613
- JetPack Compose手势——拖动 官方文档 https://developer.android.google.cn/jetpack/compose/touch-input/gestures?hl=zh-cn#dragging