Compose 中的形状

借助 Compose,您可以创建由多边形组成的形状。例如,您可以制作以下形状:

绘图区域中央的蓝色六边形
图 1. 您可以使用 graphics-shapes 库制作的不同形状的示例

如需在 Compose 中创建自定义圆角多边形,请将 graphics-shapes 依赖项添加到 app/build.gradle

implementation "androidx.graphics:graphics-shapes:1.0.1"

此库可让您创建由多边形组成的形状。虽然多边形只有直边和尖角,但这些形状允许使用可选的圆角。这样一来,您就可以轻松实现两种不同形状之间的变形。任意形状之间的变形比较困难,并且往往是设计时问题。不过,此库通过在具有相似多边形结构的形状之间变形,简化了这一过程。

创建多边形

以下代码段在绘图区域的中心创建了一个包含 6 个点的基本多边形:

Box(
    modifier = Modifier
        .drawWithCache {
            val roundedPolygon = RoundedPolygon(
                numVertices = 6,
                radius = size.minDimension / 2,
                centerX = size.width / 2,
                centerY = size.height / 2
            )
            val roundedPolygonPath = roundedPolygon.toPath().asComposePath()
            onDrawBehind {
                drawPath(roundedPolygonPath, color = Color.Blue)
            }
        }
        .fillMaxSize()
)

绘图区域中央的蓝色六边形
图 2. 绘图区域中央的蓝色六边形。

在此示例中,库会创建一个 RoundedPolygon,其中包含表示所请求形状的几何图形。为了在 Compose 应用中绘制该形状,您必须从中获取 Path 对象,以便将该形状转换为 Compose 知道如何绘制的形式。

将多边形的角变圆

如需将多边形的角变圆,请使用 CornerRounding 参数。此方法接受两个参数:radiussmoothing。每个圆角都由 1-3 条三次曲线组成,其中间具有圆弧形状,而两侧的曲线则从形状的边缘过渡到中间曲线。

半径

radius 是用于使顶点变圆的圆的半径。

例如,以下圆角三角形的制作方式如下:

圆角三角形
图 3. 圆角三角形。
圆角半径 r 决定了圆角的圆形圆角大小
图 4。圆角半径 r 决定了圆角的圆形圆度。

平滑

平滑度是一个系数,用于确定从圆角部分的圆形到边缘所需的时间。平滑系数为 0(不平滑,CornerRounding 的默认值)会导致纯圆形边角圆角。非零平滑系数(最高为 1.0)会导致边角通过三条单独的曲线进行圆角处理。

平滑系数为 0(不平滑)时,会生成一条立方曲线,该曲线会以指定的圆角半径沿圆角周围的圆形轨迹移动,如前面的示例所示
图 5. 平滑系数为 0(不平滑)时,会生成一条三次曲线,该曲线会以指定的圆角半径沿圆角周围的圆圈移动,如前面的示例所示。
非零平滑系数会生成三条用于使顶点变圆的三次曲线:内侧圆形曲线(与之前一样),以及两条在内侧曲线和多边形边缘之间过渡的侧翼曲线。
图 6. 非零平滑系数会生成三条用于使顶点变圆的三次曲线:内部圆形曲线(与之前一样)以及两条在内部曲线和多边形边缘之间过渡的侧翼曲线。

例如,以下代码段展示了将平滑度设置为 0 与 1 之间的细微差别:

Box(
    modifier = Modifier
        .drawWithCache {
            val roundedPolygon = RoundedPolygon(
                numVertices = 3,
                radius = size.minDimension / 2,
                centerX = size.width / 2,
                centerY = size.height / 2,
                rounding = CornerRounding(
                    size.minDimension / 10f,
                    smoothing = 0.1f
                )
            )
            val roundedPolygonPath = roundedPolygon.toPath().asComposePath()
            onDrawBehind {
                drawPath(roundedPolygonPath, color = Color.Black)
            }
        }
        .size(100.dp)
)

两个黑色三角形,显示了平滑参数的差异。
图 7. 两个黑色三角形,显示了平滑参数的差异。

大小和位置

