Compose 中实现凸角、凹角、切角、尖角

Jetpack Compose 通过诸如 RoundedCornerShapeCutCornerShape 的类,可以在各种组件上应用圆角或切角。

例如切角:

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),
)

这些类创建的形状中,特定形状的所有边角要么都是圆形的,要么都是切角的,但不能混合使用。

如果将 ZeroCornerSize0.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() 是入口点。在这里,我们尽可能使用 RectangleShapeRoundedCornerShapeCutCornerShape(均来自 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.dpRectangleShape)形状。

通过一个小型映射器和自定义引擎,你还能在一个形状中实现凹角以及圆角和切角的真正混合。

阴影和 MaterialDesign 相关属性的效果仍如预期一样。

相关推荐
苦学编程啊10 小时前
【2025Flutter 入门指南】Dart SDK 安装与 VS Code 环境配置-Windows
android·dart
yuanManGan16 小时前
走进Linux的世界:初识操作系统(Operator System)
android·linux·运维
叶羽西17 小时前
Android15跟踪函数调用关系
android
消失的旧时光-194318 小时前
webView 的canGoBack/goBack 回退栈
android·webview
SHEN_ZIYUAN18 小时前
Flow 责任链模式图解
android
沐怡旸20 小时前
【底层机制】LeakCanary深度解析:从对象监控到内存泄漏分析的完整技术体系
android·面试
又菜又爱coding21 小时前
Android + Flutter打包出来的APK体积太大
android·flutter
LiuYaoheng21 小时前
【Android】Drawable 基础
android·java
Jerry1 天前
构建 Compose 界面
android
Y多了个想法1 天前
Linux驱动开发与Android驱动开发
android·linux·驱动开发