Compose のシェイプ

Compose では、ポリゴンから作成されたシェイプを作成できます。たとえば、次の種類のシェイプを作成できます。

描画領域の中央にある青い六角形
図 1. graphics-shapes ライブラリで作成できるさまざまな形状の例

Compose でカスタムの丸いポリゴンを作成するには、app/build.gradlegraphics-shapes 依存関係を追加します。

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

このライブラリを使用すると、ポリゴンから作成されたシェイプを作成できます。多角形のシェイプには直線のエッジと鋭角のみがありますが、これらのシェイプでは角を丸くすることもできます。2 つの異なるシェイプを簡単にモーフィングできます。任意の形状間でのモーフィングは困難であり、設計時の問題になる傾向があります。このライブラリでは、類似したポリゴン構造を持つこれらのシェイプをモーフィングすることで、この作業を簡単に行えます。

ポリゴンを作成する

次のスニペットは、描画領域の中央に 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 の 2 つのパラメータを取ります。各角の丸みは 1 ~ 3 つのキュービック カーブで構成されています。中央のカーブは円弧状ですが、2 つの側面(「側面」)カーブはシェイプのエッジから中央のカーブに移行します。

Radius

radius は、頂点の丸め処理に使用する円の半径です。

たとえば、次の丸い角の三角形は次のように作成します。

角の丸い三角形
図 3. 角の丸い三角形。
丸め半径 r は、丸い角の円形の丸めサイズを決定します。
図 4. 丸め半径 r は、丸い角の円形の丸めサイズを決定します。

スムージング

スムージングは、角の丸い部分から端までの移動にかかる時間を決定する要素です。スムージング係数が 0 の場合(スムージングなし、CornerRounding のデフォルト値)、角は完全に丸くなります。0 以外のスムージング ファクタ(最大 1.0)を使用すると、角が 3 つの個別のカーブで丸くなります。

スムージング ファクタが 0 の場合(スムージングなし)は、前述の例のように、指定された丸め半径で角の周りの円に沿って単一の 3 次曲線が生成されます。
図 5. スムージング係数が 0 の場合(スムージングなし)は、前述の例のように、指定された丸め半径で角の周りの円に沿って単一の 3 次曲線が生成されます。
スムージング係数がゼロでない場合、頂点を丸くするために 3 つの 3 次曲線が生成されます。内側の円形曲線(前述のとおり)と、内側の曲線とポリゴンのエッジを遷移する 2 つの側面曲線です。
図 6. スムージング係数がゼロでない場合、頂点を丸くするために 3 つの 3 次曲線が生成されます。内側の円形曲線(前述のとおり)と、内側の曲線とポリゴンのエッジをつなぐ 2 つの曲線です。

たとえば、次のスニペットは、スムージングを 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)
)

2 つの黒い三角形は、スムージング パラメータの違いを示しています。
図 7. 2 つの黒い三角形は、スムージング パラメータの違いを示しています。

サイズと位置

デフォルトでは、中心(0, 0)の周囲に 1 の半径でシェイプが作成されます。この半径は、シェイプのベースとなるポリゴンの中心と外側の頂点間の距離を表します。角を丸めると、角が丸められた頂点よりも丸い角が中心に近づくため、図形が小さくなります。ポリゴンのサイズを調整するには、radius の値を調整します。位置を調整するには、ポリゴンの centerX または centerY を変更します。または、DrawScope#translate() などの標準の DrawScope 変換関数を使用してオブジェクトを変換し、サイズ、位置、回転を変更します。

図形をモーフィングする

Morph オブジェクトは、2 つのポリゴン形状間のアニメーションを表す新しいシェイプです。2 つのシェイプをモーフィングするには、2 つの RoundedPolygons と、これらの 2 つのシェイプを取る 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()
)

上記の例では、進行状況は 2 つの形状(丸い三角形と正方形)のちょうど中間にあり、次の結果が得られます。

丸みを帯びた三角形と正方形の中間
図 8. 丸みを帯びた三角形と正方形の中間。

ほとんどのシナリオでは、モーフィングは静的なレンダリングではなく、アニメーションの一部として行われます。これらの 2 つの間でアニメーション化するには、標準の Compose の Animation API を使用して、進行状況の値を時間の経過とともに変更します。たとえば、次のように 2 つのシェイプ間のモーフィングを無限にアニメーション化できます。

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 ライブラリを使用すると、押すと 2 つのシェイプをモーフィングするボタンを作成できます。まず、Shape を拡張する MorphPolygonShape を作成し、適切に収まるようにスケーリングして変換します。シェイプをアニメーション化できるように、進行状況を渡していることに注目してください。

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

このモーフィング シェイプを使用するには、2 つのポリゴン(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))
}

ボックスをタップすると、次のアニメーションが表示されます。

2 つのシェイプ間のクリックとして適用されたモーフ
図 12. 2 つのシェイプ間のクリックとして適用されたモーフィング。

形状のモーフィングを無限にアニメーション化する

モーフィング シェイプを無限にアニメーション化するには、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 ライブラリは任意の形状に使用することを目的としたものではなく、丸いポリゴンの作成とそれらの間のモーフ アニメーションを簡素化することを目的としています。

参考情報

詳細と例については、次のリソースをご覧ください。