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 相关属性的效果仍如预期一样。

相关推荐
Erwinl5 小时前
android 开机启动 无线调试
android
此生只爱蛋5 小时前
mysql_store_result
android·adb
双桥wow6 小时前
Android Framework开机动画开发
android
yueqc111 小时前
Kotlin 协程 Flow 操作符总结
kotlin·协程·flow
fanged13 小时前
天马G前端的使用
android·游戏
molong93116 小时前
Kotlin 内联函数、高阶函数、扩展函数
android·开发语言·kotlin
叶辞树18 小时前
Android framework调试和AMS等服务调试
android
慕伏白20 小时前
【慕伏白】Android Studio 无线调试配置
android·ide·android studio
低调小一20 小时前
Kuikly 小白拆解系列 · 第1篇|两棵树直调(Kotlin 构建与原生承载)
android·开发语言·kotlin