Jetpack Compose初体验之自定义图表

·  阅读 2052
Jetpack Compose初体验之自定义图表

本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

开发项目的时候,难免会遇到原生控件无法满足,需要自定义的情况,今天通过绘制几个图表来练习一下Jetpack Compose 中的自定义View。

线形图

copmose_30.gif

绘制原理和之前xml中一样,只不过实现的方式变了一些,比之前简单了很多,比如下面通过path来绘制线形图。构建好path之后,直接在Canvas中绘制就OK了。

如果想要对图标进行双指缩放,可以通过Modifier.graphicsLayer().transformable()来监听手势。通过rememberTransformableState来监听手指缩放的大小然后将返回值赋值给相应的变量就可以啦

完整代码:

    data class Point(val X: Float = 0f, val Y: Float = 0f)

    @Composable
    fun LineChart() {
        //用来记录缩放大小
        var scale by remember { mutableStateOf(1f) }
        val state = rememberTransformableState {
                zoomChange, panChange, rotationChange ->
            scale*=zoomChange
        }
        val point = listOf(
            Point(10f, 10f), Point(50f, 100f), Point(100f, 30f),
            Point(150f, 200f), Point(200f, 120f), Point(250f, 10f),
            Point(300f, 280f), Point(350f, 100f), Point(400f, 10f),
            Point(450f, 100f), Point(500f, 200f)
        )
        val path = Path()
        for ((index, item) in point.withIndex()) {
            if (index == 0) {
                path.moveTo(item.X*scale, item.Y)
            } else {
                path.lineTo(item.X*scale, item.Y)
            }
        }
        val point1 = listOf(
            Point(10f, 210f), Point(50f, 150f), Point(100f, 130f),
            Point(150f, 200f), Point(200f, 80f), Point(250f, 240f),
            Point(300f, 20f), Point(350f, 150f), Point(400f, 50f),
            Point(450f, 240f), Point(500f, 140f)
        )
        val path1 = Path()
        path1.moveTo(point1[0].X*scale, point1[0].Y)
        path1.cubicTo(point1[0].X*scale, point1[0].Y, point1[1].X*scale, point1[1].Y, point1[2].X*scale, point1[2].Y)
        path1.cubicTo(point1[3].X*scale, point1[3].Y, point1[4].X*scale, point1[4].Y, point1[5].X*scale, point1[5].Y)
        path1.cubicTo(point1[6].X*scale, point1[6].Y, point1[7].X*scale, point1[7].Y, point1[8].X*scale, point1[8].Y)
        path1.cubicTo(point1[7].X*scale, point1[7].Y, point1[8].X*scale, point1[8].Y, point1[9].X*scale, point1[9].Y)

        Canvas(
            modifier = Modifier
                .fillMaxWidth()
                .height(120.dp)
                .background(Color.White)
                //监听手势缩放
                .graphicsLayer(
                ).transformable(state)
        ) {
            //绘制 X轴 Y轴
            drawLine(
                start = Offset(10f, 300f),
                end = Offset(10f, 0f),
                color = Color.Black,
                strokeWidth = 2f
            )
            drawLine(
                start = Offset(10f, 300f),
                end = Offset(510f, 300f),
                color = Color.Black,
                strokeWidth = 2f
            )
            //绘制path
            drawPath(
                path = path,
                color = Color.Blue,
                style = Stroke(width = 2f)
            )
            drawPath(
                path = path1,
                color = Color.Green,
                style = Stroke(width = 2f)
            )
        }
    }

复制代码

柱状图

copmose_31.gif

下面来绘制柱状图,绘制很简单,直接根据坐标绘制矩形就可以了。Jetpack Compose中的绘制矩形的API跟之前XML中的API不大一样,需要提供绘制的左上角和矩形的大小就可以绘制了,看一下构造函数就知道了。

然后给柱子加上点击事件,Jetpack Compose中监听点击的屏幕位置坐标使用Modifier中的pointerInput方法,然后判断点击的坐标是否在矩形的范围之内即可,下面代码中只判断了X轴 的坐标,也可以在加上Y轴的判断。

最后再给柱形图加上动画,动画使用animateFloatAsState方法,值设置为0到1代表当前绘制高度的百分比,然后绘制的时候给高度添加该百分比的值就OK了。

完整代码:

  private fun identifyClickItem(points: List<Point>, x: Float, y: Float): Int {
        for ((index, point) in points.withIndex()) {
            if (x > point.X+20 && x < point.X + 20+40) {
                return index
            }
        }
        return -1
    }

    @Composable
    fun BarChart() {
        val point = listOf(
            Point(10f, 10f), Point(90f, 100f), Point(170f, 30f),
            Point(250f, 200f), Point(330f, 120f), Point(410f, 10f),
            Point(490f, 280f), Point(570f, 100f), Point(650f, 10f),
            Point(730f, 100f), Point(810f, 200f)
        )
        var start by remember { mutableStateOf(false) }
        val heightPre by animateFloatAsState(
            targetValue = if (start) 1f else 0f,
            animationSpec = FloatTweenSpec(duration = 1000)
        )
        Canvas(
            modifier = Modifier
                .fillMaxWidth()
                .height(300.dp)
                .background(Color.White)
                .pointerInput(Unit) {
                    detectTapGestures(
                        onTap = {
                            val i = identifyClickItem(point, it.x, it.y)
                            Log.d("pointerInput", "onTap: ${it.x} ${it.y} item:$i")
                            Toast
                                .makeText(this@FourActivity, "onTap: $i", Toast.LENGTH_SHORT)
                                .show()
                        }
                    )
                }
        ) {
            //绘制 X轴 Y轴
            drawLine(
                start = Offset(10f, 600f),
                end = Offset(10f, 0f),
                color = Color.Black,
                strokeWidth = 2f
            )
            drawLine(
                start = Offset(10f, 600f),
                end = Offset(850f, 600f),
                color = Color.Black,
                strokeWidth = 2f
            )
            start = true
            for (p in point) {
                drawRect(
                    color = Color.Blue,
                    topLeft = Offset(p.X + 20, 600 - (600 - p.Y) * heightPre),
                    size = Size(40f, (600 - p.Y) * heightPre)
                )
            }
        }
    }
复制代码

饼图

copmose_32.gif

最后绘制一个饼图,饼图的实现方式可以通过绘制drawPathdrawArc两种方式实现,drawArc的方式简单一点。

给饼图中的每一块添加点击事件,点击事件也是在Modifier的pointerInput方法中监听点击的坐标。Math.atan2()返回从原点(0,0) 到 (x,y)的线与x轴正方向的弧度值,然后通Math.toDegrees()方法把弧度转化为角度,最后通过角度获取点击的区域。

完整代码:

 private fun getPositionFromAngle(angles:List<Float>,touchAngle:Double):Int{
        var totalAngle = 0f
        for ((i, angle) in angles.withIndex()) {
            totalAngle +=angle
            if(touchAngle<=totalAngle){
                return i
            }
        }
        return -1
    }
    @Composable
    fun PieChart() {
        val point = listOf(10f, 40f, 20f, 80f, 100f, 60f)
        val color = listOf(Color.Blue, Color.Yellow, Color.Green, Color.Gray, Color.Red, Color.Cyan)
        val sum = point.sum()
        var startAngle = 0f
        val radius = 200f
        val rect = Rect(Offset(-radius, -radius), Size(2 * radius, 2 * radius))
        val path = Path()
        val angles = mutableListOf<Float>()
        val regions = mutableListOf<Region>()
        var start by remember { mutableStateOf(false) }
        val sweepPre by animateFloatAsState(
            targetValue = if (start) 1f else 0f,
            animationSpec = FloatTweenSpec(duration = 1000)
        )
        Canvas(
            modifier = Modifier
                .fillMaxWidth()
                .height(200.dp)
                .background(Color.White)
                .pointerInput(Unit) {
                    detectTapGestures(
                        onTap = {
                            Log.d(
                                "pointerInput",
                                "onTap: ${it.x - radius.toInt()} ${it.y - radius.toInt()} ${regions}"
                            )
                            var x = it.x - radius
                            var y = it.y - radius
                            var touchAngle = Math.toDegrees(Math.atan2(y.toDouble(),x.toDouble()))
                            //坐标1,2象限返回-180~0  3,4象限返回0~180
                            if(x<0&&y<0 || x>0&&y<0){//1,2象限
                                touchAngle += 360;
                            }
                            val position = getPositionFromAngle(touchAngle = touchAngle,angles = angles)
                            Toast
                                .makeText(
                                    this@FourActivity,
                                    "onTap: $position",
                                    Toast.LENGTH_SHORT
                                )
                                .show()
                        }
                    )
                }
        ) {
            translate(radius, radius) {
                start = true
                for ((i, p) in point.withIndex()) {
                    var sweepAngle = p / sum * 360f
                    println("sweepAngle: $sweepAngle  p:$p  sum:$sum")
                    path.moveTo(0f, 0f)
                    path.arcTo(rect = rect, startAngle, sweepAngle*sweepPre, false)
                    angles.add(sweepAngle)
                    drawPath(path = path, color = color[i])
                    path.reset()

//                    drawArc(color = color[i],
//                        startAngle = startAngle,
//                        sweepAngle = sweepAngle,
//                        useCenter = true,
//                        topLeft = Offset(-radius,-radius),
//                        size = Size(2*radius,2*radius)
//                    )

                    startAngle += sweepAngle
                }
            }
        }
    }
复制代码

Jetpack Compose 刚出来有一些功能还不完善,可以在drawIntoCanvas的作用域中使用使用原来的canvas,按照原来的方式来绘制。drawIntoCanvas作用域内的对象是一个canvas,通过it.nativeCanvas方法可以返回一个原生Android中的canvas对象。我们就可以通过它来按照原来的方式绘制了。

比如上面的饼图的点击事件,原来我们可以通过Path和Region这两个类结合,计算出每一块的绘制区域。但是在使用的时候发现Jetpack Compose的UI包中没有对应的Region类,只有对应的Path类,想要使用上面的功能就只能使用原来的Path类和Region来计算了。使用方式如下:

@Composable
    fun PieChart1(){
        val point = listOf(10f, 40f, 20f, 80f, 100f, 60f)
        val colors = listOf(Color.Blue, Color.Yellow, Color.Green, Color.Gray, Color.Red, Color.Cyan)
        val sum = point.sum()
        var startAngle = 0f
        val radius = 200f
        val path = android.graphics.Path()
        val rect = android.graphics.RectF(-radius,-radius,radius,radius)
        val regions = mutableListOf<Region>()
        val paint = Paint()
        paint.isAntiAlias = true
        paint.style = Paint.Style.FILL
        Canvas(
            modifier = Modifier
                .fillMaxWidth()
                .height(200.dp)
                .background(Color.White)
                .pointerInput(Unit) {
                    detectTapGestures(
                        onTap = {
                            Log.d(
                                "pointerInput",
                                "onTap: ${it.x - radius.toInt()} ${it.y - radius.toInt()} ${regions.toString()}"
                            )
                            val x = it.x - radius
                            val y = it.y - radius
                            var position = -1
                            for ((i, region) in regions.withIndex()) {
                                if(region.contains(x.toInt(),y.toInt())){
                                    position = i
                                }
                            }
                            Toast
                                .makeText(
                                    this@FourActivity,
                                    "onTap: $position",
                                    Toast.LENGTH_SHORT
                                )
                                .show()
                        }
                    )
                }
        ) {
            translate(radius, radius) {
                drawIntoCanvas {
                    for ((i, p) in point.withIndex()) {
                        var sweepAngle = p / sum * 360f
                        println("sweepAngle: $sweepAngle  p:$p  sum:$sum")
                        path.moveTo(0f, 0f)
                        path.arcTo(rect,startAngle,sweepAngle)
                        //计算绘制区域并保存
                        val r = RectF()
                        path.computeBounds(r,true)
                        val region = Region()
                        region.setPath(path, Region(r.left.toInt(),r.top.toInt(),r.right.toInt(),r.bottom.toInt()))
                        regions.add(region)

                        paint.color = colors[i].toArgb()
                        it.nativeCanvas.drawPath(path,paint)
                        path.reset()
                        startAngle += sweepAngle
                    }
                }
            }
        }
    }
复制代码

运行效果跟跟前面绘制的饼图效果一样。

总结:Jetpack Compose中自定义View的API比原来的方式简洁了不少,而且当当前API无法满足需求的时候,也可以很方便的使用原来的API进行绘制,体验很不错。

分类:
Android
分类:
Android