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 的开箱即用能力中,并不支持在一个形状中同时包含切角和圆角。
除了混合使用不同类型的角之外,另一个原生不支持的功能是:内凹角(即向内切割的角)。
我的目标是创建一种形状,让我能够混合搭配凹角、凸角、尖角和切角。我将其称为 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 。
实现上,分为如下四种情况:
代码如下:
@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 已经为你提供了圆角、切角和直角(通过 0.dp 或 RectangleShape )形状。
通过一个小型映射器和自定义引擎,你还能在一个形状中实现凹角以及圆角和切角的真正混合。
阴影和 MaterialDesign 相关属性的效果仍如预期一样。