• 使用 compose 的 Canvas 自定义绘制实现 LCD 显示数字效果


    前言

    前段时间谷歌开发者公众号发布了一个 compose 进阶挑战,挑战内容是完全使用 compose 编写一个计算器 APP。

    思考了一下准备做一个“仿真”形式的计算器。

    那么,既然想要做“仿真”,自然少不了显示效果的还原,经典的计算器都是使用的 LCD 显示屏,通过控制不同显象区域的显示与隐藏达到显示 0-9 的数字的目的。

    显示效果大致如下:
    请添加图片描述

    本文的内容就是通过使用 compose 的自定义绘制(Canvas),实现上图效果。

    最终实现效果如图:
    请添加图片描述

    开始编写

    使用直线绘制

    绘制主体

    仔细分析上图,不难发现,其实不过就是一个由3条短横线,4条长竖线构成的 “8” 字形显示区域,通过变换不同的线段显示隐藏来生成不同的数字。

    既然如此,肯定想到的就是使用 drawLine 来实现。

    首先定义一个 composable 函数:

    @Composable
    fun LcdNumber(
        number: Int,
        modifier: Modifier = Modifier,
        defaultColor: Color = Color.Gray,
        numberColor: Color = Color.Black,
        numberSize: IntSize = IntSize(10, 30)
    )
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    其中, number 表示要显示的数字,这里只允许传入 0-9;defaultColor 表示线段没有显示时的默认颜色; numberColor 表示线段需要显示时的颜色; numberSize 表示绘制数字的区域大小。

    在开始正式实现之前,我们需要先写几个辅助方法。

    首先,我们需要判断某条线段是否应该显示,为了方便说明,我们把不同线段编号如下:

    请添加图片描述

    然后,编写判断方法如下:

    private fun isNeedShow(index: Int, number: Int): Boolean {
        return when (index) {
            0 -> {
                number != 1 && number != 4
            }
            1 -> {
                number != 5 && number != 6
            }
            2 -> {
                number != 2
            }
            3-> {
                number != 1 && number != 4 && number != 7
            }
            4-> {
                number != 1 && number != 3 && number != 4 && number != 5 && number != 7 && number != 9
            }
            5-> {
                number != 1 && number != 2 && number != 3
            }
            6 -> {
                number != 0 && number != 1 && number != 7
            }
            else -> {
                false
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    方法写的很简单粗暴,一看就懂,例如,参考上面的图示索引,对于编号为 0 的直线,除了数字 1 和 4 ,其他数字都需要显示。

    有了判断是否需要显示的方法,再简单加两个方法:

    private fun getLcdNumberColor(defaultColor: Color, numberColor: Color, isNeedShow: Boolean): Color {
        return if (isNeedShow) numberColor else defaultColor
    }
    
    private fun getLcdNumberAlpha(isNeedShow: Boolean): Float {
        return if (isNeedShow) 1f else 0.35f
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    一个用来获取直线颜色,因为却决于直线是否显示,它们的颜色是不同的;一个用来获取直线的透明度,当某条直线不显示时,不仅要使用浅色颜色,还应该把透明度降低,不然不好看。

    完成上面的辅助方法后,就可以开始绘制直线了:

    @Composable
    fun LcdNumber(
        number: Int,
        modifier: Modifier = Modifier,
        defaultColor: Color = Color.Gray,
        numberColor: Color = Color.Black,
        numberSize: IntSize = IntSize(10, 30)
    ) {
        Canvas(modifier = modifier.size(numberSize.width.dp, numberSize.height.dp)) {
            if (number !in 0..9) return@Canvas
    
            val shortLineSize = numberSize.width.toFloat()
            val longLineSize = numberSize.height / 2f
            val strokeWidth = shortLineSize / 3f
    
            var isNeedShow = isNeedShow(0, number)
            drawLine(
                color = getLcdNumberColor(defaultColor, numberColor, isNeedShow),
                start = Offset(0f, 0f),
                end = Offset(shortLineSize, 0f),
                strokeWidth = strokeWidth,
                alpha = getLcdNumberAlpha(isNeedShow)
            )
            isNeedShow = isNeedShow(1, number)
            drawLine(
                color = getLcdNumberColor(defaultColor, numberColor, isNeedShow),
                start = Offset(shortLineSize, 0f),
                end = Offset(shortLineSize, longLineSize),
                strokeWidth = strokeWidth,
                alpha = getLcdNumberAlpha(isNeedShow)
            )
            isNeedShow = isNeedShow(2, number)
            drawLine(
                color = getLcdNumberColor(defaultColor, numberColor, isNeedShow),
                start = Offset(shortLineSize, longLineSize),
                end = Offset(shortLineSize, longLineSize * 2),
                strokeWidth = strokeWidth,
                alpha = getLcdNumberAlpha(isNeedShow)
            )
            isNeedShow = isNeedShow(3, number)
            drawLine(
                color = getLcdNumberColor(defaultColor, numberColor, isNeedShow),
                start = Offset(0f, longLineSize*2),
                end = Offset(shortLineSize, longLineSize*2),
                strokeWidth = strokeWidth,
                alpha = getLcdNumberAlpha(isNeedShow)
            )
            isNeedShow = isNeedShow(4, number)
            drawLine(
                color = getLcdNumberColor(defaultColor, numberColor, isNeedShow),
                start = Offset(0f, longLineSize),
                end = Offset(0f, longLineSize*2),
                strokeWidth = strokeWidth,
                alpha = getLcdNumberAlpha(isNeedShow)
            )
            isNeedShow = isNeedShow(5, number)
            drawLine(
                color = getLcdNumberColor(defaultColor, numberColor, isNeedShow),
                start = Offset(0f, 0f),
                end = Offset(0f, longLineSize),
                strokeWidth = strokeWidth,
                alpha = getLcdNumberAlpha(isNeedShow)
            )
            isNeedShow = isNeedShow(6, number)
            drawLine(
                color = getLcdNumberColor(defaultColor, numberColor, isNeedShow),
                start = Offset(0f, longLineSize),
                end = Offset(shortLineSize, longLineSize),
                strokeWidth = strokeWidth,
                alpha = getLcdNumberAlpha(isNeedShow)
            )
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73

    同样是简单粗暴的直接绘制,我们看一下预览效果:

    @Preview(showSystemUi = true)
    @Composable
    fun PreviewLcdNumber() {
        Column(modifier = Modifier
            .fillMaxSize()
            .padding(8.dp)
        ) {
            Row {
                LcdNumber(number = 0)
                LcdNumber(number = 1)
                LcdNumber(number = 2)
                LcdNumber(number = 3)
                LcdNumber(number = 4)
                LcdNumber(number = 5)
                LcdNumber(number = 6)
                LcdNumber(number = 7)
                LcdNumber(number = 8)
                LcdNumber(number = 9)
            }
            
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    请添加图片描述

    好像也还行?但是仔细一看,好像不够拟真啊?再看看原图和仿写的对比:

    请添加图片描述

    发现了吗?没错,原图每个直线的两端都是有不同的斜角的,而且直线之间并不是直接连在一起的,而是有一定的间距的。

    间距这个还好调整,修改一下 drawLinestartend 参数就行了。

    但是斜角要怎么实现呢?

    查看 drawLine 方法参数,发现有一个 cap 参数:

    cap treatment applied to the ends of the line segment

    可以使用这个参数更改线段的末尾样式,但是,只提供了三种变换方式:

    companion object {
        /**
         * Begin and end contours with a flat edge and no extension.
         */
        val Butt = StrokeCap(0)
    
        /**
         * Begin and end contours with a semi-circle extension.
         */
        val Round = StrokeCap(1)
    
        /**
         * Begin and end contours with a half square extension. This is
         * similar to extending each contour by half the stroke width (as
         * given by [Paint.strokeWidth]).
         */
        val Square = StrokeCap(2)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    貌似还不支持自己编写,反正我翻了一圈文档和源码,没有发现能自己编写的地方。

    也就是说,这个也行不通。

    那怎么办呢?

    或许我们可以稍微变通一下,使用绘制矩形来实现斜角效果?

    绘制两端斜角

    我们可以通过 rotate 方法,旋转绘制的内容。

    因此或许我们可以绘制一个特定尺寸的矩形,然后旋转,以此实现斜角效果:

    @Preview(showSystemUi = true)
    @Composable
    fun PreviewLine() {
        Canvas(modifier = Modifier.size(100.dp, 100.dp)) {
            drawLine(
                color = Color.Black,
                start = Offset(10f, 20f),
                end = Offset(70f, 20f),
                strokeWidth = 20f
            )
    
    
            withTransform({
                rotate(45F, Offset(80f, 20f))
            }) {
                drawRect(
                    Color.Black,
                    topLeft = Offset(65f, 20f),
                    size = Size(15f, 15f)
                )
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    上面的代码中,我们先绘制了一个直线,然后绘制一个矩形,并应用旋转变换,得出效果如下:

    请添加图片描述

    emmm,怎么说呢,确实是实现了斜角的效果,可是这也和原图的不符合啊,而且原图是单面斜角,不是这种箭头啊。
    如果能够绘制一个三角形,直接把三角形拼上去就简单多了,但是很显然, compose 没有提供绘制三角形的方法,
    但是可以通过 drawPath 自己实现,不过都使用 drawPath 了,为什么还要采用拼接直线和三角形的方法呢?直接用 drawPath 一把梭哈不是更香?

    直接使用 drawPath 绘制

    先用量角器量一下斜角的角度:

    请添加图片描述

    很显然,是 45° ,其它的斜角我也量过了,都是 45° ,那就好说了,至少不用算三角函数了。

    接下来就是使用 drawPath 绘制直线,这里我们以 0 号直线为例:

    @Composable
    private fun Line0(width: Float, length: Float) {
        Canvas(modifier = Modifier.size(100.dp, 100.dp)) {
            val path = Path()
            path.moveTo(0f, 0f)
            path.lineTo(length, 0f)
            path.lineTo(length-width, width)
            path.lineTo(width, width)
    
            drawPath(path = path, color = Color.Black)
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    上面代码中 width 表示线宽, length 表示线长。

    因为斜角是 45° 所以不需要做坐标计算,直接使用 path.lineTo(length-width, width) 即可绘制出右边斜角,而左边斜角则直接使用 path.lineTo(width, width) 绘制。

    让我们来看看效果:

    请添加图片描述

    唔,终于对味了,那接下来就是把其他几条直线也画出来就 OK 了:

    @Composable
    fun LcdNumber(
        number: Int,
        modifier: Modifier = Modifier,
        defaultColor: Color = Color.Gray,
        numberColor: Color = Color.Black,
        numberSize: IntSize = IntSize(10, 30)
    ) {
        Canvas(modifier = modifier.size(numberSize.width.dp, numberSize.height.dp)) {
            if (number !in 0..9) return@Canvas // 如果不是数字 0-9 就直接退出
    
            val path = Path()
            
            var shortLineSize = numberSize.width.toFloat() // 使用画布宽度作为横线长度
            val longLineSize = numberSize.height / 2f // 使用画布高度的一半作为竖线长度
            val strokeWidth = shortLineSize / 3f // 使用横线的 1/3 作为线段宽度
            val spacing = 1f // 线段间隔 1 像素
            var isNeedShow = false
    
            // draw line 0
            isNeedShow = isNeedShow(0, number)
            path.moveTo(0f, 0f) // 移动画笔至画布原点
            path.lineTo(shortLineSize, 0f) // 从上一个点向右直线移动到横线长度位置
            path.lineTo(shortLineSize - strokeWidth, strokeWidth) // 从上一个点向左偏移线段宽度并向下偏移线段宽度,直线移动
            path.lineTo(strokeWidth, strokeWidth) // 从上一个偏移至xy轴至线段宽度,直线移动
            // 按照上面的路径绘制图形,闭合最后一个坐标和第一个坐标,并且填充图形
            drawPath(
                path = path,
                color = getLcdNumberColor(defaultColor, numberColor, isNeedShow),
                alpha = getLcdNumberAlpha(isNeedShow)
            )
            // draw line3
            isNeedShow = isNeedShow(3, number)
            // 直接通过旋转 0 号直线的 path 绘制 3 号直线
            // 旋转角度为顺时针 180° ,旋转中心为 shortLineSize/2, longLineSize+strokeWidth+spacin
            // 即整个数字的中心点
            rotate(180f, Offset(shortLineSize/2, longLineSize+strokeWidth+spacing)) {
                drawPath(
                    path = path,
                    color = getLcdNumberColor(defaultColor, numberColor, isNeedShow),
                    alpha = getLcdNumberAlpha(isNeedShow)
                )
            }
    
            // draw line1
            path.reset() // 清除上次对 Path 的操作,重新开始新的偏移
            isNeedShow = isNeedShow(1, number)
            path.moveTo(shortLineSize+spacing, spacing)
            path.lineTo(shortLineSize+spacing, spacing + longLineSize)
            path.lineTo(shortLineSize+spacing-strokeWidth/2, longLineSize+spacing+strokeWidth)
            path.lineTo(shortLineSize+spacing-strokeWidth, longLineSize+spacing)
            path.lineTo(shortLineSize+spacing-strokeWidth, strokeWidth+spacing)
            drawPath(
                path = path,
                color = getLcdNumberColor(defaultColor, numberColor, isNeedShow),
                alpha = getLcdNumberAlpha(isNeedShow)
            )
    
            // draw line4
            isNeedShow = isNeedShow(4, number)
            rotate(180f, Offset(shortLineSize/2, longLineSize+strokeWidth+spacing)) {
                drawPath(
                    path = path,
                    color = getLcdNumberColor(defaultColor, numberColor, isNeedShow),
                    alpha = getLcdNumberAlpha(isNeedShow)
                )
            }
    
            // draw line2
            isNeedShow = isNeedShow(2, number)
            path.reset()
            path.moveTo(shortLineSize+spacing-strokeWidth/2, longLineSize+spacing*2+strokeWidth)
            path.lineTo(shortLineSize+spacing, longLineSize+spacing*2+strokeWidth*2)
            path.lineTo(shortLineSize+spacing, longLineSize*2+spacing*2+strokeWidth*2)
            path.lineTo(shortLineSize+spacing-strokeWidth, longLineSize*2+spacing*2+strokeWidth)
            path.lineTo(shortLineSize+spacing-strokeWidth, longLineSize+spacing*2+strokeWidth*2)
            drawPath(
                path = path,
                color = getLcdNumberColor(defaultColor, numberColor, isNeedShow),
                alpha = getLcdNumberAlpha(isNeedShow)
            )
    
            // draw line5
            isNeedShow = isNeedShow(5, number)
            rotate(180f, Offset(shortLineSize/2, longLineSize+strokeWidth+spacing)) {
                drawPath(
                    path = path,
                    color = getLcdNumberColor(defaultColor, numberColor, isNeedShow),
                    alpha = getLcdNumberAlpha(isNeedShow)
                )
            }
    
            // draw line6
            isNeedShow = isNeedShow(6, number)
            shortLineSize -= strokeWidth
            path.reset()
            path.moveTo(strokeWidth+spacing, longLineSize+spacing*2)
            path.lineTo(strokeWidth+spacing+shortLineSize-strokeWidth*2, longLineSize+spacing*2)
            path.lineTo(strokeWidth+spacing+shortLineSize-strokeWidth, longLineSize+spacing*2+strokeWidth/2)
            path.lineTo(strokeWidth+spacing+shortLineSize-strokeWidth*2, longLineSize+spacing*2+strokeWidth)
            path.lineTo(strokeWidth+spacing, longLineSize+spacing*2+strokeWidth)
            path.lineTo(strokeWidth+spacing-strokeWidth, longLineSize+spacing*2+strokeWidth/2)
            translate(left = strokeWidth/2, top = strokeWidth/2) {
                drawPath(
                    path = path,
                    color = getLcdNumberColor(defaultColor, numberColor, isNeedShow),
                    alpha = getLcdNumberAlpha(isNeedShow)
                )
            }
    
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112

    上面代码我已经添加了注释。

    这里说一下, 3 号直线可以由 0 号直线旋转得到、4 号 可由 1 号旋转得来、5 号可由 2 号旋转得来。

    其实,理论上来说,横向的直线,除了六号,其他全部可以由 0 号旋转得来,竖向直线全部可以由 1 号旋转得来,但是我翻遍了文档和源码没有找到 Z 轴旋转,只有 X,Y 轴旋转,所以导致有些线无法直接旋转得到。

    (ps:其实看源码找到一个通过矩阵变形可以实现 Z 轴旋转,但是引入矩阵反而会更麻烦了,索性多写几个算了)

    我们来看看效果怎么样:

    请添加图片描述

    哈哈,终于像了!

    虽然因为某些尺寸计算可能不太完美,导致字体有点偏“瘦长”了,但是总体来说还是挺还原的。

    对了,上面的代码,我没有对单位进行换算,各位使用时别忘了换算一下单位

    总结

    compose 的 Canvas 的自定义绘制相比于原生 view 的绘制简单的多,因为少了很多模板代码,也不用去考虑生命周期的问题。

    但是简单也有简单的劣势,那就是可定制性相比于原生 view 没有那么多,少了一些方法。

    对了,写完这个“仿真”显示界面,我突然觉得好像“仿真”计算器并没有什么意思,所以决定不做这个类型的了(笑

  • 相关阅读:
    一个”.java”源文件中是否可以包括多个类?有什么限制?(企业真题)
    STM32与ZigBee无线通信技术在工业自动化中的应用
    java项目-第165期ssm咨询交流论坛_ssm毕业设计_计算机毕业设计
    Verilog实现半整数分频,3.5分频电路,可推广至N.5分频
    MySQL数据库的性能优化及自动化运维与Mysql高并发优化详细教程
    # 基于MongoDB实现商品管理系统(2)
    解密Spring中的Bean实例化:推断构造方法(上)
    使用非空断言解决Typescript报错:对象可能为 “null“
    【Android-Jetpack进阶】7、DataBinding 布局的变量与事件绑定、inlclude 二级页面绑定、自定义 BindingAdapter
    [激光原理与应用-35]:《光电检测技术-2》- 光学测量基础 - 认识光源
  • 原文地址:https://blog.csdn.net/sinat_17133389/article/details/126245619