Bentuk di Compose

Dengan Compose, Anda dapat membuat bentuk yang dibuat dari poligon. Misalnya, Anda dapat membuat jenis bentuk berikut:

Heksagon biru di tengah area gambar
Gambar 1. Contoh berbagai bentuk yang dapat Anda buat dengan bentuk grafis koleksi

Untuk membuat poligon bulat kustom di Compose, tambahkan dependensi graphics-shapes ke app/build.gradle:

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

Library ini memungkinkan Anda membuat bentuk yang dibuat dari poligon. Jika poligonal bentuk hanya memiliki tepi lurus dan sudut tajam, bentuk ini memungkinkan sudut membulat opsional. Hal ini membuatnya sederhana untuk berubah antara dua jenis bentuk tertentu. {i>Morphing<i} sulit di antara bentuk-bentuk yang berubah-ubah, dan cenderung menjadi masalah waktu desain. Namun, library ini mempermudahnya dengan mengubah bentuk antara bentuk-bentuk ini dengan struktur poligonal yang serupa.

Membuat poligon

Cuplikan berikut membuat bentuk poligon dasar dengan 6 titik di bagian tengah dari area gambar:

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

Heksagon biru di tengah area gambar
Gambar 2. Heksagon biru di bagian tengah area gambar.

Dalam contoh ini, library membuat RoundedPolygon yang menyimpan geometri yang merepresentasikan bentuk yang diminta. Untuk menggambar bentuk tersebut di aplikasi Compose, Anda harus mendapatkan objek Path darinya untuk mendapatkan bentuk ke dalam bentuk yang diketahui Compose cara menggambarnya.

Membulatkan sudut poligon

Untuk membulatkan sudut poligon, gunakan parameter CornerRounding. Ini menggunakan dua parameter, radius dan smoothing. Setiap sudut membulat dibuat dengan 1-3 kurva kubik, yang pusatnya memiliki bentuk busur melingkar sedangkan dua kurva samping ("mengapit") transisi dari tepi bentuk ke kurva tengah.

Radius

radius adalah radius lingkaran yang digunakan untuk membulatkan verteks.

Misalnya, segitiga sudut membulat berikut dibuat sebagai berikut:

Segitiga dengan sudut membulat
Gambar 3. Segitiga dengan sudut membulat.
Radius pembulatan r menentukan ukuran pembulatan melingkar
sudut membulat
Gambar 4. Radius pembulatan r menentukan ukuran pembulatan melingkar sudut membulat.

Kehalusan

Penghalusan adalah faktor yang menentukan waktu yang diperlukan untuk berpindah dari bagian pembulatan melingkar di sudut ke tepi. Faktor penghalusan 0 (tidak dihaluskan, nilai default untuk CornerRounding) menghasilkan pembulatan sudut yang sepenuhnya melingkar. Faktor penghalusan non-nol (hingga maksimum 1,0) akan menyebabkan sudut dibulatkan oleh tiga kurva terpisah.

Faktor penghalusan 0 (tidak dihaluskan) menghasilkan satu kurva kubik yang
mengikuti lingkaran di sekitar sudut dengan radius pembulatan yang ditentukan, seperti pada
contoh sebelumnya
Gambar 5. Faktor penghalus 0 (tidak halus) menghasilkan kurva kubik tunggal yang mengikuti lingkaran di sekitar sudut dengan radius pembulatan yang ditentukan, seperti dalam contoh sebelumnya.
Faktor penghalusan non-nol menghasilkan tiga kurva kubik untuk membulatkan
vertikal: kurva lingkaran bagian dalam (seperti sebelumnya) ditambah dua kurva samping yang
bertransisi antara kurva bagian dalam dan tepi poligon.
Gambar 6. Faktor penghalus yang tidak nol menghasilkan tiga kurva kubik untuk membulatkan verteks: kurva lingkaran dalam (seperti sebelumnya) ditambah dua kurva mengapit yang transisi antara kurva dalam dan tepi poligon.

Misalnya, cuplikan di bawah ini mengilustrasikan perbedaan kecil dalam setelan penghalusan menjadi 0 versus 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)
)

Dua segitiga hitam menunjukkan perbedaan dalam penghalusan
.
Gambar 7. Dua segitiga hitam menunjukkan perbedaan parameter penghalusan.

Ukuran dan posisi

Secara default, bentuk dibuat dengan radius 1 di sekitar pusat (0, 0). Radius ini mewakili jarak antara pusat dan vertex luar poligon tempat bentuk didasarkan. Perhatikan bahwa membulatkan sudut menghasilkan bentuk yang lebih kecil karena sudut yang membulat akan lebih dekat dengan tengah daripada verteks yang dibulatkan. Untuk menentukan ukuran poligon, sesuaikan nilai radius. Untuk menyesuaikan posisi, ubah centerX atau centerY poligon. Atau, transformasikan objek untuk mengubah ukuran, posisi, dan rotasinya menggunakan fungsi transformasi DrawScope standar seperti DrawScope#translate().

Bentuk morph

Objek Morph adalah bentuk baru yang mewakili animasi antara dua bentuk poligonal. Untuk berubah menjadi dua bentuk, buat dua RoundedPolygons dan Morph yang menggunakan dua bentuk ini. Untuk menghitung bentuk antara {i>start<i} dan bentuk akhir, berikan nilai progress antara nol dan satu untuk menentukan bentuk antara bentuk awal (0) dan akhir (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()
)

Dalam contoh di atas, progresnya tepat di tengah antara dua bentuk (segitiga membulat dan persegi), yang menghasilkan hasil berikut:

50% dari antara segitiga membulat dan persegi
Gambar 8. 50% jarak antara segitiga membulat dan persegi.

Dalam kebanyakan skenario, morphing dilakukan sebagai bagian dari animasi, dan bukan hanya dan rendering statis. Untuk menganimasikan di antara keduanya, Anda dapat menggunakan Animation API di Compose untuk mengubah nilai progres dari waktu ke waktu. Misalnya, Anda dapat menganimasikan morph tanpa batas antara kedua bentuk ini sebagai berikut:

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

Morphing yang terus berubah antara persegi dan segitiga membulat
Gambar 9. Morphing yang terus berubah antara persegi dan segitiga membulat.

Gunakan poligon sebagai klip

Umumnya, pengubah clip digunakan di Compose untuk mengubah cara rendering composable, dan untuk memanfaatkan bayangan yang menggambar di sekitar area pemangkasan:

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

Kemudian, Anda dapat menggunakan poligon sebagai klip, seperti yang ditunjukkan dalam cuplikan berikut:

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

Hal ini menghasilkan hal berikut:

Heksagonal dengan teks `hello compose` di tengah.
Gambar 10. Heksagon dengan teks "Hello Compose" di bagian tengah.

Tampilan ini mungkin tidak terlihat berbeda dengan yang dirender sebelumnya, tetapi memungkinkan pengoptimalan fitur lain di Compose. Misalnya, teknik ini dapat digunakan untuk memotong gambar dan menerapkan bayangan di sekitar area yang terpotong:

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)

    )
}

Anjing dalam bentuk segi enam dengan bayangan yang diterapkan di sekitar tepi
Gambar 11. Bentuk kustom diterapkan sebagai klip.

Tombol Morph saat diklik

Anda dapat menggunakan library graphics-shape untuk membuat tombol yang berubah seiring waktu dua bentuk saat ditekan. Pertama, buat MorphPolygonShape yang memperluas Shape, menskalakan dan menerjemahkannya agar sesuai. Perhatikan penerusan ID progres sehingga bentuknya dapat dianimasikan:

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

Untuk menggunakan bentuk morph ini, buat dua poligon, shapeA dan shapeB. Buat dan ingat Morph. Kemudian, terapkan morph ke tombol sebagai garis batas klip, menggunakan interactionSource saat ditekan sebagai pendorong di balik animasi:

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

Hal ini menghasilkan animasi berikut saat kotak diketuk:

Morph diterapkan sebagai klik di antara dua bentuk
Gambar 12. Morph diterapkan sebagai klik antara dua bentuk.

Animasi bentuk yang berubah tanpa batas

Untuk menganimasikan bentuk morph tanpa henti, gunakan rememberInfiniteTransition Berikut adalah contoh foto profil yang mengubah bentuk (dan memutar) tanpa batas dari waktu ke waktu. Pendekatan ini menggunakan sedikit penyesuaian terhadap MorphPolygonShape ditampilkan di atas:

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

Kode ini memberikan hasil yang menyenangkan berikut:

Tangan membentuk hati
Gambar 13. Foto profil yang terpotong oleh bentuk bergerigi yang berputar.

Poligon kustom

Jika bentuk yang dibuat dari poligon biasa tidak memenuhi kasus penggunaan, Anda dapat membuat bentuk yang lebih khusus dengan daftar verteks. Misalnya, Anda mungkin ingin membuat bentuk hati seperti ini:

Tangan membentuk hati
Gambar 14. Bentuk hati.

Anda dapat menentukan setiap vertex bentuk ini menggunakan overload RoundedPolygon yang menggunakan array float koordinat x, y.

Untuk menguraikan poligon hati, perhatikan bahwa sistem koordinat kutub untuk menentukan titik mempermudah hal ini daripada menggunakan sistem koordinat cartesian (x, y), dengan dimulai di sisi kanan, dan dilanjutkan searah jarum jam, dengan 270° pada posisi jam 12:

Tangan membentuk hati
Gambar 15. Bentuk hati dengan koordinat.

Bentuk sekarang dapat ditentukan dengan cara yang lebih mudah dengan menentukan sudut () dan radius dari pusat di setiap titik:

Tangan membentuk hati
Gambar 16. Bentuk hati dengan koordinat, tanpa pembulatan.

Sekarang, vertex dapat dibuat dan diteruskan ke fungsi 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,
    )
}

Vertix harus diterjemahkan ke dalam koordinat Kartesius menggunakan fungsi radialToCartesian ini:

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

Kode sebelumnya memberi Anda vertex mentah untuk hati, tetapi Anda perlu membulatkan sudut tertentu untuk mendapatkan bentuk hati yang dipilih. Sudut di 90° dan 270° tidak memiliki pembulatan, tetapi sudut lainnya memiliki pembulatan. Untuk mendapatkan pembulatan kustom untuk setiap sudut, gunakan parameter 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)
)

Tindakan ini akan menghasilkan hati berwarna merah muda:

Tangan membentuk hati
Gambar 17. Hasil bentuk hati.

Jika bentuk sebelumnya tidak mencakup kasus penggunaan Anda, pertimbangkan untuk menggunakan class Path untuk menggambar bentuk kustom, atau memuat file ImageVector dari disk. Library graphics-shapes tidak dimaksudkan untuk digunakan sebagai arbitrer bentuk, tetapi secara khusus dimaksudkan untuk menyederhanakan pembuatan poligon bulat dan animasi morph di antara mereka.

Referensi lainnya

Untuk informasi dan contoh selengkapnya, lihat referensi berikut: