
Jetpack Compose 通过诸如 RoundedCornerShape
或 CutCornerShape
的类,可以在各种组件上应用圆角或切角。
例如切角:
Kotlin
Spacer(
modifier = Modifier
.size(200.dp)
.clip(
shape = CutCornerShape(20.dp),
)
.drawBackground(Color.Red),
)

你可能不知道,Compose 还支持切角形状。
凸角(也就是普通的圆角):
Kotlin
Spacer(
modifier = Modifier
.size(200.dp)
.clip(
shape = RoundedCornerShape(20.dp),
)
.drawBackground(Color.Red),
)

这些类创建的形状中,特定形状的所有边角要么都是圆形的,要么都是切角的,但不能混合使用。
如果将 ZeroCornerSize
(0.dp
)作为边角尺寸,这样你可以得到一个普通的尖角。
当所有边角都是尖角时,也可以直接使用 RectangleShape
。
Kotlin
// 下面三种写法实际上是一样的
shape = RoundedCornerShape(ZeroCornerSize)
shape = RoundedCornerShape(size = 0.dp)
shape = RectangleShape

在 Compose 的开箱即用能力中,并不支持在一个形状中同时包含切角和圆角。
除了混合使用不同类型的角之外,另一个原生不支持的功能是:内凹角(即向内切割的角)。
CornerShape
我的目标是创建一种形状,让我能够混合搭配凹角、凸角、尖角和切角。我将其称为 CornersShape
。
其 API 如下所示:
Kotlin
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.ZeroCornerSize
import androidx.compose.ui.unit.Dp
public sealed interface Corner {
public val cornerSize: CornerSize
public data class Concave(override val cornerSize: CornerSize) : Corner
public data class Rounded(override val cornerSize: CornerSize) : Corner
public data class Cut(override val cornerSize: CornerSize) : Corner
public data object Sharp : Corner {
override val cornerSize: CornerSize = ZeroCornerSize
}
public companion object {
public fun rounded(size: Dp): Corner = Rounded(CornerSize(size))
public fun cut(size: Dp): Corner = Cut(CornerSize(size))
public fun concave(size: Dp): Corner = Concave(CornerSize(size))
}
}
@Composable
public fun cornerShape(
bottomEnd: Corner = Corner.Rounded(MaterialTheme.shapes.large.bottomEnd),
bottomStart: Corner = Corner.Rounded(MaterialTheme.shapes.large.bottomStart),
topEnd: Corner = Corner.Rounded(MaterialTheme.shapes.large.topEnd),
topStart: Corner = Corner.Rounded(MaterialTheme.shapes.large.topStart),
): Shape
注意:
MaterialTheme.shapes.*
的类型为Shape
。当主题形状是CornerBasedShape
时,访问.topStart
等属性是可行的。如果你的主题不支持这样做,那么你需要进行安全类型转换(或者直接使用固定的Dp
默认值)。
先来看个简单用法:
Kotlin
Spacer(
modifier = Modifier
.size(200.dp)
.clip(
shape = cornerShape(
topStart = Corner.Sharp, // 尖角
topEnd = Corner.rounded(16.dp), // 凸角
bottomEnd = Corner.concave(16.dp), // 凹角
bottomStart = Corner.cut(32.dp), // 切角
),
)
.drawBackground(Color.Red),
)

映射简单的角
Kotlin
@Composable
public fun cornerShape(
bottomEnd: Corner = Corner.Rounded(MaterialTheme.shapes.large.bottomEnd),
bottomStart: Corner = Corner.Rounded(MaterialTheme.shapes.large.bottomStart),
topEnd: Corner = Corner.Rounded(MaterialTheme.shapes.large.topEnd),
topStart: Corner = Corner.Rounded(MaterialTheme.shapes.large.topStart),
): Shape {
val corners = listOf(bottomEnd, bottomStart, topEnd, topStart)
return when {
corners.all { corner -> corner is Corner.Sharp } -> RectangleShape
corners.all { corner -> corner is Corner.Rounded || corner is Corner.Sharp } -> RoundedCornerShape(
bottomEnd = bottomEnd.cornerSize,
bottomStart = bottomStart.cornerSize,
topEnd = topEnd.cornerSize,
topStart = topStart.cornerSize,
)
corners.all { corner -> corner is Corner.Cut || corner is Corner.Sharp } -> CutCornerShape(
bottomEnd = bottomEnd.cornerSize,
bottomStart = bottomStart.cornerSize,
topEnd = topEnd.cornerSize,
topStart = topStart.cornerSize,
)
else -> CornerShape(
bottomEnd = bottomEnd,
bottomStart = bottomStart,
topEnd = topEnd,
topStart = topStart,
)
}
}
cornerShape()
是入口点。在这里,我们尽可能使用 RectangleShape
、RoundedCornerShape
或 CutCornerShape
(均来自 Compose 库)。如果无法使用这些形状,就会解析为自定义的 CornerShape
。
自定义 CornerShape
实现上,分为如下四种情况:
- 为任何圆角构建一个圆形底座(使用
RoundRect
)。 - 对于切角情况,从边角处减去一个三角形。
- 对于凹角情况,减去一个以边角为中心的椭圆。
- 当不需要减法操作时,返回一个
Outline.Rounded
;否则,返回带有布尔差分的Outline.Generic
。
代码如下:
Kotlin
/**
* 一个可自定义各角形状的图形:[Convex](圆角)、[Concave](内凹切割)、
* [Cut](对角线切割)或 [Sharp](90度直角)。针对简单情况(矩形或圆角)
* 进行了优化,并使用路径相减处理复杂的凹角或切割角。
*
* @param topStart 左上角的角样式
* @param topEnd 右上角的角样式
* @param bottomEnd 右下角的角样式
* @param bottomStart 左下角的角样式
*/
@Immutable
private class CornerShape(
private val topStart: Corner,
private val topEnd: Corner,
private val bottomEnd: Corner,
private val bottomStart: Corner,
) : Shape {
init {
listOf(topStart, topEnd, bottomEnd, bottomStart).forEach { corner ->
require(corner.cornerSize.toPx(Size(width = 100f, height = 100f), Density(1f)) >= 0f) {
"Corner size must be non-negative, but was ${corner.cornerSize} for corner $corner"
}
}
}
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
): Outline {
if (size.width <= 0f || size.height <= 0f) return Outline.Rectangle(Rect.Zero)
val rect = Rect(0f, 0f, size.width, size.height)
val corners = listOf(topStart, topEnd, bottomEnd, bottomStart)
val radii = corners.map { convexCornerRadius(it, density, size) }
val concaveRadii = corners.map { concaveRadiusInPixels(it, density, size) }
val cutSizes = corners.map { cutSizeInPixels(it, density, size) }
// Fast path: rectangle if no corners have size
if (radii.all { it == CornerRadius.Zero } && concaveRadii.all { it == 0f } && cutSizes.all { it == 0f }) {
return Outline.Rectangle(rect)
}
// Build base path. Always use a RoundRect to handle all corner types correctly.
val basePath = Path().apply {
addRoundRect(rect.toRoundRect(radii))
}
// Build concave and cut cutouts
val cutoutPath = Path().apply {
corners.forEachIndexed { index, corner ->
val position = CornerPosition.entries[index]
when (corner) {
is Corner.Concave -> {
val radius = concaveRadiusInPixels(corner, density, size)
if (radius > 0f) {
addConcaveOval(position, radius, size, layoutDirection)
}
}
is Corner.Cut -> {
val cutSize = cutSizeInPixels(corner, density, size)
if (cutSize > 0f) {
addCutTriangle(position, cutSize, size, layoutDirection)
}
}
else -> Unit
}
}
}
// Use generic outline if any cutouts or concave/cut corners exist
return if (cutoutPath.isEmpty) {
Outline.Rounded(rect.toRoundRect(radii))
} else {
Outline.Generic(Path.combine(PathOperation.Difference, basePath, cutoutPath))
}
}
private fun clampCornerSize(sizeInPx: Float, size: Size): Float = minOf(sizeInPx, size.width / 2, size.height / 2)
private fun convexCornerRadius(corner: Corner, density: Density, size: Size): CornerRadius = when (corner) {
is Corner.Rounded -> {
val radius = clampCornerSize(corner.cornerSize.toPx(size, density), size)
if (radius > 0f) CornerRadius(radius) else CornerRadius.Zero
}
is Corner.Cut, is Corner.Concave, is Corner.Sharp -> CornerRadius.Zero
}
private fun concaveRadiusInPixels(corner: Corner, density: Density, size: Size): Float = when (corner) {
is Corner.Concave -> clampCornerSize(corner.cornerSize.toPx(size, density), size)
else -> 0f
}
private fun cutSizeInPixels(corner: Corner, density: Density, size: Size): Float = when (corner) {
is Corner.Cut -> clampCornerSize(corner.cornerSize.toPx(size, density), size)
else -> 0f
}
private fun Path.addConcaveOval(
position: CornerPosition,
radius: Float,
size: Size,
layoutDirection: LayoutDirection,
) {
val (centerX, centerY) = position.getCenter(size, layoutDirection == LayoutDirection.Rtl)
val ovalRect = Rect(centerX - radius, centerY - radius, centerX + radius, centerY + radius)
addOval(ovalRect)
}
private fun Path.addCutTriangle(
position: CornerPosition,
cutSize: Float,
size: Size,
layoutDirection: LayoutDirection,
) {
val (cornerX, cornerY) = position.cornerXY(size, layoutDirection == LayoutDirection.Rtl)
when (position) {
CornerPosition.TopStart -> {
moveTo(cornerX, cornerY)
lineTo(cornerX + cutSize, cornerY)
lineTo(cornerX, cornerY + cutSize)
close()
}
CornerPosition.TopEnd -> {
moveTo(cornerX, cornerY)
lineTo(cornerX - cutSize, cornerY)
lineTo(cornerX, cornerY + cutSize)
close()
}
CornerPosition.BottomEnd -> {
moveTo(cornerX, cornerY)
lineTo(cornerX, cornerY - cutSize)
lineTo(cornerX - cutSize, cornerY)
close()
}
CornerPosition.BottomStart -> {
moveTo(cornerX, cornerY)
lineTo(cornerX + cutSize, cornerY)
lineTo(cornerX, cornerY - cutSize)
close()
}
}
}
private fun CornerPosition.cornerXY(size: Size, isRtl: Boolean): Pair<Float, Float> {
val x = when (this) {
CornerPosition.TopStart, CornerPosition.BottomStart -> if (isRtl) size.width else 0f
CornerPosition.TopEnd, CornerPosition.BottomEnd -> if (isRtl) 0f else size.width
}
val y = when (this) {
CornerPosition.TopStart, CornerPosition.TopEnd -> 0f
CornerPosition.BottomStart, CornerPosition.BottomEnd -> size.height
}
return x to y
}
private enum class CornerPosition(val baseX: Float, val baseY: Float) {
TopStart(0f, 0f), TopEnd(1f, 0f), BottomEnd(1f, 1f), BottomStart(0f, 1f);
fun getCenter(size: Size, isRtl: Boolean): Pair<Float, Float> {
val x = if (isRtl) size.width - baseX * size.width else baseX * size.width
return x to baseY * size.height
}
}
}
/**
* 将一个 [Rect] 转换为 [RoundRect],使用四个 [CornerRadius] 值的列表,顺序为:
* topStart, topEnd, bottomEnd, bottomStart。
*/
private fun Rect.toRoundRect(radii: List<CornerRadius>): RoundRect {
require(radii.size == 4) { "Radii list must contain exactly four elements" }
return RoundRect(
rect = this,
topLeft = radii[0],
topRight = radii[1],
bottomRight = radii[2],
bottomLeft = radii[3],
)
}
上述代码中,实际上的核心代码只有一行:
Kotlin
Outline.Generic(Path.combine(PathOperation.Difference, basePath, cutoutPath))
一句话总结这段代码就是:从 basePath
中减去 cutoutPath
,得到剩余的形状。
其原理示意如下:

所以,一个圆角:如果从角落减去一个直角三角形,剩下的形状就是一个切角;如果减掉一个圆形,剩下的形状就是一个凹角。

注意看右上角的示意图,蓝色区域就是剩余的形状。
如果结果是标准的圆角矩形时,我们返回 Outline.Rounded
。
当我们必须减去几何形状时,我们会通过路径的布尔差分返回 Outline.Generic
。
注意事项
- 角的百分比大小是相对于形状的较小边而言的(符合 Compose 的行为)。
- 从右到左(RTL)布局:辅助函数能正确翻转起始/结束位置,这样在 RTL 布局中,切角或凹角能出现在预期的边角位置。
- 主题形状:
MaterialTheme.shapes.*
是一种形状。如果主题提供的不是基于角的形状,访问.topStart
等属性将不起作用。请进行安全类型转换或使用固定的 Dp 默认值。 - 限制:所有尺寸都限制在
min(width, height)/2
,以避免自相交情况。 - 性能:大多数情况会使用快速路径(矩形/圆角/切角);只有真正的混合角/凹角情况才会构建布尔路径。
总结
Compose 已经为你提供了圆角、切角和直角(通过 0.dp
或 RectangleShape
)形状。
通过一个小型映射器和自定义引擎,你还能在一个形状中实现凹角以及圆角和切角的真正混合。
阴影和 MaterialDesign 相关属性的效果仍如预期一样。