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

  • 时间:2025-11-15 21:14 作者: 来源: 阅读:0
  • 扫一扫,手机访问
摘要:Jetpack Compose 通过诸如 RoundedCornerShape 或 CutCornerShape 的类,可以在各种组件上应用圆角或切角。例如切角:Spacer( modifier = Modifier .size(200.dp) .clip( shape = CutCornerShape(20.dp),

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

例如切角:

Spacer(
    modifier = Modifier
        .size(200.dp)
        .clip(
            shape = CutCornerShape(20.dp),
        )
        .drawBackground(Color.Red),
)

你可能不知道,Compose 还支持切角形状。 #技术分享

凸角(也就是普通的圆角):

Spacer(
    modifier = Modifier
        .size(200.dp)
        .clip(
            shape = RoundedCornerShape(20.dp),
        )
        .drawBackground(Color.Red),
)

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

如果将 ZeroCornerSize ( 0.dp )作为边角尺寸,这样你可以得到一个普通的尖角。

当所有边角都是尖角时,也可以直接使用 RectangleShape 。

shape = RoundedCornerShape(ZeroCornerSize)

shape = RoundedCornerShape(size = 0.dp)

shape = RectangleShape

在 Compose 的开箱即用能力中,并不支持在一个形状中同时包含切角和圆角。

除了混合使用不同类型的角之外,另一个原生不支持的功能是:内凹角(即向内切割的角)。

C CornerShape

我的目标是创建一种形状,让我能够混合搭配凹角、凸角、尖角和切角。我将其称为 CornersShape 。

其 API 如下所示:

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 默认值)。

先来看个简单用法:

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

映射简单的角

@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 。

C 自定义 CornerShape

实现上,分为如下四种情况:

  • 为任何圆角构建一个圆形底座(使用 RoundRect )。
  • 对于切角情况,从边角处减去一个三角形。
  • 对于凹角情况,减去一个以边角为中心的椭圆。
  • 当不需要减法操作时,返回一个 Outline.Rounded ;否则,返回带有布尔差分的 Outline.Generic 。

代码如下:

@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) }

if (radii.all { it == CornerRadius.Zero } && concaveRadii.all { it == 0f } && cutSizes.all { it == 0f }) { return Outline.Rectangle(rect) }

val basePath = Path().apply { addRoundRect(rect.toRoundRect(radii)) }

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 } } }

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 } } }

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

上述代码中,实际上的核心代码只有一行:

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

  • 全部评论(0)
最新发布的资讯信息
【系统环境|】Office 2010 自带公式编辑器的公式字体怎么修改?(2025-11-15 22:07)
【系统环境|】PGC世界赛 A组队伍概览 #绝地求生(2025-11-15 22:07)
【系统环境|】讲透 Spring Boot Cloud(2025-11-15 22:06)
【系统环境|】Dubbo和SpringCloud区别详解(4大核心区别)(2025-11-15 22:06)
【系统环境|】Spring Boot3 中实现全链路追踪,你 get 了吗?(2025-11-15 22:05)
【系统环境|】SpringCloud最全详解(万字图文总结)(2025-11-15 22:05)
【系统环境|】爆了爆了,Spring Cloud面试题(2025-11-15 22:04)
【系统环境|】一文部署skywalking(2025-11-15 22:03)
【系统环境|】使用Qt实现一个简单的绘图软件(2025-11-15 22:03)
【系统环境|】用Python做科学计算(工具篇)——scikit-learn(机器学习)2(2025-11-15 22:02)
手机二维码手机访问领取大礼包
返回顶部