Compose 挖孔卡片实现

1,552 阅读7分钟

去年入坑鸿蒙,现在算来快半年没碰Android了。昨天JetPack Compose博物馆群里大佬们在讨论上图如何用Compose实现问题。大概看了看应该十分钟可以搞定的东西,于是发表了意见,裁剪背景就可以,十分钟应该能搞定。抱着对自己话负责,以及回忆一下Compose的知识,有了后补的这篇文章。也许大家也可以从中学到一些技巧。

一、需求实现分析

image.png

1、分析需求

需求图显示,卡片为四角圆滑的矩形,两侧有半圆挖孔,且圆角需透底显示背景。因此,不能仅使用填充颜色,而需要通过裁剪来实现该效果。

2、技术分析

不要为了自定义而进行编写代码,好的自定义应该建立在官方原有稳定组件的基础上,且可以适用于各个组件。在ComposeModifier.backgound作为在组件内容背后可以填充具有形状的API, Modifier.clip可以对内容进行裁剪的API,应该首先想到,而不是脱离原生组件,完全自定义一个容器或背景组件。

/**
 * Draws [shape] with a solid [color] behind the content.
 *
 * @sample androidx.compose.foundation.samples.DrawBackgroundColor
 *
 * @param color color to paint background with
 * @param shape desired shape of the background
 */
@Stable
fun Modifier.background(
    color: Color,
    shape: Shape = RectangleShape
): Modifier {
    val alpha = 1.0f // for solid colors
    return this.then(
        BackgroundElement(
            color = color,
            shape = shape,
            alpha = alpha,
            inspectorInfo = debugInspectorInfo {
                name = "background"
                value = color
                properties["color"] = color
                properties["shape"] = shape
            }
        )
    )
}


/**
 * Clip the content to [shape].
 *
 * @param shape the content will be clipped to this [Shape].
 */
@Stable
fun Modifier.clip(shape: Shape) = graphicsLayer(shape = shape, clip = true)

综合分析,我们用Modifier.backgound, Modifier.clip实现。

二、代码实现

案例中的布局为了样式瞎写,可以根据需求编写优美的组件。

1、简单布局

简单写个架子,添加了基本的文字、按钮背景。并简单的自定义了个BezierShape作为裁剪形状。效果如下:

@Stable
class BezierShapes(
) :
    Shape {
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        val path = Path()
        val rect = Rect(Offset(0f, 0f), Offset(size.width, size.height))
        path.addRoundRect(
            RoundRect(
                rect,
                CornerRadius(30f),
                CornerRadius(30f),
                CornerRadius(30f),
                CornerRadius(30f)
            )
        )
        path.close()
        return Outline.Generic(path)
    }
}

image.png 具体代码:

@Stable
class BezierShape(
) :
    Shape {
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        val path = Path()
        val rect = Rect(Offset(0f, 0f), Offset(size.width, size.height))
        path.addRoundRect(
            RoundRect(
                rect,
                CornerRadius(30f),
                CornerRadius(30f),
                CornerRadius(30f),
                CornerRadius(30f)
            )
        )
        path.close()
        return Outline.Generic(path)
    }
}

@Preview(widthDp = 480, heightDp = 200)
@Composable
fun CardComposeView(navigateToScreen: (route: String) -> Unit = {}) {
    Box() {
        Image(
            painter = painterResource(R.mipmap.ticket_bar_bg),
            contentDescription = "",
            modifier = Modifier
                .fillMaxSize()
                .fillMaxWidth()
                .blur(10.dp),
            contentScale = ContentScale.Crop
        )
        Box(
            Modifier
                .background(Color.Transparent)
                .padding(10.dp)
        ) {
            Box(
                Modifier
                    .fillMaxWidth()
                    .fillMaxHeight()
                    .clip(BezierShapes())
                    .background(Color(238, 220, 203, 255),BezierShapers())
                    .padding(10.dp)

            ) {
                Column(Modifier.fillMaxWidth()) {
                    Text(
                        "$12 OFF",
                        color = Color.Black,
                        fontSize = 20.sp,
                        fontWeight = FontWeight.Bold,
                        modifier = Modifier.padding(8.dp)
                    )
                    Text(
                        "Available for orders over $39",
                        color = Color.Gray,
                        fontSize = 16.sp,
                        fontWeight = FontWeight.Bold,
                        modifier = Modifier.padding(start = 8.dp, bottom = 10.dp, top = 6.dp)
                    )
                    Text(
                        "06/25/2023 09:00-07/01/2023 09:00",
                        color = Color.Gray,
                        fontSize = 16.sp,
                        fontWeight = FontWeight.Bold,
                        modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)
                    )
                    Box(Modifier.height(20.dp))
                    Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth().padding(end = 10.dp)) {
                        Row(
                            modifier = Modifier
                                .wrapContentSize()
                                .padding(start = 10.dp)
                                .background(Color(239, 230, 216, 255), shape = RoundedCornerShape(5.dp))
                        ) {
                            Text(
                                "Code:",
                                color = Color.Black,
                                fontSize = 16.sp,
                                modifier = Modifier.padding(start = 8.dp).padding(vertical = 8.dp)
                            )
                            Text(
                                "DsCT12",
                                color = Color.Black,
                                fontSize = 16.sp,
                                fontWeight = FontWeight.Bold,
                                modifier = Modifier.padding(start = 8.dp).padding(top = 8.dp, end = 10.dp)
                            )
                        }
                        Row(
                            modifier = Modifier
                                .wrapContentSize()
                                .padding(start = 10.dp)
                                .background(Color(134, 56, 4, 255), shape = RoundedCornerShape(5.dp))
                        ) {
                            Text(
                                "COPY",
                                color = Color.White,
                                fontSize = 20.sp,
                                modifier = Modifier.padding(start = 8.dp, end = 8.dp).padding(vertical = 8.dp)
                            )
                        }
                    }

                }
            }
        }
    }
}

2、自定义裁剪路径

当前路径只是增加了一个圆角矩形,所以裁剪完之后如下所示。如何在左右两边各挖一个孔呢?也是实现这个卡片关键所在。

image.png

Path熟悉的开发者,应该知道path.addPath,我们将两个圆的路径添加到圆角矩形路径中看看效果。

@Stable
class BezierShapes(
    private var circleRadius: Float = 30f,
    private var circleMarginTop: Float = 80f
) :
    Shape {
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        val path = Path()
        val rect = Rect(Offset(0f, 0f), Offset(size.width, size.height))
        path.addRoundRect(
            RoundRect(
                rect,
                CornerRadius(30f),
                CornerRadius(30f),
                CornerRadius(30f),
                CornerRadius(30f)
            )
        )
        val circleLeftPath = Path()
        val circleRightPath = Path()
        val circleLeftRect = Rect(Offset(0f, size.height / 2 + circleMarginTop), circleRadius)
        val circleRightRect =
            Rect(Offset(size.width, size.height / 2 + circleMarginTop), circleRadius)
        circleLeftPath.addArc(circleLeftRect, 0f, 360f)
        circleRightPath.addArc(circleRightRect, 0f, 360f)
        path.addPath(circleLeftPath)
        path.addPath(circleRightPath)
        path.close()
        return Outline.Generic(path)
    }
}

效果如下:

image.png

仔细观察之后,可以看到路径裁剪区域,多了一部分半圆。原因是两个圆被添加到了圆角矩形路径中。而重叠部分因为路径的合并而丢失,到此可能会挡住很多开发者继续进行。熟悉Path相关的API开发者应该知道路径的合并是可以取交集、并集等。那就是Path.op

其中:Difference表示,从前一个路径中减去第二个路径。


fun op(
    path1: Path,
    path2: Path,
    operation: PathOperation
): Boolean

value class PathOperation internal constructor(@Suppress("unused") private val value: Int) {
    companion object {
        /**
         * Subtract the second path from the first path.
         *
         * For example, if the two paths are overlapping circles of equal diameter
         * but differing centers, the result would be a crescent portion of the
         * first circle that was not overlapped by the second circle.
         *
         * See also:
         *
         *  * [ReverseDifference], which is the same but subtracting the first path
         *    from the second.
         */
        val Difference = PathOperation(0)
        /**
         * Create a new path that is the intersection of the two paths, leaving the
         * overlapping pieces of the path.
         *
         * For example, if the two paths are overlapping circles of equal diameter
         * but differing centers, the result would be only the overlapping portion
         * of the two circles.
         *
         * See also:
         *  * [Xor], which is the inverse of this operation
         */
        val Intersect = PathOperation(1)

        /**
         * Create a new path that is the union (inclusive-or) of the two paths.
         *
         * For example, if the two paths are overlapping circles of equal diameter
         * but differing centers, the result would be a figure-eight like shape
         * matching the outer boundaries of both circles.
         */
        val Union = PathOperation(2)

        /**
         * Create a new path that is the exclusive-or of the two paths, leaving
         * everything but the overlapping pieces of the path.
         *
         * For example, if the two paths are overlapping circles of equal diameter
         * but differing centers, the figure-eight like shape less the overlapping parts
         *
         * See also:
         *  * [Intersect], which is the inverse of this operation
         */
        val Xor = PathOperation(3)

        /**
         * Subtract the first path from the second path.
         *
         * For example, if the two paths are overlapping circles of equal diameter
         * but differing centers, the result would be a crescent portion of the
         * second circle that was not overlapped by the first circle.
         *
         * See also:
         *
         *  * [Difference], which is the same but subtracting the second path
         *    from the first.
         */
        val ReverseDifference = PathOperation(4)
    }

到此我们对左右两个圆的Path分别作为减去的路径传入圆角矩形即可。path.add操作可以去掉。代码如下:

@Stable
class BezierShapes(
    private var circleRadius: Float = 30f,
    private var circleMarginTop: Float = 80f
) :
    Shape {
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        val path = Path()
        val rect = Rect(Offset(0f, 0f), Offset(size.width, size.height))
        path.addRoundRect(
            RoundRect(
                rect,
                CornerRadius(circleRadius),
                CornerRadius(circleRadius),
                CornerRadius(circleRadius),
                CornerRadius(circleRadius)
            )
        )
        val circleLeftPath = Path()
        val circleRightPath = Path()
        val circleLeftRect = Rect(Offset(0f, size.height / 2 + circleMarginTop), circleRadius)
        val circleRightRect =
            Rect(Offset(size.width, size.height / 2 + circleMarginTop), circleRadius)
        circleLeftPath.addArc(circleLeftRect, 0f, 360f)
        circleRightPath.addArc(circleRightRect, 0f, 360f)
        path.op(path, circleLeftPath, PathOperation.Difference)
        path.op(path, circleRightPath, PathOperation.Difference)
        path.close()
        return Outline.Generic(path)
    }
}

效果如下:

image.png

3、自定义虚线实现

image.png

需求卡片中是有一个虚线的,这个虚线我们最好可以绘制在内容下面,避免跟内容有任何的关联。Modifier提供了Modifier.drawBehind 【绘制到修改内容后面的 Canvas 中】。

fun Modifier.drawBehind(
    onDraw: DrawScope.() -> Unit
) = this then DrawBehindElement(onDraw)

虚线的画法很多,可以根据drawLine参数 pathEffect实现,也可以自己简单的遍历画线实现。

4、遍历绘制虚线

.drawBehind {
    for (i in 0 until 150) {
        if (i % 2 == 0)
            drawLine(
                Color(171, 90, 3, 255),
                Offset(0f + i * 10, size.height / 2f + 80f),
                Offset(10f + i * 10, size.height / 2f + 80f)
            )
    }
}

image.png

5、API绘制虚线

drawBehind {
    drawLine(
        Color(171, 90, 3, 255),
        Offset(0f, size.height / 2f+80),
        Offset(size.width , size.height / 2f+80),
        pathEffect = PathEffect.dashPathEffect(floatArrayOf(11f,11f),0.6f)
    )
}

三、最终代码

到这里一个好看的卡片背景完成。完全支持任何原生自带容器组件,稳定,侵入性小。


@Stable
class BezierShapes(
    private var circleRadius: Float = 30f,
    private var circleMarginTop: Float = 80f
) :
    Shape {
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        val path = Path()
        val rect = Rect(Offset(0f, 0f), Offset(size.width, size.height))
        path.addRoundRect(
            RoundRect(
                rect,
                CornerRadius(circleRadius),
                CornerRadius(circleRadius),
                CornerRadius(circleRadius),
                CornerRadius(circleRadius)
            )
        )
        val circleLeftPath = Path()
        val circleRightPath = Path()
        val circleLeftRect = Rect(Offset(0f, size.height / 2 + circleMarginTop), circleRadius)
        val circleRightRect =
            Rect(Offset(size.width, size.height / 2 + circleMarginTop), circleRadius)
        circleLeftPath.addArc(circleLeftRect, 0f, 360f)
        circleRightPath.addArc(circleRightRect, 0f, 360f)
        path.op(path, circleLeftPath, PathOperation.Difference)
        path.op(path, circleRightPath, PathOperation.Difference)
        path.close()
        return Outline.Generic(path)
    }
}

@Preview(widthDp = 480, heightDp = 200)
@Composable
fun CardComposeView(navigateToScreen: (route: String) -> Unit = {}) {
    Box() {
        Image(
            painter = painterResource(R.mipmap.ticket_bar_bg),
            contentDescription = "",
            modifier = Modifier
                .fillMaxSize()
                .fillMaxWidth()
                .blur(10.dp),
            contentScale = ContentScale.Crop
        )
        Box(
            Modifier
                .background(Color.Transparent)
                .padding(10.dp)
        ) {
            Box(
                Modifier
                    .fillMaxWidth()
                    .fillMaxHeight()
                    .clip(BezierShapes())
                    .shadow(2.dp, spotColor = Color.Black, ambientColor = Color.Black, clip = true)
                    .border(1.4.dp, Color(224, 166, 83, 255), BezierShapes())
                    .background(Color(238, 220, 203, 255))
                    .drawBehind {
                        drawLine(
                            Color(171, 90, 3, 255),
                            Offset(0f, size.height / 2f+80),
                            Offset(size.width , size.height / 2f+80),
                            pathEffect = PathEffect.dashPathEffect(floatArrayOf(11f,11f),0.6f)
                        )
                    }
                    .padding(10.dp)

            ) {
                Column(Modifier.fillMaxWidth()) {
                    Text(
                        "$12 OFF",
                        color = Color.Black,
                        fontSize = 20.sp,
                        fontWeight = FontWeight.Bold,
                        modifier = Modifier.padding(8.dp)
                    )
                    Text(
                        "Available for orders over $39",
                        color = Color.Gray,
                        fontSize = 16.sp,
                        fontWeight = FontWeight.Bold,
                        modifier = Modifier.padding(start = 8.dp, bottom = 10.dp, top = 6.dp)
                    )
                    Text(
                        "06/25/2023 09:00-07/01/2023 09:00",
                        color = Color.Gray,
                        fontSize = 16.sp,
                        fontWeight = FontWeight.Bold,
                        modifier = Modifier.padding(start = 8.dp, bottom = 8.dp)
                    )
                    Box(Modifier.height(20.dp))
                    Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth().padding(end = 10.dp)) {
                        Row(
                            modifier = Modifier
                                .wrapContentSize()
                                .padding(start = 10.dp)
                                .background(Color(239, 230, 216, 255), shape = RoundedCornerShape(5.dp))
                        ) {
                            Text(
                                "Code:",
                                color = Color.Black,
                                fontSize = 16.sp,
                                modifier = Modifier.padding(start = 8.dp).padding(vertical = 8.dp)
                            )
                            Text(
                                "DsCT12",
                                color = Color.Black,
                                fontSize = 16.sp,
                                fontWeight = FontWeight.Bold,
                                modifier = Modifier.padding(start = 8.dp).padding(top = 8.dp, end = 10.dp)
                            )
                        }
                        Row(
                            modifier = Modifier
                                .wrapContentSize()
                                .padding(start = 10.dp)
                                .background(Color(134, 56, 4, 255), shape = RoundedCornerShape(5.dp))
                        ) {
                            Text(
                                "COPY",
                                color = Color.White,
                                fontSize = 20.sp,
                                modifier = Modifier.padding(start = 8.dp, end = 8.dp).padding(vertical = 8.dp)
                            )
                        }
                    }

                }
            }
        }
    }
}

最终效果:

image.png