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

vue---vis实现复杂网状关系图显示 vis.js是基于浏览器的动态可视化库。能够处理大量的动态数据,实现对数据的操作和交互。 首先,贴上一个很好的vis.js参考文档:https://yajunfan.github.io/vis/#/module 案例效果如图 (1)安装【npm inatall vis --save】 (2)引入:在【main.js】中引入【import"vis/dist/vis.css";】,在展示网状图的页面引入【importVisfrom"vis";】 (3)实现 网状图实现: 首先在html.... 阅读详情

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=2
size=1:


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

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

size=14
后续可以把线条、点、文字、区域等的颜色、大小等参数抽取出来,让使用者可以更加自由定义样式

至此本篇文章完结了,有不对的地方欢迎指正,一起交流学习,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
Compose Canvas基础(1) drawxxx()绘制方法 阅读本文需要一定compose基础,如果没有请移步Jetpack Compose入门详解(实时更新)本文介绍Compose Canvas基础,介绍Canvas可组合项依赖的原理和绘制相关的内置api。 阅读详情

相关推荐

基于Compose & Canvas的蜘蛛网雷达图呈现效果

我们前面根据调用者传递进来的dataList的size,确定了需要绘制的多边形的边数 count,根据count计算出各个顶点分布的角度,结合辅助圆的半径,利用高中学过的 sin 和 cos 三角函数知识,计算出x,y的值,注意方向和正负,这里还涉及到角度转换成弧度制,使用Math.toRadians()。这里将showBackground设为true,显示背景色可以更加接近真实的效果,传入modifier,填满整个布局,传入绘制的数据,build一下,就可以看到,一片空白,因为我们还没有绘制任何东西呢。

king168236的博客 337

使用Canvas实现手撕渔网袜特效(*^▽^*)

