Compose 中的形狀

您可以使用 Compose 建立由多邊形構成的圖形。舉例來說,您可以製作下列形狀:

繪圖區域中央的藍色六角形
圖 1. 以下是您可以使用圖形形狀庫建立的不同形狀範例

如要在 Compose 中建立自訂圓角多邊形,請將 graphics-shapes 依附元件新增至 app/build.gradle

implementation "androidx.graphics:graphics-shapes:1.0.0-rc01"

這個程式庫可讓您建立由多邊形製成的形狀。雖然多邊形形狀只有直邊和尖角,但這些形狀可選用圓角。這可讓您輕鬆在兩個不同形狀之間變形。在任意形狀之間變形的難度很高,而且通常是設計階段的問題。但這個程式庫可透過類似多邊形結構的形狀轉換,簡化這項作業。

建立多邊形

以下程式碼片段會在繪圖區域的中心建立基本多邊形形狀,其中含有 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

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。心形。

您可以使用 RoundedPolygon 超載 (會接收 x、y 座標的浮點陣列),指定此形狀的個別頂點。

如要分解心形多邊形,請注意,使用極座標系統指定座標點比使用笛卡爾 (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 程式庫並非用於任意形狀,而是專門用於簡化圓角多邊形的建立作業,以及這些形狀之間的轉換動畫。

其他資源

如需更多資訊和範例,請參閱下列資源: