稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

喜迎国庆,用Compose赶制一面五星红旗

我们学习Jetpack Compose已经有一段时间了,通过前面的学习已能掌握足够的技巧以在实战中应用。恰逢普天庆国庆,利用我们学过的知识,使用Jetpack Compose来画一个迎风飘扬的五星红旗吧!废话不多说,先来看一眼效果图。

五星红旗的设计标准

需要特别注意,五星红旗有明确的设计标准的,在国旗法中有明确的制法说明

总结一下要点:宽与高之比为3比2,五颗星都在左上四分之一小矩形内,最大五角星直径约为高的十分之一,四个小五角星的一个角要指向大五角星的中心。

如何画五角星

五星红旗并不是特别复杂,拆解一下,其组成图案就是矩形和五角星了,矩形是基本的图形可以直接画。需要研究一下五角星怎么画。

画对称多边形的方法都要借助圆,因为几何图形最容易画的同时也是最标准的就是圆了,再借助角度从圆上取点,把点连成线就是多边形。多边形的顶角度数不一样,因此把圆分成多少份,就能画出不同的多边形了。

五角星也要借助圆,把五角星五个顶点连线就是一个正五边形了,所以在圆上取5个等分点,也即每隔72度取一个点,然后把这5个点每隔一点连成线,就是五角星了。如下图所示:

图2. 五角星画法

这种画法对手工尺规作图很友好,对程序来说,就没那么友好了。程序化的API需要明确的坐标点,把点串连成路径(Path)。这里需要的五角星是填充的,所以如果能知道5个顶点,和凹进来的五个点,只要把这10个点串起来,就能组成一个闭合的图形,得到我们想要五角星了。

五角星的外面五个点和内部五个点能组成两个正五边形,这两个正五边形的外接圆是两个同心圆,外顶点与内顶点刚好相差36度,正五边形的顶点之间是72度。所以,我们通过画两个半径不同的同心圆,每个圆分成5份,大圆的点与小圆的点交错开,就能画出一个五角星了。圆心和半径是关键的参数,通过圆心与半径,就能精细调整五角星的形状。

图3. 填充式五角星

因为五角星是填充色,所以我们把最外层的五个点与内层的五个点连串一起组成一个闭合的图形。通过前面的降Compose十八掌之『利涉大川』| Canvas学习,我们知道可以用路径(Path)来画图形,一共10个点把圆分成10分,所以角度是36度,半径是一大一小交错开来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
   val path = Path().apply {
        val pointNumber = 5
        val angle = PI.toFloat() / pointNumber
        val innerRadius = radius * cos(angle) / 2f

        for (i in pointNumber * 2 downTo 0) {
            val r = if (i % 2 == 1) radius else innerRadius
            val omega = angle * i

            val x = center.x + r * sin(omega)
            val y = center.y + r * cos(omega)
            lineTo(x, y)
        }
    }

    drawPath(
        path = path,
        color = color,
        style = Fill
    )

画五星红旗

根据制法以及画五角星的方法,我们总结一下设计要点:

  1. 用一个高height作为主要参数,那么宽就是其1.5倍,其他的参数都与height有关系,所以改变height就可以完全控制整个旗子的大小;
  2. 大五星圆心x是宽的1/6,y是height的1/4,可以看出比例是一致的,所以可以先计算y,再乘1.5就是x;
  3. 大五星的外接圆直径约是height的3/10,半径就是高度的3/20,这样大五星就完全确定了;
  4. 小五星的直径是高的1/10,半径就是1/20;从上到下命名为a,b,c,d;
  5. 小五星a的圆心x在宽的1/3,y在高的1/10
  6. 小五星b的圆心x在宽的2/5,y在高的1/5
  7. 小五星c的圆心x在宽的2/5,y在高的7/20
  8. 小五星d的圆心x在宽的1/3,y在高的9/20
  9. 小五星的角要对着大五星的圆心,也就是要把小五星旋转一下。一个办法对Path做变幻,但其实不用那么复杂。我们在画五角星时,选择点时加上一个偏移角度beta就可以了,这样尖角就有旋转角度了。这个旋转角度可以用小五星的圆心与alpha的圆心来求得,就是这两个圆心连线与水平x轴的夹角,用反正切atan来求。

综上,就可以写代码啦:

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
@Composable
fun FiveStarsRedFlag(height: Dp = 200.dp) {
    val stickWidth = 10.dp
    val flagWidth = height.times(1.5f)
    val flagHeight = height
    val canvasWidth = flagWidth.plus(stickWidth)
    val canvasHeight = height.times(2f)

    Canvas(modifier = Modifier.size(canvasWidth, canvasHeight)) {
        // The background
        drawRect(color = Color.Red, size = Size(flagWidth.toPx(), flagHeight.toPx()))

        // The stick
        drawRect(color = Color.LightGray, size = Size(stickWidth.toPx(), canvasHeight.toPx()))

        val centerY = flagHeight.toPx() / 4f
        val centerX = flagWidth.toPx() / 6f
        val radius = flagHeight.toPx() * 3f / 20f
        val smallRadius = flagHeight.toPx() / 20f
        val alphaCenter = Offset(centerX, centerY)

        // 大五角星 alpha
        drawStar(
            alphaCenter = alphaCenter,
            center = alphaCenter,
            radius = radius,
            color = Color.Yellow
        )

        // 小五星 a
        drawStar(
            alphaCenter = alphaCenter,
            center = Offset(flagWidth.toPx() / 3f, flagHeight.toPx() / 10f),
            radius = smallRadius,
            color = Color.Yellow
        )

        // 小五星 b
        drawStar(
            alphaCenter = alphaCenter,
            center = Offset(flagWidth.toPx() * 0.4f, flagHeight.toPx() / 5f),
            radius = smallRadius,
            color = Color.Yellow
        )

        // 小五星 c
        drawStar(
            alphaCenter = alphaCenter,
            center = Offset(flagWidth.toPx() * 0.4f, flagHeight.toPx() * 7 / 20f),
            radius = smallRadius,
            color = Color.Yellow
        )

        // 小五星 d
        drawStar(
            alphaCenter = alphaCenter,
            center = Offset(flagWidth.toPx() / 3f, flagHeight.toPx() * 9 / 20f),
            radius = smallRadius,
            color = Color.Yellow
        )
   }
}

fun DrawScope.drawStar(alphaCenter: Offset, center: Offset, radius: Float, color: Color) {
    val pointNumber = 5
    val angle = PI.toFloat() / pointNumber
    val innerRadius = radius * cos(angle) / 2f

    val beta = if (alphaCenter == center) {
        0f
    } else {
        PI.toFloat() / 2f - atan((center.y - alphaCenter.y) / (center.x - alphaCenter.x))
    }

    val path = Path().apply {
        for (i in 0 .. pointNumber * 2) {
            val r = if (i % 2 == 1) radius else innerRadius
            val omega = angle * i + beta

            val x = center.x + r * sin(omega)
            val y = center.y + r * cos(omega)
            lineTo(x, y)
        }
        close()
    }

    drawPath(
        path = path,
        color = color,
        style = Fill
    )
}

为了检查画图结果是否符合设计,我们可以画出制法中的那样的格子:

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
    if (DEBUG) {
        val strokeWidth = 0.8.dp.toPx()

        // Slice
        drawLine(
            Color.Black,
            Offset(stickWidth.toPx(), flagHeight.toPx() / 2f),
            Offset(flagWidth.toPx(), flagHeight.toPx() / 2f),
            strokeWidth = strokeWidth * 2f
        )

        drawLine(
            Color.Black,
            Offset(flagWidth.toPx() / 2f, 0f),
            Offset(flagWidth.toPx() / 2f, flagHeight.toPx()),
            strokeWidth = strokeWidth * 2f
        )

        // Grid
        for (i in 1 until 10) {
            drawLine(
                Color.Black,
                Offset(stickWidth.toPx(), flagHeight.toPx() * i / 20f),
                Offset(flagWidth.toPx() / 2f, flagHeight.toPx() * i / 20f),
                strokeWidth = strokeWidth
            )
        }

        for (i in 1 until 14) {
            drawLine(
                Color.Black,
                Offset(stickWidth.toPx() + flagWidth.toPx() * i / 30f, 0f),
                Offset(stickWidth.toPx() + flagWidth.toPx() * i / 30f, flagHeight.toPx() / 2f),
                strokeWidth = strokeWidth
            )
        }
   }

拿带格子的效果图,与制法设计图对比,可以发现一模一样,完全符合设计。

图4. 带格子的效果图

好了,到这里,我们的五星红旗就画完了。未完,别走啊,我们还要让旗子飘扬起来。

让五星红旗飘扬起来

旗子飘扬的真实形态是三维的曲面,比如用三角函数曲面计算每一个坐标点x, y, z,就像这篇文章中的做法那样。

但在Compose中无法实现,因为Compose,虽然也可以做三维的变幻,但都是针对整个图层的,没有办法针对图形中的每个坐标点去单独做变幻,这也是与三维图形库如OpenGL ES的最大区别。

在Compose中要想每个坐标点都不一样,只能绘制曲线,曲线 的点再由动画动态的去改变,这样就会有类似波动一样的效果,但都局限在二维。为此,我们需要用Path来画背景,上边和下边都用曲线,曲线的点用动画来不断的波动,这样就能有点波动的感觉了:

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
val infiniteTransition = rememberInfiniteTransition(label = "infinite transition")

val amplitude = with(LocalDensity.current) { height.div(8f).toPx() }
val heightPx = with(LocalDensity.current) { height.toPx() }

val waveDuration = 2000
val ya by infiniteTransition.animateFloat(
    initialValue = amplitude / 2f,
    targetValue = -amplitude / 2f,
    animationSpec = infiniteRepeatable(tween(waveDuration), RepeatMode.Reverse),
    label = "ya"
)
val yb by infiniteTransition.animateFloat(
    initialValue = -amplitude / 2f,
    targetValue = amplitude / 2f,
    animationSpec = infiniteRepeatable(tween(waveDuration), RepeatMode.Reverse),
    label = "yb"
)
val yc by infiniteTransition.animateFloat(
    initialValue = amplitude / 2f,
    targetValue = -amplitude / 2f,
    animationSpec = infiniteRepeatable(tween(waveDuration), RepeatMode.Reverse),
    label = "yc"
)

val ye by infiniteTransition.animateFloat(
    initialValue = heightPx + amplitude / 2f,
    targetValue = heightPx - amplitude / 2f,
    animationSpec = infiniteRepeatable(tween(waveDuration), RepeatMode.Reverse),
    label = "ye"
)
val yf by infiniteTransition.animateFloat(
    initialValue = heightPx - amplitude / 2f,
    targetValue = heightPx + amplitude / 2f,
    animationSpec = infiniteRepeatable(tween(waveDuration), RepeatMode.Reverse),
    label = "yf"
)
val yg by infiniteTransition.animateFloat(
    initialValue = heightPx + amplitude / 2f,
    targetValue = heightPx - amplitude / 2f,
    animationSpec = infiniteRepeatable(tween(waveDuration), RepeatMode.Reverse),
    label = "yg"
)

Canvas(
    modifier = Modifier.size(canvasWidth, height)
) {
    val stickOffset = Offset(stickWidth.toPx(), 0f)

    // The background
    val pathBG = Path().apply {
        moveTo(0f, 0f)
        cubicTo(flagWidth.toPx() / 3f, ya, flagWidth.toPx() * 2f / 3f, yb, flagWidth.toPx(), yc)

        lineTo(flagWidth.toPx(), ye)

        cubicTo(flagWidth.toPx() * 2f / 3f, yf, flagWidth.toPx() / 3f, yg, 0f, size.height)

        lineTo(0f, 0f)

        translate(stickOffset)
    }
    drawPath(path = pathBG, color = Color.Red, style = Fill)
   }

也可以用GraphicsLayer,再添加一点点Y轴和Z轴的旋转,就更像那么回事了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
val rotateY by infiniteTransition.animateFloat(
        initialValue = -3f,
        targetValue = 6f,
        animationSpec = infiniteRepeatable(tween(3000), RepeatMode.Reverse),
        label = "rotateY"
    )
Canvas(
        modifier = Modifier
            .size(canvasWidth, height)
            .graphicsLayer {
                transformOrigin = TransformOrigin(0f, 0f)
                rotationZ = 2f
                rotationY = rotateY
            }
    ) { ... }

至此,我们的五星红旗就算做完了,当然了可对背景的左边和右边也加上波动,就会更像一些了,完整代码可以看这里

让我们小结一下,看似简单的一个五星红旗,实现起来其实并不容易,用到了好多数学知识,书到用时方恨少,数学真的太重要了,无处不在。UI开发会涉及大量的数学(特别是几何)知识,要想做好UI必须 要有良好的数学功底,难度并不小。

仅供娱乐,请勿参考

以上的实现方式其实仅供娱乐,在真实的项目中不建议这样一笔一笔的用Canvas来画。建议的实现方式应该是找一个(或者让设计师提供)现成的五星红旗图形资源,然后当成图片来展示 出来。

这样做的好处是把设计与代码实现分离开来,当需要调整设计效果时,不必去修改代码,毕竟替换一个资源比起修改代码的风险要小很多,虽然说可能也只是调整一个整数(颜色),但毕竟是改代码了,风险还是有的。再者,分离开来能让设计工作由更为专业的人士来做,而不必受到(或者考虑)代码实现的限制。还有就是,用代码一笔一笔的画,无论研发效率还是运行效率其实都不高,远不如显示一张图片性能好。

最后

祝愿伟大的祖国繁荣昌盛,国泰民安!祝愿所有的朋友国庆快乐,天天开心!

References

Comments