Hình dạng trong Compose

Với Compose, bạn có thể tạo hình dạng được tạo từ đa giác. Ví dụ: bạn có thể tạo các loại hình dạng sau:

Hình lục giác xanh dương ở giữa vùng vẽ
Hình 1. Ví dụ về nhiều hình dạng mà bạn có thể tạo bằng thư viện hình dạng đồ hoạ

Để tạo đa giác bo tròn tuỳ chỉnh trong Compose, hãy thêm phần phụ thuộc graphics-shapes vào app/build.gradle:

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

Thư viện này cho phép bạn tạo hình dạng được tạo từ đa giác. Mặc dù hình đa giác chỉ có cạnh thẳng và góc sắc, nhưng những hình dạng này cho phép có góc tròn tuỳ ý. Công cụ này giúp bạn dễ dàng biến đổi giữa hai hình dạng khác nhau. Việc biến đổi giữa các hình dạng tuỳ ý và thường là vấn đề về thời gian thiết kế. Nhưng thư viện này làm cho nó đơn giản bằng cách biến đổi giữa các hình dạng này với cấu trúc đa giác tương tự.

Tạo đa giác

Đoạn mã sau đây sẽ tạo một hình đa giác cơ bản có 6 điểm ở tâm của vùng vẽ:

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

Hình lục giác xanh dương ở giữa vùng vẽ
Hình 2. Hình lục giác màu xanh dương ở giữa vùng vẽ.

Trong ví dụ này, thư viện sẽ tạo một RoundedPolygon chứa hình học đại diện cho hình dạng được yêu cầu. Để vẽ hình dạng đó trong ứng dụng Compose, bạn phải lấy đối tượng Path từ ứng dụng đó để biến hình dạng thành một biểu mẫu mà Compose đã biết cách vẽ.

Làm tròn các góc của đa giác

Để làm tròn các góc của một đa giác, hãy sử dụng tham số CornerRounding. Việc này gồm 2 tham số, radiussmoothing. Mỗi góc bo tròn được tạo thành từ 1-3 đường cong bậc ba, tâm của đường cong này có hình vòng cung tròn, trong khi hai đường cong hai bên ("cạnh") chuyển tiếp từ cạnh của hình dạng sang đường cong ở giữa.

Bán kính

radius là bán kính của hình tròn dùng để làm tròn một đỉnh.

Ví dụ: tam giác bo tròn các góc được tạo như sau:

Hình tam giác bo tròn góc
Hình 3. Hình tam giác bo tròn các góc.
Bán kính làm tròn r xác định kích thước làm tròn của các góc bo tròn
Hình 4. Bán kính làm tròn r xác định kích thước làm tròn của các góc bo tròn.

Độ nhẵn

Làm mượt là một yếu tố xác định thời gian cần thiết để đi từ phần làm tròn bo tròn của góc đến cạnh. Hệ số làm mượt là 0 (giá trị mặc định của CornerRounding không được làm mượt) dẫn đến việc làm tròn góc tròn hoàn toàn. Hệ số làm mượt khác 0 (tối đa là 1,0) dẫn đến việc góc được làm tròn bằng 3 đường cong riêng biệt.

Hệ số làm mượt bằng 0 (không làm mượt) sẽ tạo ra một đường cong khối duy nhất theo sau một vòng tròn xung quanh góc với bán kính làm tròn được chỉ định, như trong ví dụ trước
Hình 5. Hệ số làm mượt 0 (không làm mượt) sẽ tạo ra một đường cong khối duy nhất đi theo một vòng tròn xung quanh góc có bán kính làm tròn được chỉ định, như trong ví dụ trước.
Một hệ số làm mượt khác 0 tạo ra 3 đường cong bậc ba để làm tròn đỉnh: đường cong tròn bên trong (như trước) cộng với hai đường cong bên trong chuyển đổi giữa đường cong bên trong và các cạnh đa giác.
Hình 6. Một hệ số làm mượt khác không tạo ra 3 đường cong bậc ba để làm tròn đỉnh: đường cong tròn bên trong (như trước) cộng với hai đường cong bên trong chuyển đổi giữa đường cong bên trong và các cạnh đa giác.

Ví dụ: đoạn mã dưới đây minh hoạ sự khác biệt nhỏ trong việc đặt độ mượt ở mức 0 so với 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 tam giác màu đen cho thấy sự khác biệt về tham số làm mượt.
Hình 7. Hai hình tam giác màu đen cho thấy sự khác biệt về tham số làm mượt.

Kích thước và vị trí

Theo mặc định, một hình dạng sẽ được tạo với bán kính 1 xung quanh tâm (0, 0). Bán kính này thể hiện khoảng cách giữa tâm và các đỉnh bên ngoài của đa giác mà dựa trên hình dạng đó. Lưu ý rằng việc làm tròn các góc sẽ có hình dạng nhỏ hơn vì các góc tròn sẽ gần tâm hơn so với các đỉnh được bo tròn. Để định kích thước một đa giác, hãy điều chỉnh giá trị radius. Để điều chỉnh vị trí, hãy thay đổi centerX hoặc centerY của đa giác. Ngoài ra, bạn có thể biến đổi đối tượng để thay đổi kích thước, vị trí và chế độ xoay bằng các hàm biến đổi DrawScope tiêu chuẩn như DrawScope#translate().

Hình dạng mô đun

Đối tượng Morph là một hình dạng mới biểu thị ảnh động giữa hai hình đa giác. Để thay đổi giữa hai hình dạng, hãy tạo 2 đối tượng RoundedPolygonsMorph nhận 2 hình dạng này. Để tính toán hình dạng giữa hình dạng bắt đầu và hình dạng kết thúc, hãy cung cấp giá trị progress trong khoảng từ 0 đến 1 để xác định dạng của hình dạng đó giữa hình dạng bắt đầu (0) và kết thúc (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()
)

Trong ví dụ trên, tiến trình nằm chính xác giữa hai hình dạng (hình tam giác tròn và hình vuông), tạo ra kết quả sau:

50% khoảng cách giữa một tam giác tròn và một hình vuông
Hình 8. 50% khoảng cách giữa một tam giác tròn và một hình vuông.

Trong hầu hết các trường hợp, hiệu ứng biến hình được thực hiện như một phần của ảnh động chứ không chỉ là kết xuất tĩnh. Để tạo ảnh động giữa hai loại chuyển động này, bạn có thể sử dụng API ảnh động trong Compose tiêu chuẩn để thay đổi giá trị tiến trình theo thời gian. Ví dụ: bạn có thể tạo ảnh động vô hạn cho hiệu ứng biến đổi giữa hai hình dạng này như sau:

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

Biến đổi vô hạn giữa một hình vuông và một hình tam giác tròn
Hình 9. Biến đổi vô tận giữa một hình vuông và một tam giác tròn.

Sử dụng đa giác làm đối tượng cắt

Thường thì bạn nên sử dụng đối tượng sửa đổi clip trong Compose để thay đổi cách kết xuất thành phần kết hợp cũng như để tận dụng bóng đổ xung quanh vùng cắt:

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

Sau đó, bạn có thể sử dụng đa giác dưới dạng một đoạn, như minh hoạ trong đoạn mã sau:

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

Điều này dẫn đến:

Hình lục giác có dòng chữ "hello compose" (Xin chào Compose) ở giữa.
Hình 10. Hình lục giác có dòng chữ "Hello Compose" (Xin chào Compose) ở giữa.

Giao diện này có thể không khác so với cách hiển thị trước đây, nhưng cho phép tận dụng các tính năng khác trong Compose. Ví dụ: bạn có thể dùng kỹ thuật này để cắt một hình ảnh và áp dụng bóng đổ xung quanh vùng bị cắt:

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)

    )
}

Chú chó hình lục giác có bóng đổ xung quanh các cạnh
Hình 11. Đã áp dụng hình dạng tuỳ chỉnh dưới dạng phần cắt.

Nút Morph khi nhấp vào