想要代码敲的好,高数算法不能少 html部分 &lt;canvas id="c"&gt;&lt;/canvas&gt; css部分 * { margin: 0; overflow: hidden; user-select: none; } body { background: #333; } canvas { background: #333; width: 1000px; height: 365px; ma..

Mr-yang的随笔 697

RK3572-Android16-记录一次OTA升级失败解决方案

这里记录一次解决RK3576 Android16 OTA 用Recovery 升级失败的一次解决案例,将部分经验作为总结。本次OTA升级耗时了几天,最终是RK给出的解决方案,再加上经验来解决了其它部分问题新平台,高一点的Android版本,可能本身有bug,多请教平台来,不要蒙着干,干不出来的。

十年饮冰,难凉热血 115

H5 canvas基础入门到捕鱼达人小游戏实现(5)drawImage的应用

不知不觉,这已经是第五篇canvas系列文章了, 课时我们依然没有讲捕鱼游戏,今天这节我们主要讲canvas是如何处理图片的,这节讲完了,我们就可以正式开始编写捕鱼游戏了。 图片处理 drawImage1、context.drawImage(image,x,y) x:绘制图像的x坐标 y:绘制图像的y坐标2、context.drawImage(image,x,y,w,h); x:画在canvas的x

joke_shi的博客 3349

Autosar结合simulink的汽车应用层软件开发实例

本文基于汽车应用开发案例,并结合Autosar相关知识,生成具有Autosar标准的汽车应用层开发代码,以供学习和参考。以上就是本篇内容,主要是利用Simulink创建Autosar接口并进行端口映射的方法。

m0_64899535的博客 2152

【ROS学习笔记】8.话题消息(Message)的定义与使用

ROS学习笔记8——话题消息(Message)的定义与使用

takedachia的博客 9547

自定义蛛网图

【代码】自定义蛛网图。

weixin_47712399的博客 419

synopsys工具安装系列(一)安装installer

synopsys为eda工具安装,提供了一个安装器,名叫installer。所以,首先要先安装installer这个工具,之后才能安装其他的eda工具。 首先从官网下载该installer工具。我这里下载的是5.2版本 下载之后,有如下安装文件。 将该安装包,拷贝到linux服务器中。 首先给SynopsysInstaller_v5.2.run赋予可执行权限。 chmod a+x SynopsysInstaller_...

weiqi7777的博客 8389

基于Compose & Canvas的蜘蛛网雷达效果

LiePy的博客地址:https://blog.youkuaiyun.com/LiePy?type=blog先看效果(文末附完整代码):实现过程 由于我初次看文章时并没有看的太细,感觉这个效果很炫酷就上手做了,完成之后仔细对比各个细节的实现,发现是有一些不同的,不过整体的思路大致是一致的,最后还有彩蛋。准备工作创建Compose方法,确定参数新建kotlin文件,输入comp,回车,AS帮我们自动生成comp...

Android技术之家 461

STM32G070xx将Flash页分块方式存储,固定数据块存储,实现一次擦除多次写入

本例程使用STM32G070xx芯片,Flash大小128KB,Flash每页(2048字节)写满后再擦除。操作存储区分为64块,每块32字节,即写满64次擦除一次;标定存储区分为16块,每块128字节,即写满16次擦除一次,每个存储区的块都有各自的头部标记

RaphaelCeo的博客 1543

快速上手:网页版EggNOG功能注释全流程解析

本文详细解析了网页版EggNOG在蛋白序列功能注释中的全流程操作,从初识工具到实战技巧,涵盖FASTA格式处理、参数设置、结果解读及常见问题解决方案。特别适合生物信息学初学者快速掌握这一高效注释工具,提升非模式生物基因组研究的注释效率。

weixin_29310631的博客 189

鸿蒙 ArkUI 放射性布局:从极坐标到动态可视化的完整实战

鸿蒙 ArkUI 放射性布局技术解析 摘要 本文详细介绍了在HarmonyOS NEXT平台上使用ArkUI框架实现放射性布局(Radial Layout)的技术方案。放射性布局基于极坐标系,适合雷达图、拓扑图、环形菜单等场景。文章从数学原理出发,讲解了极坐标转换公式,并展示了在ArkUI中的具体实现,包括: 极坐标与笛卡尔坐标转换的核心算法 ArkUI自定义组件架构设计 Canvas 2D绘制系统的应用 响应式状态管理与动态布局更新 脉冲动画系统的实现方法 项目采用双层架构设计,分离数据管理与UI渲染职责

xiaoaa_a的博客 376

保姆级教程:用Python+OpenCV实现相移法与格雷码三维重建(从生成条纹到点云生成)

本文详细介绍了使用Python和OpenCV实现相移法与格雷码三维重建的全流程,从条纹生成到点云计算。通过结构光技术,结合相移法和格雷码,实现高精度的三维重建,适用于无纹理物体表面的密集对应点生成。教程包含环境搭建、条纹生成、相位计算、三维点云重建及常见问题排查,适合计算机视觉开发者和研究人员。

weixin_33443333的博客 349

微信小程序 component组件里添加canvas画布

官方文档canvas:https://developers.weixin.qq.com/miniprogram/dev/component/canvas.html 调试基础库:2.9.3 一开始,我使用的是官方文档里的示例代码进行修改的,即 // js methods内 drawOn:function(){ var context = wx.createCanvas...

Higashino_Keigo的博客 2231

Windows 11 安装 uv:手动下载、配置国内源

本教程详细介绍了在 Windows 11 系统下安装并配置 Python 包管理器 uv 的完整流程,包括配置环境变量 PATH、设置清华 PyPI 镜像源,以及创建收据文件以支持未来更新。适用于因网络限制无法通过官方脚本自动安装的用户,操作步骤清晰,配有图文指引,帮助读者高效完成本地部署。

寒水馨 · 轻量级技术文档 · hanshuixin.org 7598

通信算法之281:大疆DJI无人机ID-DJI DroneID开源工程-相关问题-协议信息问题

ZC序列的参数未知。但是,使用第一个或第二个零相关序列对每个OFDM符号进行相位调整,对于直接相邻于使用过的零相关序列的时间上的OFDM符号工作得很好,但对其它符号效果不佳。由于符号的顺序没有改变,而9 OFDM符号情况下的第一个OFDM符号没有用处,因此本存储库中的脚本对待所有突发都是有9个OFDM符号的,并且不对第一个OFDM符号做任何处理。棘手的地方在于需要生成比数据载波数量(始终为600)多1个的零相关序列,零化中间元素,将生成的样本映射到FFT的中心(采样率决定bin的数量),然后计算IFFT。

leegang12的专栏 1619

自定义蜘蛛网状图

运行效果: 绘制分为三步: 1、绘制蜘蛛网格线 2、绘制格中线 3、绘制数据 获取中心点想x,y坐标和半径: @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { radius = Math.min(w, h) / 2 * 0.9f; centerX = w / 2; cent...

一个写代码的胖子的博客 5074

SAP B1中文培訓教材_SDK

公司内部培训资料,对开发工具 ,DIAPI, UIAPI,Add-ON有详细解说

Android 14+ 模拟器抓包实录:把 Burp CA 装成系统 CA,让 targetSdk≥34 的 App 乖乖解密

本文记录了在 Android 14+ 环境下,通过将 Burp CA 安装为系统信任锚,成功抓取 targetSdk=35 App 的 HTTPS 明文流量的完整实操过程。针对“浏览器可用、App 不可用”的典型现象,精准识别出根本原因是系统级证书信任策略变更(用户安装的 CA 被屏蔽),而非 SSL Pinning。仅通过 adb 命令将 CA 推送至系统证书存储并重启生效,无需 Frida、Objection 或修改 APK,实现零侵入解密。全文命令均基于 Windows + MuMu 模拟器环境实测通

JIANqiao19931029的博客 340

怀旧版电脑GBC模拟器

再现原版GBC掌机的界面,电脑运行连游戏界面都一样大小,很怀旧,很真实,(英文版)强烈推荐,很小的文件,很难才找到的。

SIMATIC S7-1200, S7-1500 PID 控制[手册]

SIMATIC S7-1200, S7-1500 PID 控制[手册]

具身智能 2026:启元人形机器人开发者生态的“安卓时刻”来了吗

一个贯穿全文的问题:人形机器人开发者生态,是否正从「封闭垂直」走向「开源平台化」?本文以开源底座的公开数据为标尺,盘点 SDK、URDF、仿真与技能分发的开放程度,并落回启元机器人的开源路径。所有关键数据均标注来源,文末附社区与学堂链接。

在北风吹起的时候加入我们的队伍 在秋雨到来的时候成为彼此的温暖 739

C# WinForm 基于DirectShow.dll编写的程序,可边预览边保存视频,音频和图片

基于微软的DirectShow.dll的二次开发1.自动识别连接的摄像头跟麦克风且可以对其进行选择(默认选中第一个)2.可录制wmv和avi两种格式的视频并且都带有音频,avi可以录制的分辨率和帧数进行选择,wmv默认进行了淸晰度优化如不喜欢可去除代码中有相关提示3.可在预览点开之后进行拍照,也可边录制视频边拍照4.添加了视频和音频的压缩功能(酌情使用)这次二次开发发现C#对DirectShow的相关信息太少,这次做完也不敢藏私共享出来,还望大家指正

android-ad-cleaner:android免 Root 批量关闭系统开屏与营销推送

橘子,计算机视觉方向工程师,深耕 AIGC 与多媒体处理领域,专注技术实用化落地。本文为原创内容,转载请注明来源与作者。优快云 首发 | 版权所有 © 2026。

下载资源或查看教程前,请务必阅读置顶的《免责声明》 489

Android 14 SELinux策略深度解析

init 的全局公开约束(如 neverallow init execute_no_trans,放 public 才能约束所有分区的规则)这就是"哪些可见"的强制机制。alps_s0(MTK 工程)的 device/mediatek/sepolicy/basic/ 提供。所有分区(system/system_ext/product/vendor)都可引用。init的公开约束、跨分区共用的 type/attribute/neverallow。——vendor 的 .cil 只能引用 plat 的。

新时代农民工 102

Android 开发入门实践(二):Kotlin 空安全,从可空类型到安全访问

延续 NewsApp 项目中的新闻标题练习,记录 Kotlin 安全调用。

Easyhood的博客 273

Android 模块化收口:能力组合框架、手写 LifecycleOwner 与热修复共存

二十多个模块业务高度相似却各有差异,核心矛盾在于“统一与灵活”的平衡。通过角色接口组合(如IProjectAware、IVoiceAware)实现能力按需注入,新增功能零侵入;用模板方法锁定生命周期流程,确保顺序一致;结合@CallSuper与工厂方法,既约束关键逻辑,又保留子类自由。最终实现:骨架统一、填空自由,管得住业务,又不侵入业务。

mmsx990的博客 395

Android Message源码阅读

本文梳理了Android Handler机制中Message的核心设计:链表结构、消息复用池(sPool)及回收机制,重点解析了异步消息与同步屏障(SyncBarrier)的协同机制。当主线程需优先处理UI绘制等高优先级任务时,系统通过postSyncBarrier插入屏障,使Looper跳过普通同步消息,优先执行标记为异步的UI渲染任务,确保帧率流畅。该机制是保障Android界面响应性的关键技术之一。

u012116089的博客 164

安卓部署Linux性能实测:一颗移动处理器,能否胜任日常 Linux 开发?

本文通过在一加Ace5上部署NimoteCode的ARM64 Linux环境,测试了其在CoreMark、SQLite编译及并行构建中的表现。结果显示,尽管单核性能约2.34GHz,多核扩展比达6.15×,但优化编译耗时仍高达35秒,反映移动处理器虽具备一定开发能力,却受限于文件系统开销与运行环境(如PRoot)。实际开发中,非优化构建效率较高,而并行编译可显著提速。结论:安卓手机可作为轻量级开发辅助工具,但无法替代桌面环境,真正价值在于便携性与即时响应,而非全功能替代。

一个不知名码农的专栏 281

Android中Gradle网络下载超时、更改存放位置(解决方案)

在国内开发Android应用,需要使用到一些外网才能正常访问的环境依赖。除了挂梯子,这里再提供几个常见问题的配置方案,可以已定程度减少下载超时问题。但是要注意,不同的开发环境、网络环境、时间段,出现的问题都是不可控的,遇到配置问题,建议保持耐心,多做尝试。

indeedes的博客 122
上一篇: 【情人节用Compose给女神写个爱心动画APP】
下一篇: Compose中的一些机制验证与总结——remember
LiePy
博客等级 码龄7年 1927粉丝 33原创
评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值