Material Design 2 trong Compose

Jetpack Compose cung cấp cách triển khai Material Design, một hệ thống thiết kế toàn diện để tạo các giao diện kỹ thuật số. Các thành phần Material Design (nút, thẻ, nút chuyển, v.v.) được xây dựng dựa trên Tuỳ chỉnh giao diện Material để phản ánh tốt hơn thương hiệu của sản phẩm một cách có hệ thống. Giao diện Material chứa các thuộc tính màu sắc, kiểu chữhình dạng. Khi bạn tuỳ chỉnh các thuộc tính này, thay đổi sẽ tự động phản ánh trong các thành phần mà bạn sử dụng để xây dựng ứng dụng.

Jetpack Compose triển khai các khái niệm này bằng thành phần kết hợp (composable) MaterialTheme:

MaterialTheme(
    colors = // ...
    typography = // ...
    shapes = // ...
) {
    // app content
}

Định cấu hình các tham số mà bạn truyền đến MaterialTheme để tuỳ chỉnh giao diện cho ứng dụng.

Hai ảnh chụp màn hình tương phản. Ảnh chụp đầu tiên tạo kiểu MaterialTheme theo mặc định, ảnh chụp thứ hai sử dụng kiểu đã qua chỉnh sửa.
Hình 1. Ảnh chụp màn hình đầu tiên cho thấy một ứng dụng không định cấu hình "MaterialTheme", do đó, ứng dụng này định kiểu theo mặc định. Ảnh chụp màn hình thứ hai cho thấy một ứng dụng truyền các tham số đến "MaterialTheme" để tuỳ chỉnh cách định kiểu.

Màu

Màu sắc được mô hình hoá trong Compose bằng lớp Color, một lớp chứa dữ liệu.

val Red = Color(0xffff0000)
val Blue = Color(red = 0f, green = 0f, blue = 1f)

Mặc dù bạn có thể sắp xếp những lớp này theo bất cứ cách nào bạn muốn (như hằng số cấp cao nhất, trong một singleton hoặc trong cùng dòng đã được xác định), chúng tôi đặc biệt khuyên bạn nên chỉ định màu trong giao diện và truy xuất màu từ đó. Cách thức này giúp bạn có thể hỗ trợ giao diện tối và giao diện lồng nhau.

Ví dụ về bảng màu giao diện
Hình 2. Hệ màu Material.

Compose cung cấp lớp Colors để lập mô hình Hệ màu Material. Colors cung cấp các hàm trình tạo để tạo các nhóm màu sáng hoặc tối:

private val Yellow200 = Color(0xffffeb46)
private val Blue200 = Color(0xff91a4fc)
// ...

private val DarkColors = darkColors(
    primary = Yellow200,
    secondary = Blue200,
    // ...
)
private val LightColors = lightColors(
    primary = Yellow500,
    primaryVariant = Yellow400,
    secondary = Blue700,
    // ...
)

Sau khi xác định Colors, bạn có thể truyền các màu đó đến MaterialTheme:

MaterialTheme(
    colors = if (darkTheme) DarkColors else LightColors
) {
    // app content
}

Sử dụng màu giao diện

Bạn có thể truy xuất Colors được cung cấp cho thành phần kết hợp MaterialThemebằng cách sử dụng MaterialTheme.colors.

Text(
    text = "Hello theming",
    color = MaterialTheme.colors.primary
)

Màu bề mặt và màu nội dung

Nhiều thành phần chấp nhận một cặp màu và màu nội dung:

Surface(
    color = MaterialTheme.colors.surface,
    contentColor = contentColorFor(color),
    // ...
) { /* ... */ }

TopAppBar(
    backgroundColor = MaterialTheme.colors.primarySurface,
    contentColor = contentColorFor(backgroundColor),
    // ...
) { /* ... */ }

Do vậy, bạn có thể không chỉ thiết lập màu cho một thành phần kết hợp, mà còn cung cấp màu mặc định cho nội dung, các thành phần kết hợp trong đó. Nhiều thành phần kết hợp sử dụng màu nội dung này theo mặc định. Ví dụ: màu của Text dựa trên màu nội dung của lớp mẹ, còn Icon sử dụng màu đó để đặt sắc thái màu.

Hai ví dụ về cùng một biểu ngữ nhưng có màu sắc khác nhau
Hình 3. Việc đặt màu nền khác nhau sẽ tạo ra các màu của biểu tượng và văn bản khác nhau.

Phương thức contentColorFor() truy xuất màu "trên" thích hợp cho màu của bất kỳ giao diện nào. Ví dụ: nếu bạn đặt màu nền primary trên Surface, thì hàm này sẽ dùng để đặt onPrimary làm màu nội dung. Nếu đặt màu nền không theo giao diện, bạn cũng nên chỉ định màu nội dung phù hợp. Sử dụng LocalContentColor để truy xuất màu nội dung ưu tiên cho nền hiện tại, tại một vị trí nhất định trong hệ phân cấp.

Độ đậm nhạt của nội dung

Thông thường, bạn muốn thay đổi mức độ nhấn mạnh nội dung để truyền tải mức độ quan trọng và mang lại sự phân cấp thị giác. Các tài liệu đề xuất về mức độ dễ đọc văn bản trong Material Design khuyến cáo nên sử dụng nhiều mức độ đậm nhạt để truyền tải các mức độ quan trọng khác nhau.

Jetpack Compose triển khai việc này bằng cách sử dụng LocalContentAlpha. Bạn có thể chỉ định độ đậm nhạt (alpha) của nội dung cho hệ thống phân cấp bằng cách cung cấp giá trị cho CompositionLocal. Các thành phần kết hợp lồng ghép có thể sử dụng giá trị này để áp dụng biện pháp xử lý độ đậm nhạt cho nội dung của chúng. Ví dụ: theo mặc định, TextIcon sử dụng tổ hợp LocalContentColor được điều chỉnh để sử dụng LocalContentAlpha. Material chỉ định một số giá trị alpha chuẩn (high, medium, disabled) được mô hình hoá bằng đối tượng ContentAlpha.

// By default, both Icon & Text use the combination of LocalContentColor &
// LocalContentAlpha. De-emphasize content by setting content alpha
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
    Text(
        // ...
    )
}
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) {
    Icon(
        // ...
    )
    Text(
        // ...
    )
}

Để tìm hiểu thêm về CompositionLocal, hãy xem phần Dữ liệu trong phạm vi cục bộ với CompositionLocal.

Ảnh chụp màn hình của tiêu đề bài viết cho thấy các cấp độ nhấn mạnh văn bản khác nhau
Hình 4. Áp dụng nhiều mức độ nhấn mạnh cho văn bản để truyền đạt thứ tự cấp bậc thông tin một cách trực quan. Dòng văn bản đầu tiên là tiêu đề và chứa thông tin quan trọng nhất, do đó sử dụng ContentAlpha.high. Dòng thứ hai chứa siêu dữ liệu ít quan trọng hơn, do đó sử dụng ContentAlpha.medium.

Giao diện tối

Trong Compose, bạn cài đặt giao diện tối và sáng bằng cách cung cấp các nhóm Colors khác nhau cho thành phần kết hợp MaterialTheme:

@Composable
fun MyTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    MaterialTheme(
        colors = if (darkTheme) DarkColors else LightColors,
        /*...*/
        content = content
    )
}

Trong ví dụ này, MaterialTheme được bọc trong một thành phần kết hợp riêng chấp nhận một tham số chỉ định xem có nên sử dụng giao diện tối hay không. Trong trường hợp này, hàm sẽ nhận giá trị mặc định cho darkTheme bằng cách truy vấn chế độ cài đặt giao diện trên thiết bị.

Bạn có thể sử dụng mã như thế này để kiểm tra xem Colors hiện tại là sáng hay tối:

val isLightTheme = MaterialTheme.colors.isLight
Icon(
    painterResource(
        id = if (isLightTheme) {
            R.drawable.ic_sun_24
        } else {
            R.drawable.ic_moon_24
        }
    ),
    contentDescription = "Theme"
)

Lớp phủ độ nâng

Trong Material, các bề mặt trong giao diện tối có độ nâng cao hơn sẽ nhận được lớp phủ độ nâng để làm sáng nền tương ứng. Bề mặt có độ nâng càng cao (nâng độ nâng lên gần với nguồn sáng ngầm ẩn) thì bề mặt đó càng sáng.

Thành phần kết hợp Surface sẽ tự động áp dụng các lớp phủ này khi sử dụng màu tối, cũng như mọi thành phần kết hợp Material khác sử dụng một bề mặt:

Surface(
    elevation = 2.dp,
    color = MaterialTheme.colors.surface, // color will be adjusted for elevation
    /*...*/
) { /*...*/ }

Ảnh chụp màn hình một ứng dụng cho thấy màu sắc có sự khác biệt nhẹ được sử dụng cho các phần tử ở nhiều độ nâng
Hình 5. Các thẻ và phần điều hướng dưới cùng đều sử dụng màu surface làm màu nền. Vì thẻ và phần điều hướng dưới cùng có độ nâng khác nhau phía trên nền, nên màu sắc của chúng sẽ có sự khác biệt nhẹ – màu thẻ sáng hơn màu nền và màu của phần điều hướng dưới cùng sáng hơn màu thẻ.

Đối với các trường hợp tuỳ chỉnh không liên quan đến Surface, hãy sử dụng LocalElevationOverlay, một CompositionLocal chứa ElevationOverlay được dùng trong các thành phần Surface:

// Elevation overlays
// Implemented in Surface (and any components that use it)
val color = MaterialTheme.colors.surface
val elevation = 4.dp
val overlaidColor = LocalElevationOverlay.current?.apply(
    color, elevation
)

Để tắt lớp phủ nâng, hãy cung cấp null tại điểm đã chọn trong hệ phân cấp thành phần kết hợp:

MyTheme {
    CompositionLocalProvider(LocalElevationOverlay provides null) {
        // Content without elevation overlays
    }
}

Hạn chế các màu nhấn

Material Design đề xuất áp dụng một số màu nhấn hạn chế cho giao diện tối bằng cách ưu tiên sử dụng màu surface thay cho màu primary trong hầu hết các trường hợp. Theo mặc định, các thành phần kết hợp trong Material như TopAppBarBottomNavigation sẽ triển khai hành vi này.

Ảnh chụp màn hình giao diện tối của Material, cho thấy thanh ứng dụng trên cùng sử dụng màu bề mặt thay vì màu chính cho các điểm nhấn màu sắc hạn chế
Hình 6. Giao diện tối có màu nhấn hạn chế trong Material. Thanh ứng dụng trên cùng sử dụng màu chính trong giao diện sáng và màu bề mặt trong giao diện tối.

Đối với các trường hợp tuỳ chỉnh, hãy sử dụng thuộc tính mở rộng primarySurface:

Surface(
    // Switches between primary in light theme and surface in dark theme
    color = MaterialTheme.colors.primarySurface,
    /*...*/
) { /*...*/ }

Kiểu chữ

Material xác định hệ thống kiểu chữ, khuyến khích bạn sử dụng một số ít các kiểu được đặt tên theo ngữ nghĩa.

Ví dụ về một số kiểu chữ dưới nhiều dạng khác nhau
Hình 7. Hệ thống kiểu chữ của Material.

Compose triển khai hệ thống kiểu chữ bằng lớp Typography, TextStylecác lớp liên quan đến phông chữ. Hàm khởi tạo Typography cung cấp các tuỳ chọn mặc định cho từng kiểu để bạn có thể bỏ qua bất kỳ kiểu nào bạn không muốn tuỳ chỉnh:

val raleway = FontFamily(
    Font(R.font.raleway_regular),
    Font(R.font.raleway_medium, FontWeight.W500),
    Font(R.font.raleway_semibold, FontWeight.SemiBold)
)

val myTypography = Typography(
    h1 = TextStyle(
        fontFamily = raleway,
        fontWeight = FontWeight.W300,
        fontSize = 96.sp
    ),
    body1 = TextStyle(
        fontFamily = raleway,
        fontWeight = FontWeight.W600,
        fontSize = 16.sp
    )
    /*...*/
)
MaterialTheme(typography = myTypography, /*...*/) {
    /*...*/
}

Nếu bạn muốn sử dụng cùng một kiểu chữ, hãy chỉ định tham số defaultFontFamily và bỏ qua fontFamily của bất kỳ phần tử TextStyle nào:

val typography = Typography(defaultFontFamily = raleway)
MaterialTheme(typography = typography, /*...*/) {
    /*...*/
}

Sử dụng kiểu văn bản

Các phần tử TextStyle được truy cập bằng MaterialTheme.typography. Truy xuất các phần tử TextStyle như sau:

Text(
    text = "Subtitle2 styled",
    style = MaterialTheme.typography.subtitle2
)

Ảnh chụp màn hình cho thấy có sự kết hợp nhiều kiểu chữ khác nhau cho các mục đích khác nhau
Hình 8. Sử dụng một số kiểu chữ và cách tạo kiểu được chọn để thể hiện thương hiệu.

Hình dạng

Material xác định hệ thống hình dạng, cho phép bạn xác định các hình dạng cho các thành phần lớn, vừa và nhỏ.

Cho thấy nhiều hình dạng của Material Design
Hình 9. Hệ thống hình dạng Material.

Compose triển khai hệ thống hình dạng bằng lớp Shapes, cho phép bạn chỉ định CornerBasedShape cho từng danh mục kích thước:

val shapes = Shapes(
    small = RoundedCornerShape(percent = 50),
    medium = RoundedCornerShape(0f),
    large = CutCornerShape(
        topStart = 16.dp,
        topEnd = 0.dp,
        bottomEnd = 0.dp,
        bottomStart = 16.dp
    )
)

MaterialTheme(shapes = shapes, /*...*/) {
    /*...*/
}

Nhiều thành phần sử dụng các hình dạng này theo mặc định. Ví dụ: Button, TextFieldFloatingActionButton mặc định là nhỏ, AlertDialog mặc định là trung bình và ModalDrawer mặc định là lớn – hãy xem tham chiếu lược đồ hình dạng để biết thông tin liên kết đầy đủ.

Sử dụng hình dạng

Các phần tử Shape được truy cập bằng MaterialTheme.shapes. Truy xuất các phần tử Shape bằng mã như sau:

Surface(
    shape = MaterialTheme.shapes.medium, /*...*/
) {
    /*...*/
}

Ảnh chụp màn hình ứng dụng sử dụng hình dạng Material để cho biết trạng thái của một thành phần
Hình 10. Sử dụng hình dạng để thể hiện thương hiệu hoặc trạng thái.

Kiểu mặc định

Không có khái niệm tương đương về các kiểu mặc định trong Compose từ Chế độ xem Android. Bạn có thể cung cấp chức năng tương tự bằng cách tạo các hàm có khả năng kết hợp overload riêng để bao bọc các thành phần Material. Ví dụ: để tạo kiểu nút, hãy gói nút trong hàm có khả năng kết hợp của riêng bạn, trực tiếp đặt các tham số bạn muốn hoặc cần thay đổi và cấp quyền truy cập cho các tham số khác dưới dạng tham số cho thành phần kết hợp chứa tham số.

@Composable
fun MyButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit
) {
    Button(
        colors = ButtonDefaults.buttonColors(
            backgroundColor = MaterialTheme.colors.secondary
        ),
        onClick = onClick,
        modifier = modifier,
        content = content
    )
}

Lớp phủ giao diện

Bạn có thể đạt được mức tương đương với lớp phủ giao diện trong thành phần hiển thị Android trong Compose bằng cách lồng ghép thành phần kết hợp MaterialTheme. Vì MaterialTheme đặt giá trị mặc định cho màu sắc, kiểu chữ và hình dạng theo giá trị giao diện hiện tại, nên tất cả các tham số khác sẽ giữ nguyên giá trị mặc định khi một giao diện chỉ đặt một trong các tham số đó.

Ngoài ra, khi di chuyển màn hình dựa trên Khung hiển thị sang Compose, hãy lưu ý đến cách sử dụng thuộc tính android:theme. Có thể bạn cần một MaterialTheme mới trong phần đó của cây giao diện người dùng Compose.

Trong ví dụ này, màn hình chi tiết sử dụng PinkTheme cho phần lớn màn hình, sau đó dùng BlueTheme cho phần có liên quan. Ảnh chụp màn hình và mã sau đây minh hoạ khái niệm này:

Ảnh chụp màn hình của một ứng dụng minh hoạ các giao diện lồng nhau, với giao diện màu hồng cho màn hình chính và giao diện màu xanh dương cho một phần có liên quan
Hình 11. Giao diện lồng nhau.

@Composable
fun DetailsScreen(/* ... */) {
    PinkTheme {
        // other content
        RelatedSection()
    }
}

@Composable
fun RelatedSection(/* ... */) {
    BlueTheme {
        // content
    }
}

Trạng thái thành phần

Các thành phần Material có thể tương tác (được nhấp, bật/tắt, v.v.) có thể ở các trạng thái thị giác khác nhau. Các trạng thái bao gồm bật, tắt, được nhấn, v.v.

Các thành phần kết hợp thường có tham số enabled. Việc đặt thành false sẽ ngăn tương tác và thay đổi các thuộc tính như màu sắc và elevation để cho biết trạng thái thành phần một cách trực quan.

Ảnh chụp màn hình của hai nút: một nút được bật, một nút bị tắt, cho thấy các trạng thái trực quan khác nhau của chúng
Hình 12. Nút có enabled = true (bên trái) và enabled = false (bên phải).

Trong hầu hết các trường hợp, bạn có thể dựa vào giá trị mặc định như màu sắc và elevation. Nếu cần định cấu hình các giá trị dùng trong các trạng thái khác nhau, bạn có thể dùng các lớp và hàm tiện lợi có sẵn. Hãy xem ví dụ sau về nút:

Button(
    onClick = { /* ... */ },
    enabled = true,
    // Custom colors for different states
    colors = ButtonDefaults.buttonColors(
        backgroundColor = MaterialTheme.colors.secondary,
        disabledBackgroundColor = MaterialTheme.colors.onBackground
            .copy(alpha = 0.2f)
            .compositeOver(MaterialTheme.colors.background)
        // Also contentColor and disabledContentColor
    ),
    // Custom elevation for different states
    elevation = ButtonDefaults.elevation(
        defaultElevation = 8.dp,
        disabledElevation = 2.dp,
        // Also pressedElevation
    )
) { /* ... */ }

Ảnh chụp màn hình của 2 nút có màu sắc và độ nâng đã điều chỉnh cho trạng thái bật và tắt
Hình 13. Nút có enabled = true (trái) và enabled = false (phải), với các giá trị màu và độ nâng đã điều chỉnh.

Hiệu ứng gợn sóng

Các thành phần trong Material Design sử dụng hiệu ứng gợn sóng để cho biết chúng đang được tương tác. Nếu bạn đang sử dụng MaterialTheme trong hệ thống phân cấp, thì Ripple sẽ được dùng làm Indication mặc định bên trong các đối tượng sửa đổi, chẳng hạn như clickableindication.

Trong hầu hết các trường hợp, bạn có thể sử dụng Ripple mặc định. Nếu cần định cấu hình giao diện của các thành phần này, bạn có thể dùng RippleTheme để thay đổi các thuộc tính như màu sắc và alpha.

Bạn có thể mở rộng RippleTheme và sử dụng các hàm số hiệu dụng defaultRippleColordefaultRippleAlpha. Sau đó, bạn có thể cung cấp giao diện gợn sóng tuỳ chỉnh trong hệ thống phân cấp bằng cách sử dụng LocalRippleTheme:

@Composable
fun MyApp() {
    MaterialTheme {
        CompositionLocalProvider(
            LocalRippleTheme provides SecondaryRippleTheme
        ) {
            // App content
        }
    }
}

@Immutable
private object SecondaryRippleTheme : RippleTheme {
    @Composable
    override fun defaultColor() = RippleTheme.defaultRippleColor(
        contentColor = MaterialTheme.colors.secondary,
        lightTheme = MaterialTheme.colors.isLight
    )

    @Composable
    override fun rippleAlpha() = RippleTheme.defaultRippleAlpha(
        contentColor = MaterialTheme.colors.secondary,
        lightTheme = MaterialTheme.colors.isLight
    )
}

Ảnh GIF động cho thấy các nút có hiệu ứng gợn sóng khác nhau khi được nhấn
Hình 14. Các nút có các giá trị gợn sóng khác nhau được cung cấp bằng cách sử dụng RippleTheme.

Tìm hiểu thêm

Để tìm hiểu thêm về Tuỳ chỉnh Material Design trong Compose, hãy tham khảo các tài nguyên khác sau đây.

Lớp học lập trình

Video