默认情况下,系统会创建一个以中心点 (0, 0) 为圆心、半径为 1 的形状。此半径表示中心点与形状所基于的多边形的外部顶点之间的距离。请注意,圆角会使形状变小,因为圆角比被圆角的顶点更靠近中心。如需调整多边形的大小,请调整 radius 值。如需调整位置,请更改多边形的 centerXcenterY。 或者,使用标准 DrawScope 转换函数(例如 DrawScope#translate())转换对象,以更改其大小、位置和旋转。

形状变形

Morph 对象是一种新形状,表示两个多边形形状之间的动画。如需在两个形状之间进行变形,请创建两个 RoundedPolygons 和一个 Morph 对象,该对象会采用这两个形状。如需计算起始形状和结束形状之间的形状,请提供一个介于 0 和 1 之间的 progress 值,以确定该形状在起始形状 (0) 和结束形状 (1) 之间的形式:

Box(
    modifier = Modifier
        .drawWithCache {
            val triangle = RoundedPolygon(
                numVertices = 3,
                radius = size.minDimension / 2f,
                centerX = size.width / 2f,
                centerY = size.height / 2f,
                rounding = CornerRounding(
                    size.minDimension / 10f,
                    smoothing = 0.1f
                )
            )
            val square = RoundedPolygon(
                numVertices = 4,
                radius = size.minDimension / 2f,
                centerX = size.width / 2f,
                centerY = size.height / 2f
            )

            val morph = Morph(start = triangle, end = square)
            val morphPath = morph
                .toPath(progress = 0.5f).asComposePath()

            onDrawBehind {
                drawPath(morphPath, color = Color.Black)
            }
        }
        .fillMaxSize()
)

在上面的示例中,进度正好位于两个形状(圆角三角形和正方形)之间,从而产生以下结果:

介于圆角三角形和正方形之间的 50%
图 8. 介于圆角三角形和正方形之间的 50% 形状。

在大多数情况下,变形是动画的一部分,而不仅仅是静态渲染。如需在这两个值之间添加动画效果,您可以使用 Compose 中的标准 Animation API 随时间变化进度值。例如,您可以无限次地对这两个形状之间的变形进行动画处理,如下所示:

val infiniteAnimation = rememberInfiniteTransition(label = "infinite animation")
val morphProgress = infiniteAnimation.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        tween(500),
        repeatMode = RepeatMode.Reverse
    ),
    label = "morph"
)
Box(
    modifier = Modifier
        .drawWithCache {
            val triangle = RoundedPolygon(
                numVertices = 3,
                radius = size.minDimension / 2f,
                centerX = size.width / 2f,
                centerY = size.height / 2f,
                rounding = CornerRounding(
                    size.minDimension / 10f,
                    smoothing = 0.1f
                )
            )
            val square = RoundedPolygon(
                numVertices = 4,
                radius = size.minDimension / 2f,
                centerX = size.width / 2f,
                centerY = size.height / 2f
            )

            val morph = Morph(start = triangle, end = square)
            val morphPath = morph
                .toPath(progress = morphProgress.value)
                .asComposePath()

            onDrawBehind {
                drawPath(morphPath, color = Color.Black)
            }
        }
        .fillMaxSize()
)

在正方形和圆角三角形之间无限变形
图 9. 在正方形和圆角三角形之间无限变形。

使用多边形作为剪辑

在 Compose 中,通常使用 clip 修饰符来更改可组合项的渲染方式,并利用在剪裁区域周围绘制的阴影:

fun RoundedPolygon.getBounds() = calculateBounds().let { Rect(it[0], it[1], it[2], it[3]) }
class RoundedPolygonShape(
    private val polygon: RoundedPolygon,
    private var matrix: Matrix = Matrix()
) : Shape {
    private var path = Path()
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        path.rewind()
        path = polygon.toPath().asComposePath()
        matrix.reset()
        val bounds = polygon.getBounds()
        val maxDimension = max(bounds.width, bounds.height)
        matrix.scale(size.width / maxDimension, size.height / maxDimension)
        matrix.translate(-bounds.left, -bounds.top)

        path.transform(matrix)
        return Outline.Generic(path)
    }
}

然后,您可以将该多边形用作剪裁,如以下代码段所示:

val hexagon = remember {
    RoundedPolygon(
        6,
        rounding = CornerRounding(0.2f)
    )
}
val clip = remember(hexagon) {
    RoundedPolygonShape(polygon = hexagon)
}
Box(
    modifier = Modifier
        .clip(clip)
        .background(MaterialTheme.colorScheme.secondary)
        .size(200.dp)
) {
    Text(
        "Hello Compose",
        color = MaterialTheme.colorScheme.onSecondary,
        modifier = Modifier.align(Alignment.Center)
    )
}

这会导致以下结果:

六边形,中心有文字“hello compose”。
图 10. 中心带有“Hello Compose”字样的六边形。

这可能与之前的渲染效果没有太大区别,但它允许利用 Compose 中的其他功能。例如,此技术可用于剪裁图片并在剪裁区域周围应用阴影:

val hexagon = remember {
    RoundedPolygon(
        6,
        rounding = CornerRounding(0.2f)
    )
}
val clip = remember(hexagon) {
    RoundedPolygonShape(polygon = hexagon)
}
Box(
    modifier = Modifier.fillMaxSize(),
    contentAlignment = Alignment.Center
) {
    Image(
        painter = painterResource(id = R.drawable.dog),
        contentDescription = "Dog",
        contentScale = ContentScale.Crop,
        modifier = Modifier
            .graphicsLayer {
                this.shadowElevation = 6.dp.toPx()
                this.shape = clip
                this.clip = true
                this.ambientShadowColor = Color.Black
                this.spotShadowColor = Color.Black
            }
            .size(200.dp)

    )
}

六边形中的狗,边缘周围应用了阴影
图 11. 已将自定义形状作为剪辑应用。

点击时变形的按钮

您可以使用 graphics-shape 库创建一个按钮,该按钮在按下时会在两种形状之间变形。首先,创建一个扩展 ShapeMorphPolygonShape,对其进行缩放和平移以使其大小合适。请注意,我们传入了进度,以便对形状进行动画处理:

class MorphPolygonShape(
    private val morph: Morph,
    private val percentage: Float
) : Shape {

    private val matrix = Matrix()
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        // Below assumes that you haven't changed the default radius of 1f, nor the centerX and centerY of 0f
        // By default this stretches the path to the size of the container, if you don't want stretching, use the same size.width for both x and y.
        matrix.scale(size.width / 2f, size.height / 2f)
        matrix.translate(1f, 1f)

        val path = morph.toPath(progress = percentage).asComposePath()
        path.transform(matrix)
        return Outline.Generic(path)
    }
}

如需使用此变形形状,请创建两个多边形,即 shapeAshapeB。创建并记住 Morph。然后,将变形效果作为剪辑轮廓应用于按钮,使用按下时的 interactionSource 作为动画的驱动力:

val shapeA = remember {
    RoundedPolygon(
        6,
        rounding = CornerRounding(0.2f)
    )
}
val shapeB = remember {
    RoundedPolygon.star(
        6,
        rounding = CornerRounding(0.1f)
    )
}
val morph = remember {
    Morph(shapeA, shapeB)
}
val interactionSource = remember {
    MutableInteractionSource()
}
val isPressed by interactionSource.collectIsPressedAsState()
val animatedProgress = animateFloatAsState(
    targetValue = if (isPressed) 1f else 0f,
    label = "progress",
    animationSpec = spring(dampingRatio = 0.4f, stiffness = Spring.StiffnessMedium)
)
Box(
    modifier = Modifier
        .size(200.dp)
        .padding(8.dp)
        .clip(MorphPolygonShape(morph, animatedProgress.value))
        .background(Color(0xFF80DEEA))
        .size(200.dp)
        .clickable(interactionSource = interactionSource, indication = null) {
        }
) {
    Text("Hello", modifier = Modifier.align(Alignment.Center))
}

当点按该框时,系统会播放以下动画:

变形效果应用于两个形状之间的点击
图 12. 以点击形式在两个形状之间应用的变形效果。

无限次循环播放形状变形动画

如需无限循环播放变形形状动画,请使用 rememberInfiniteTransition。 以下是一个个人资料照片的示例,该照片的形状(和旋转角度)会随着时间的推移而不断变化。此方法对上述 MorphPolygonShape 进行了小幅调整:

class CustomRotatingMorphShape(
    private val morph: Morph,
    private val percentage: Float,
    private val rotation: Float
) : Shape {

    private val matrix = Matrix()
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        // Below assumes that you haven't changed the default radius of 1f, nor the centerX and centerY of 0f
        // By default this stretches the path to the size of the container, if you don't want stretching, use the same size.width for both x and y.
        matrix.scale(size.width / 2f, size.height / 2f)
        matrix.translate(1f, 1f)
        matrix.rotateZ(rotation)

        val path = morph.toPath(progress = percentage).asComposePath()
        path.transform(matrix)

        return Outline.Generic(path)
    }
}

@Preview
@Composable
private fun RotatingScallopedProfilePic() {
    val shapeA = remember {
        RoundedPolygon(
            12,
            rounding = CornerRounding(0.2f)
        )
    }
    val shapeB = remember {
        RoundedPolygon.star(
            12,
            rounding = CornerRounding(0.2f)
        )
    }
    val morph = remember {
        Morph(shapeA, shapeB)
    }
    val infiniteTransition = rememberInfiniteTransition("infinite outline movement")
    val animatedProgress = infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            tween(2000, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "animatedMorphProgress"
    )
    val animatedRotation = infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 360f,
        animationSpec = infiniteRepeatable(
            tween(6000, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "animatedMorphProgress"
    )
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Image(
            painter = painterResource(id = R.drawable.dog),
            contentDescription = "Dog",
            contentScale = ContentScale.Crop,
            modifier = Modifier
                .clip(
                    CustomRotatingMorphShape(
                        morph,
                        animatedProgress.value,
                        animatedRotation.value
                    )
                )
                .size(200.dp)
        )
    }
}

此代码会生成以下有趣的结果:

爱心形状
图 13. 被旋转的扇贝形剪裁的个人资料照片。

自定义多边形

如果由正多边形创建的形状不符合您的使用情形,您可以使用顶点列表创建更自定义的形状。例如,您可能想要创建如下所示的心形:

爱心形状
图 14。心形。

您可以使用采用 x、y 坐标浮点数组的 RoundedPolygon 重载来指定此形状的各个顶点。

为了分解心形多边形,请注意,与使用笛卡尔 (x,y) 坐标系相比,使用极坐标系指定点会更简单,其中 从右侧开始,顺时针方向前进,270° 位于 12 点钟位置:

爱心形状
图 15。带有坐标的心形。

现在,可以通过指定每个点的角度 (𝜭) 和中心半径,以更简单的方式定义形状:

爱心形状
图 16。具有坐标的心形,无圆角。

现在可以创建顶点并将其传递给 RoundedPolygon 函数:

val vertices = remember {
    val radius = 1f
    val radiusSides = 0.8f
    val innerRadius = .1f
    floatArrayOf(
        radialToCartesian(radiusSides, 0f.toRadians()).x,
        radialToCartesian(radiusSides, 0f.toRadians()).y,
        radialToCartesian(radius, 90f.toRadians()).x,
        radialToCartesian(radius, 90f.toRadians()).y,
        radialToCartesian(radiusSides, 180f.toRadians()).x,
        radialToCartesian(radiusSides, 180f.toRadians()).y,
        radialToCartesian(radius, 250f.toRadians()).x,
        radialToCartesian(radius, 250f.toRadians()).y,
        radialToCartesian(innerRadius, 270f.toRadians()).x,
        radialToCartesian(innerRadius, 270f.toRadians()).y,
        radialToCartesian(radius, 290f.toRadians()).x,
        radialToCartesian(radius, 290f.toRadians()).y,
    )
}

需要使用以下 radialToCartesian 函数将顶点转换为笛卡尔坐标:

internal fun Float.toRadians() = this * PI.toFloat() / 180f

internal val PointZero = PointF(0f, 0f)
internal fun radialToCartesian(
    radius: Float,
    angleRadians: Float,
    center: PointF = PointZero
) = directionVectorPointF(angleRadians) * radius + center

internal fun directionVectorPointF(angleRadians: Float) =
    PointF(cos(angleRadians), sin(angleRadians))

上述代码可为您提供心形的原始顶点,但您需要将特定角变圆,才能获得所选的心形。90°270° 处的角没有圆角,但其他角有圆角。如需为各个角实现自定义圆角,请使用 perVertexRounding 参数:

val rounding = remember {
    val roundingNormal = 0.6f
    val roundingNone = 0f
    listOf(
        CornerRounding(roundingNormal),
        CornerRounding(roundingNone),
        CornerRounding(roundingNormal),
        CornerRounding(roundingNormal),
        CornerRounding(roundingNone),
        CornerRounding(roundingNormal),
    )
}

val polygon = remember(vertices, rounding) {
    RoundedPolygon(
        vertices = vertices,
        perVertexRounding = rounding
    )
}
Box(
    modifier = Modifier
        .drawWithCache {
            val roundedPolygonPath = polygon.toPath().asComposePath()
            onDrawBehind {
                scale(size.width * 0.5f, size.width * 0.5f) {
                    translate(size.width * 0.5f, size.height * 0.5f) {
                        drawPath(roundedPolygonPath, color = Color(0xFFF15087))
                    }
                }
            }
        }
        .size(400.dp)
)

这会生成粉色心形:

爱心形状
图 17. 心形结果。

如果上述形状无法满足您的使用情形,请考虑使用 Path 类来绘制自定义形状,或从磁盘加载 ImageVector 文件。graphics-shapes 库并非旨在用于任意形状,而是专门用于简化创建圆形多边形以及它们之间的变形动画。

其他资源

如需了解详情和示例,请参阅以下资源: