去年入坑鸿蒙,现在算来快半年没碰Android了。昨天JetPack Compose博物馆
群里大佬们在讨论上图如何用Compose实现问题。大概看了看应该十分钟可以搞定的东西,于是发表了意见,裁剪背景就可以,十分钟应该能搞定。抱着对自己话负责,以及回忆一下Compose的知识,有了后补的这篇文章。也许大家也可以从中学到一些技巧。
一、需求实现分析
1、分析需求
需求图显示,卡片为四角圆滑的矩形,两侧有半圆挖孔,且圆角需透底显示背景。因此,不能仅使用填充颜色,而需要通过裁剪
来实现该效果。
2、技术分析
不要为了自定义而进行编写代码,好的自定义应该建立在官方原有稳定组件的基础上,且可以适用于各个组件。在Compose
中Modifier.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)
}
}
具体代码:
@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、自定义裁剪路径
当前路径只是增加了一个圆角矩形,所以裁剪完之后如下所示。如何在左右两边各挖一个孔呢?也是实现这个卡片关键所在。
对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)
}
}
效果如下:
仔细观察之后,可以看到路径裁剪区域,多了一部分半圆。原因是两个圆被添加到了圆角矩形路径中。而重叠部分因为路径的合并而丢失,到此可能会挡住很多开发者继续进行。熟悉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)
}
}
效果如下:
3、自定义虚线实现
需求卡片中是有一个虚线的,这个虚线我们最好可以绘制在内容下面,避免跟内容有任何的关联。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)
)
}
}
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)
)
}
}
}
}
}
}
}
最终效果: