Hình dạng trong Compose

Với Compose, bạn có thể tạo các 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 màu xanh dương ở chính giữa vùng vẽ
Hình 1. Ví dụ về các hình dạng khác nhau mà bạn có thể tạo bằng thư viện graphics-shapes

Để tạo một đ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.1"

Thư viện này cho phép bạn tạo các hình dạng được tạo từ đa giác. Mặc dù các hình đa giác chỉ có các cạnh thẳng và góc nhọn, nhưng những hình này cho phép các góc bo tròn không bắt buộc. Nhờ đó, bạn có thể 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ỳ ý rất khó và thường là vấn đề về thời gian thiết kế. Nhưng thư viện này giúp đơn giản hoá 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 tạo một hình đa giác cơ bản có 6 điểm ở giữ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 màu xanh dương ở chính giữa vùng vẽ
Hình 2. Hình lục giác màu xanh dương ở chính 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 một ứng dụng Compose, bạn phải lấy một đối tượng Path từ đối tượng đó để đưa hình dạng vào một biểu mẫu mà Compose biết cách vẽ.

Bo tròn các góc của đa giác

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

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 có góc bo tròn sau đây được tạo như sau:

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

Độ nhẵn

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

Hệ số làm mịn là 0 (không làm mịn) tạo ra một đường cong bậc ba duy nhất theo một vòng tròn quanh góc có bán kính bo tròn được chỉ định, như trong ví dụ trước
Hình 5. Hệ số làm mịn là 0 (không làm mịn) tạo ra một đường cong bậc ba duy nhất theo một vòng tròn quanh góc có bán kính làm tròn được chỉ định, như trong ví dụ trước.
Hệ số làm mịn 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 2 đường cong bên cạnh chuyển đổi giữa đường cong bên trong và các cạnh đa giác.
Hình 6. Hệ số làm mịn 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 2 đường cong bên cạnh 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ỏ khi đặt chế độ làm mịn thành 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)
)

Hai hình tam giác màu đen cho thấy sự khác biệt về tham số làm mịn.
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ịn.

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

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

Hình dạng biến đổi

Đối tượng Morph là một hình dạng mới biểu thị một ảnh động giữa hai hình dạng đa giác. Để biến đổi giữa hai hình dạng, hãy tạo hai RoundedPolygons và một đối tượng Morph nhận hai hình dạng này. Để tính toán một 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 từ 0 đến 1 để xác định hình dạng của hình dạng đó giữa hình dạng bắt đầu (0) và hình dạng 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 bo tròn và hình vuông), tạo ra kết quả sau:

Nằm ở khoảng giữa hình tam giác bo tròn và hình vuông
Hình 8. Nằm ở khoảng giữa hình tam giác bo tròn và hình vuông.

Trong hầu hết các trường hợp, hiệu ứng biến đổi được thực hiện trong quá trình tạo ảnh động, chứ không chỉ là một bản kết xuất tĩnh. Để tạo ảnh động giữa hai giá trị này, bạn có thể sử dụng API Animation tiêu chuẩn trong Compose để thay đổi giá trị tiến trình theo thời gian. Ví dụ: bạn có thể tạo hiệu ứng biến đổi vô hạn 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 hình vuông và hình tam giác bo tròn
Hình 9. Biến đổi vô hạn giữa hình vuông và hình tam giác bo tròn.

Dùng đa giác làm đoạn video

Bạn thường dùng đối tượng sửa đổi clip trong Compose để thay đổi cách kết xuất một thành phần kết hợp và tận dụng các bóng đổ vẽ 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ể dùng đa giác làm một đoạn trích, 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 những vấn đề sau:

Hình lục giác có văn bản "hello compose" ở giữa.
Hình 10. Hình lục giác có dòng chữ "Hello Compose" ở giữa.

Điều này có thể không khác nhiều so với nội dung được kết xuất 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ó trong 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 làm mặt nạ.

Nút biến đổi khi nhấp

Bạn có thể dùng thư viện graphics-shape để tạo một nút biến đổi giữa hai 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 tỷ lệ và dịch nó cho phù hợp. Lưu ý việc truyền tiến trình để có thể tạo hiệu ứng cho hình dạ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 dạng biến đổi này, hãy tạo 2 đa giác, shapeAshapeB. Tạo và ghi nhớ Morph. Sau đó, hãy áp dụng hiệu ứng biến đổi cho nút dưới dạng đường viền của clip, bằng cách dùng interactionSource khi nhấn làm động lực cho hiệu ứng chuyển độ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))
}

Điều này dẫn đến ảnh động sau đây khi hộp được nhấn:

Hiệu ứng biến đổi được áp dụng khi bạn nhấp giữa hai hình
Hình 12. Hiệu ứng biến đổi được áp dụng dưới dạng một lượt nhấp giữa hai hình dạng.

Tạo hiệu ứng biến đổi hình dạng vô hạn

Để tạo hiệu ứng biến đổi hình dạng không ngừng, hãy dùng rememberInfiniteTransition. Dưới đây là ví dụ về ảnh hồ sơ thay đổi hình dạng (và xoay) vô hạn theo thời gian. Phương pháp này sử dụng một điều chỉnh nhỏ cho MorphPolygonShape xuất hiện ở 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)
        )
    }
}

Đoạn mã này mang lại kết quả thú vị sau:

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

Đa giác tuỳ chỉnh

Nếu các hình dạng được tạo từ đa giác đều không đáp ứng 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 hơn bằng danh sách các đỉnh. Ví dụ: bạn có thể muốn tạo một 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 phương thức nạp chồng RoundedPolygon. Phương thức này lấy một mảng số thực gồm các toạ độ x, y.

Để phân tích đa giác trái tim, hãy 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 ở phía 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 có toạ độ.

Giờ đây, bạn có thể xác định hình dạng một cách dễ dàng hơn bằng cách chỉ định góc (𝜭) và bán kí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 bo tròn.

Giờ đây, 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,
    )
}

Bạn cần chuyển đổi các đỉnh thành toạ độ Descartes bằng hàm radialToCartesian sau:

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

Đoạn mã trên cung cấp cho bạn các đỉnh thô cho hình 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 sẽ tạo ra trái tim màu hồng:

Tay hình trái tim
Hình 17. Kết quả có 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ẽ một 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 dùng cho các hình dạng tuỳ ý, mà được thiết kế riêng để đơn giản hoá việc tạo các đa giác bo tròn và ảnh động biến hình 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: