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

LiePy的博客地址:

https://blog.csdn.net/LiePy?type=blog

先看效果(文末附完整代码):

9b23c6479ff7e00b35370241506bbb67.gif

实现过程

由于我初次看文章时并没有看的太细,感觉这个效果很炫酷就上手做了,完成之后仔细对比各个细节的实现,发现是有一些不同的,不过整体的思路大致是一致的,最后还有彩蛋。

准备工作

创建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)。另外对参数的输入做合法校验和处理。

添加Canvas

在上面的蛛网图方法中的TODO 正文中添加Canvas组件,并传入modifier,Canvas内部就是我们控制绘制的地方了

@Preview(showBackground = true)
@Composable
fun SpiderWebRadarLineDiagramPreview() {
    SpiderWebRadarLineDiagram(
        modifier = Modifier.fillMaxSize(),
        dataList = listOf(4f, 5f, 5f, 4f, 5f),
        labelList = listOf("德", "智", "体", "美", "劳")
    )
}

编写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一下,就可以看到,一片空白,因为我们还没有绘制任何东西呢。

绘制任意多边形

绘制辅助圆

根据Canvas当前尺寸宽高的较小者,并预留5%的边距空白,确定辅助圆的半径(预留空白用于后续绘制标签文本)。

Canvas(modifier = modifier) {
        //计算多边形相接圆的半径
        val radius = java.lang.Float.min(size.height, size.width) * 0.45f
        //画辅助圆
        drawCircle(Color.Cyan, radius, center)
    }

7638ed46bc8143eff64f24c335b22173.png

计算顶点坐标

我们前面根据调用者传递进来的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作用域中的画布中心点。

画出各个顶点

我们可以先画出顶点看下计算的位置对不对,封装一个方法。

/**
 * 绘制多边形顶点
 * @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)
    }

8ff211f1dcdfdfee6bdf6320d9b729a8.png

连接相邻顶点,组成多边形

这里也封装一个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)
    }

现在是这样的。

05fcce28c3f4fdd5c0de07a48ae06f82.png

绘制蛛网

蛛网其实就是嵌套了几层与不同半径的圆相接的多边形,绘制流程类似,我们可以将上面的绘制一个多边形的代码抽象调整一下,需要绘制的层数作为参数传入,如下:

/**
 * 绘制蛛网
 *
 * @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是半径,效果如下:

e92a0d8d73d24e985efb9109b968fcce.png

绘制标签文本

有了前面的经验,标签文本的绘制就很简单了,计算确定文本位置,然后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函数里传入的 德、智、体、美、劳,效果如下:

ecb5039f08ae774f9259692263b006cc.png

绘制数据折线

绘制数据折线其实也是一个多边形,只是不一定是正多边形,思路和绘制一层多边形差不多,不同的是要根据数据计算顶点在最大半径上的位置,例如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)
    }

0fe6e7fb9df179d19f6b0cc4c0d8fb59.png

至此,蛛网图的绘制已经全部实现了,接下来我们看看怎么让它动起来。

手指拖动旋转

这块之前有接触过Compose多点触控,看官方示例Compose手势,多点触控,地址如下:

https://developer.android.google.cn/jetpack/compose/touch-input/gestures?hl=zh-cn#multitouch

很简单可以实现双指控制旋转或者大小缩放等功能,但是咱们这次挑战一下史大拿学长的单点拖动旋转方式,顺便学习一下Compose拖动手势相关的用法。

拖动手势监听

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参数,这样就能实现蛛网图旋转的时候,文本位置能跟随旋转,但是文本方向始终保持用户能正常阅读的方向。现在的效果是这样的(这里开始我把辅助圆去掉了,增加了个中心点)

38508f867865ec7703612d599941aeec.gif

添加旋转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,体验更好一些。

c9c5a921ea28ad0cde02cc8c4d8f93a3.gif

/   彩蛋   /

前面参数校验的时候,细心的同学可能会担心,蛛网图要是传入的数据dataList和labelList长度为1或者2怎么办,会不会报错,我们可以试一下。

size=2

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

115475c533bee49dbfe14bc4ba648f60.png

size=1

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

5c92dcc5945c7aa1b78039d44e2523a2.png

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

7cae13b9fc98656a4c4976cdf4880de4.png

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

至此本篇文章完结了,有不对的地方欢迎指正,一起交流学习,pass~

完整代码

本项目已收录至我的开源Compose动画及组件库ComposeAnimationKit,欢迎参与或star。

github地址:

https://github.com/LiePy/ComposeAnimationKit

gitee地址:

https://gitee.com/lie_py/compose-animation-kit

关注我获取更多知识或者投稿

0b3318b868e72d162d1920697b5c7b06.jpeg

b05a7eae4eb61e0aeb8175b4a0a0f91d.jpeg

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值