Bạn có thể sử dụng thư viện graphics-shape để tạo một nút thay đổi giữa 2 hình dạng khi nhấn. Trước tiên, hãy tạo một MorphPolygonShape mở rộng Shape, điều chỉnh theo tỷ lệ và dịch chuyển đổi này sao cho phù hợp. Lưu ý tiến trình đang truyền đi để hình dạng có thể được tạo ảnh động:

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

Để sử dụng hình đại diện này, hãy tạo hai đa giác: shapeAshapeB. Tạo và ghi nhớ Morph. Sau đó, áp dụng hiệu ứng biến hình cho nút dưới dạng đường viền của đoạn video, sử dụng interactionSource khi nhấn làm động lực thúc đẩy ảnh động:

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

Kết quả là ảnh động sau đây khi nhấn vào hộp:

Mô hình được áp dụng khi nhấp vào giữa hai hình dạng
Hình 12. Mô hình được áp dụng dưới dạng một lượt nhấp giữa hai hình dạng.

Tạo ảnh động cho hình dạng biến đổi vô hạn

Để tạo ảnh động liên tục cho một hình dạng biến đổi, hãy sử dụng rememberInfiniteTransition. Dưới đây là ví dụ về ảnh hồ sơ thay đổi hình dạng (và xoay) vô thời hạn theo thời gian. Phương pháp này áp dụng một sự điều chỉnh nhỏ đối với MorphPolygonShape như trình bày ở trên:

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

Mã này cung cấp kết quả thú vị sau đây:

Tay hình trái tim
Hình 13. Ảnh hồ sơ được cắt bằng một hình vỏ sò xoay.

Đa giác tuỳ chỉnh

Nếu các hình dạng được tạo từ đa giác thông thường không phù hợp với trường hợp sử dụng của bạn, thì bạn có thể tạo một hình dạng tuỳ chỉnh khác với danh sách các đỉnh. Ví dụ: bạn có thể tạo hình trái tim như sau:

Tay hình trái tim
Hình 14. Hình trái tim.

Bạn có thể chỉ định các đỉnh riêng lẻ của hình dạng này bằng cách sử dụng nạp chồng RoundedPolygon lấy một mảng nổi gồm các toạ độ x, y.

Để phân tích đa giác trái tim, lưu ý rằng hệ toạ độ cực để chỉ định các điểm giúp việc này dễ dàng hơn so với việc sử dụng hệ toạ độ Descartes (x, y), trong đó bắt đầu ở bên phải và tiến hành theo chiều kim đồng hồ, với 270° ở vị trí 12 giờ:

Tay hình trái tim
Hình 15. Hình trái tim kèm toạ độ.

Giờ đây, bạn có thể xác định hình dạng theo cách dễ dàng hơn bằng cách chỉ định góc (Θ) và bán kính tính từ tâm tại mỗi điểm:

Tay hình trái tim
Hình 16. Hình trái tim có toạ độ, không làm tròn.

Hiện bạn có thể tạo và truyền các đỉnh vào hàm 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,
    )
}

Các đỉnh cần được chuyển đổi thành toạ độ Descartes bằng cách sử dụng hàm radialToCartesian này:

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

Mã trên cung cấp cho bạn các đỉnh thô của trái tim, nhưng bạn cần làm tròn các góc cụ thể để có được hình trái tim đã chọn. Các góc tại 90°270° không có bo tròn, nhưng các góc khác thì có. Để bo tròn tuỳ chỉnh cho từng góc, hãy sử dụng tham số 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)
)

Điều này dẫn đến trái tim màu hồng:

Tay hình trái tim
Hình 17. Kết quả tìm kiếm hình trái tim.

Nếu các hình dạng trước đó không bao gồm trường hợp sử dụng của bạn, hãy cân nhắc sử dụng lớp Path để vẽ hình dạng tuỳ chỉnh hoặc tải tệp ImageVector lên từ ổ đĩa. Thư viện graphics-shapes không nhằm mục đích sử dụng cho các hình dạng tuỳ ý, mà đặc biệt nhằm đơn giản hoá việc tạo các đa giác bo tròn và ảnh động biến đổi giữa các đa giác đó.

Tài nguyên khác

Để biết thêm thông tin và ví dụ, hãy xem các tài nguyên sau: