前段时间谷歌开发者公众号发布了一个 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)
)
其中, 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
}
}
}
方法写的很简单粗暴,一看就懂,例如,参考上面的图示索引,对于编号为 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)
}
}
}
好像也还行?但是仔细一看,好像不够拟真啊?再看看原图和仿写的对比:
发现了吗?没错,原图每个直线的两端都是有不同的斜角的,而且直线之间并不是直接连在一起的,而是有一定的间距的。
间距这个还好调整,修改一下 drawLine
的 start
和 end
参数就行了。
但是斜角要怎么实现呢?
查看 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)
)
}
}
}
上面的代码中,我们先绘制了一个直线,然后绘制一个矩形,并应用旋转变换,得出效果如下:
emmm,怎么说呢,确实是实现了斜角的效果,可是这也和原图的不符合啊,而且原图是单面斜角,不是这种箭头啊。
如果能够绘制一个三角形,直接把三角形拼上去就简单多了,但是很显然, compose 没有提供绘制三角形的方法,
但是可以通过 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)
}
}
上面代码中 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)
)
}
}
}
上面代码我已经添加了注释。
这里说一下, 3 号直线可以由 0 号直线旋转得到、4 号 可由 1 号旋转得来、5 号可由 2 号旋转得来。
其实,理论上来说,横向的直线,除了六号,其他全部可以由 0 号旋转得来,竖向直线全部可以由 1 号旋转得来,但是我翻遍了文档和源码没有找到 Z 轴旋转,只有 X,Y 轴旋转,所以导致有些线无法直接旋转得到。
(ps:其实看源码找到一个通过矩阵变形可以实现 Z 轴旋转,但是引入矩阵反而会更麻烦了,索性多写几个算了)
我们来看看效果怎么样:
哈哈,终于像了!
虽然因为某些尺寸计算可能不太完美,导致字体有点偏“瘦长”了,但是总体来说还是挺还原的。
对了,上面的代码,我没有对单位进行换算,各位使用时别忘了换算一下单位
compose 的 Canvas 的自定义绘制相比于原生 view 的绘制简单的多,因为少了很多模板代码,也不用去考虑生命周期的问题。
但是简单也有简单的劣势,那就是可定制性相比于原生 view 没有那么多,少了一些方法。
对了,写完这个“仿真”显示界面,我突然觉得好像“仿真”计算器并没有什么意思,所以决定不做这个类型的了(笑