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

前言

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

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

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

显示效果大致如下:

img.png

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

最终实现效果如图:

preview4

开始编写

使用直线绘制

绘制主体

仔细分析上图,不难发现,其实不过就是一个由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)
)

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

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

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

number_index

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

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

方法写的很简单粗暴,一看就懂,例如,参考上面的图示索引,对于编号为 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
}

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

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

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

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

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

preview1

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

img.png

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

间距这个还好调整,修改一下 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)
}

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

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

那怎么办呢?

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

绘制两端斜角

我们可以通过 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)
            )
        }
    }
}

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

preview2

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

直接使用 drawPath 绘制

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

angle

很显然,是 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)
    }
}

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

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

让我们来看看效果:

preview3

唔,终于对味了,那接下来就是把其他几条直线也画出来就 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)
            )
        }

    }
}

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

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

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

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

我们来看看效果怎么样:

preview4

哈哈,终于像了!

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

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

总结

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

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

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