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

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

相关推荐
千里马学框架4 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台4 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone4 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc4 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo4 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077004 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
ai2work4 天前
ch23 综合复刻:从零做一个最小可用版本(capstone)
kotlin
其实防守也摸鱼4 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
ai2work4 天前
ch21 签名、校验与发版
kotlin
AFinalStone4 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui