基于 Compose & Canvas 的蛛网图组件开发

基于 Compose & Canvas 的蛛网图组件开发

  • 1. 前言
  • 2. 实现过程
    • 2.1 准备工作
      • 2.1.1创建Compose方法,确定参数
      • 2.1.2 添加Canvas
      • 2.1.3编写Preview代码,实时预览
    • 2.2 绘制任意多边形
      • 2.2.1 绘制辅助圆
      • 2.2.2计算顶点坐标
      • 2.2.3 画出各个顶点
      • 2.2.4连接相邻顶点,组成多边形
    • 2.3 绘制蛛网
    • 2.4 绘制标签文本
    • 2.5 绘制数据折线
    • 2.6 手指拖动旋转
      • 2.6.1 拖动手势监听
      • 2.6.2 添加旋转fling
    • 彩蛋
  • 完整代码
  • 参考资料

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)
    }

基于 Compose & Canvas 的蛛网图组件开发_第1张图片

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)
    }

基于 Compose & Canvas 的蛛网图组件开发_第2张图片

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)
    }

现在是这样的
基于 Compose & Canvas 的蛛网图组件开发_第3张图片

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是半径,效果如下

基于 Compose & Canvas 的蛛网图组件开发_第4张图片

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函数里传入的 德、智、体、美、劳,效果如下
基于 Compose & Canvas 的蛛网图组件开发_第5张图片

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)
    }

基于 Compose & Canvas 的蛛网图组件开发_第6张图片
至此,蛛网图的绘制已经全部实现了,接下来我们看看怎么让它动起来

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("美", "德")
    )
}

基于 Compose & Canvas 的蛛网图组件开发_第7张图片
size=1:


@Preview(showBackground = true)
@Composable
fun SpiderWebRadarLineDiagramPreview() {
    SpiderWebRadarLineDiagram(
        modifier = Modifier.fillMaxSize(),
        dataList = listOf(3f),
        labelList = listOf("德"),
        maxData_ = 5f
    )
}

基于 Compose & Canvas 的蛛网图组件开发_第8张图片
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("美", "德", "美", "德", "美", "德", "美", "德", "美", "德", "美", "德", "美", "德")
    )
}

基于 Compose & Canvas 的蛛网图组件开发_第9张图片
后续可以把线条、点、文字、区域等的颜色、大小等参数抽取出来,让使用者可以更加自由定义样式

至此本篇文章完结了,有不对的地方欢迎指正,一起交流学习,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("美", "德", "美", "德", "美", "德", "美", "德", "美", "德", "美", "德", "美", "德")
    )
}

参考资料

  1. Android自定义View之蜘蛛网雷达效果 史大拿(作) 郭霖(转) https://mp.weixin.qq.com/s/UUhE_m5eg6Fh6Ub23z-G4A
  2. JetPack Compose动画 官方文档 https://developer.android.google.cn/jetpack/compose/animation?hl=zh-cn#decay-animation
  3. Compose动画之DecayAnimation 树獭非懒 https://juejin.cn/post/7103062895860121613
  4. JetPack Compose手势——拖动 官方文档 https://developer.android.google.cn/jetpack/compose/touch-input/gestures?hl=zh-cn#dragging

你可能感兴趣的:(Android开发,compose从0开发,kotlin进阶,android,kotlin,compose,动画,自定义组件)