Hệ thống thiết kế tuỳ chỉnh trong Compose

Tuy Material là hệ thống thiết kế mà chúng tôi đề xuất và Jetpack Compose hỗ trợ việc triển khai Material, nhưng bạn không nhất thiết phải sử dụng Material. Material được xây dựng hoàn toàn trên các API công khai, vì vậy, bạn cũng có thể tạo hệ thống thiết kế của riêng mình theo cách tương tự.

Sau đây là một số phương pháp bạn có thể thực hiện:

Bạn cũng nên tiếp tục sử dụng thành phần Material với một hệ thống thiết kế tuỳ chỉnh. Bạn có thể thực hiện việc này nhưng có một số điểm cần lưu ý để phù hợp với phương pháp mà bạn thực hiện.

Để tìm hiểu thêm về API và cấu trúc cấp thấp hơn mà MaterialTheme và các hệ thống thiết kế tuỳ chỉnh sử dụng, hãy tham khảo hướng dẫn Cấu tạo của một giao diện trong Compose.

Mở rộng giao diện Material

Compose Material lập mô hình giao diện Material (Material Theme) một cách chặt chẽ để chương trình này đơn giản và cho ra kết quả lường trước được (type-safe) theo đúng nguyên tắc của Material Design. Tuy nhiên, bạn có thể mở rộng các bộ màu, kiểu chữ và hình dạng bằng các giá trị bổ sung.

Cách đơn giản nhất là thêm thuộc tính mở rộng:

// Use with MaterialTheme.colors.snackbarAction
val Colors.snackbarAction: Color
    get() = if (isLight) Red300 else Red700

// Use with MaterialTheme.typography.textFieldInput
val Typography.textFieldInput: TextStyle
    get() = TextStyle(/* ... */)

// Use with MaterialTheme.shapes.card
val Shapes.card: Shape
    get() = RoundedCornerShape(size = 20.dp)

Việc này giúp đảm bảo tính nhất quán giữa các API sử dụng MaterialTheme. Có thể kể đến một ví dụ do chính Compose xác định, đó là primarySurface hoạt động như một proxy giữa primarysurface, tuỳ thuộc vào Colors.isLight.

Một phương pháp khác là xác định một giao diện mở rộng "gói" MaterialTheme và các giá trị của giao diện đó.

Giả sử bạn muốn thêm hai màu bổ sung — tertiaryonTertiary — trong khi vẫn giữ các màu Material hiện có:

@Immutable
data class ExtendedColors(
    val tertiary: Color,
    val onTertiary: Color
)

val LocalExtendedColors = staticCompositionLocalOf {
    ExtendedColors(
        tertiary = Color.Unspecified,
        onTertiary = Color.Unspecified
    )
}

@Composable
fun ExtendedTheme(
    /* ... */
    content: @Composable () -> Unit
) {
    val extendedColors = ExtendedColors(
        tertiary = Color(0xFFA8EFF0),
        onTertiary = Color(0xFF002021)
    )
    CompositionLocalProvider(LocalExtendedColors provides extendedColors) {
        MaterialTheme(
            /* colors = ..., typography = ..., shapes = ... */
            content = content
        )
    }
}

// Use with eg. ExtendedTheme.colors.tertiary
object ExtendedTheme {
    val colors: ExtendedColors
        @Composable
        get() = LocalExtendedColors.current
}

Điều này tương tự như các API sử dụng MaterialTheme. Cách này cũng hỗ trợ nhiều giao diện do bạn có thể lồng các ExtendedTheme giống như MaterialTheme.

Sử dụng thành phần Material

Khi mở rộng phần giao diện Material, các giá trị MaterialTheme hiện có sẽ được duy trì và các thành phần Material vẫn có giá trị mặc định hợp lý.

Nếu bạn muốn sử dụng giá trị được mở rộng trong các thành phần, hãy gói các giá trị đó vào các hàm kết hợp của riêng bạn, trực tiếp đặt các giá trị mà bạn muốn thay đổi và cho thấy các giá trị khác dưới dạng tham số cho hàm chứa kết hợp:

@Composable
fun ExtendedButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit
) {
    Button(
        colors = ButtonDefaults.buttonColors(
            backgroundColor = ExtendedTheme.colors.tertiary,
            contentColor = ExtendedTheme.colors.onTertiary
            /* Other colors use values from MaterialTheme */
        ),
        onClick = onClick,
        modifier = modifier,
        content = content
    )
}

Sau đó, bạn cần thay thế việc sử dụng Button bằng ExtendedButton khi thích hợp.

@Composable
fun ExtendedApp() {
    ExtendedTheme {
        /*...*/
        ExtendedButton(onClick = { /* ... */ }) {
            /* ... */
        }
    }
}

Thay thế hệ thống Material

Thay vì mở rộng chủ đề Material, bạn cũng có thể thay thế một hoặc nhiều hệ thống — Colors, Typography hoặc Shapes — bằng một phương thức triển khai tuỳ chỉnh, trong khi vẫn duy trì các hệ thống khác.

Giả sử bạn muốn thay thế các hệ thống kiểu (type) và hình dạng (shape) trong khi vẫn giữ hệ thống màu (color):

@Immutable
data class ReplacementTypography(
    val body: TextStyle,
    val title: TextStyle
)

@Immutable
data class ReplacementShapes(
    val component: Shape,
    val surface: Shape
)

val LocalReplacementTypography = staticCompositionLocalOf {
    ReplacementTypography(
        body = TextStyle.Default,
        title = TextStyle.Default
    )
}
val LocalReplacementShapes = staticCompositionLocalOf {
    ReplacementShapes(
        component = RoundedCornerShape(ZeroCornerSize),
        surface = RoundedCornerShape(ZeroCornerSize)
    )
}

@Composable
fun ReplacementTheme(
    /* ... */
    content: @Composable () -> Unit
) {
    val replacementTypography = ReplacementTypography(
        body = TextStyle(fontSize = 16.sp),
        title = TextStyle(fontSize = 32.sp)
    )
    val replacementShapes = ReplacementShapes(
        component = RoundedCornerShape(percent = 50),
        surface = RoundedCornerShape(size = 40.dp)
    )
    CompositionLocalProvider(
        LocalReplacementTypography provides replacementTypography,
        LocalReplacementShapes provides replacementShapes
    ) {
        MaterialTheme(
            /* colors = ... */
            content = content
        )
    }
}

// Use with eg. ReplacementTheme.typography.body
object ReplacementTheme {
    val typography: ReplacementTypography
        @Composable
        get() = LocalReplacementTypography.current
    val shapes: ReplacementShapes
        @Composable
        get() = LocalReplacementShapes.current
}

Sử dụng thành phần Material

Khi một hoặc nhiều hệ thống của MaterialTheme được thay thế, việc sử dụng các thành phần Material nguyên gốc có thể dẫn đến các giá trị, kiểu hoặc hình dạng Material không mong muốn.

Nếu bạn muốn sử dụng giá trị thay thế trong các thành phần, hãy gói các giá trị đó vào các hàm kết hợp của riêng bạn, trực tiếp đặt các giá trị phù hợp với hệ thống và cho thấy các giá trị khác dưới dạng tham số cho hàm chứa kết hợp:

@Composable
fun ReplacementButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit
) {
    Button(
        shape = ReplacementTheme.shapes.component,
        onClick = onClick,
        modifier = modifier,
        content = {
            ProvideTextStyle(
                value = ReplacementTheme.typography.body
            ) {
                content()
            }
        }
    )
}

Sau đó, bạn cần thay thế việc sử dụng Button bằng ReplacementButton khi thích hợp.

@Composable
fun ReplacementApp() {
    ReplacementTheme {
        /*...*/
        ReplacementButton(onClick = { /* ... */ }) {
            /* ... */
        }
    }
}

Triển khai hệ thống thiết kế tuỳ chỉnh hoàn toàn

Bạn cũng có thể thay thế chủ đề Material bằng một hệ thống thiết kế tuỳ chỉnh hoàn toàn. Hãy cân nhắc việc MaterialTheme cung cấp các hệ thống sau đây:

  • Colors, TypographyShapes: Hệ thống giao diện Material
  • ContentAlpha: Độ mờ để thể hiện sự nhấn mạnh trong TextIcon
  • TextSelectionColors: Màu sắc được TextTextField sử dụng để chọn văn bản
  • RippleRippleTheme: Cách triển khai của Material cho Indication

Nếu muốn tiếp tục sử dụng các thành phần Material, bạn cần thay thế một số hệ thống này trong giao diện hoặc giao diện tuỳ chỉnh của bạn hoặc xử lý các hệ thống trong thành phần của bạn để tránh hành vi không mong muốn.

Tuy nhiên, hệ thống thiết kế không giới hạn ở các khái niệm mà Material dựa trên đó. Bạn có thể sửa đổi các hệ thống hiện có và đưa ra các hệ thống hoàn toàn mới – với các lớp (class) và kiểu (type) mới – để các khái niệm khác tương thích với giao diện của mình.

Trong mã sau, chúng tôi mô hình hoá một hệ thống màu tuỳ chỉnh bao gồm các màu chuyển màu (List<Color>) (bao gồm cả một hệ thống kiểu) cho thấy một hệ thống nâng (elevation) mới và loại trừ các hệ thống khác do MaterialTheme cung cấp:

@Immutable
data class CustomColors(
    val content: Color,
    val component: Color,
    val background: List<Color>
)

@Immutable
data class CustomTypography(
    val body: TextStyle,
    val title: TextStyle
)

@Immutable
data class CustomElevation(
    val default: Dp,
    val pressed: Dp
)

val LocalCustomColors = staticCompositionLocalOf {
    CustomColors(
        content = Color.Unspecified,
        component = Color.Unspecified,
        background = emptyList()
    )
}
val LocalCustomTypography = staticCompositionLocalOf {
    CustomTypography(
        body = TextStyle.Default,
        title = TextStyle.Default
    )
}
val LocalCustomElevation = staticCompositionLocalOf {
    CustomElevation(
        default = Dp.Unspecified,
        pressed = Dp.Unspecified
    )
}

@Composable
fun CustomTheme(
    /* ... */
    content: @Composable () -> Unit
) {
    val customColors = CustomColors(
        content = Color(0xFFDD0D3C),
        component = Color(0xFFC20029),
        background = listOf(Color.White, Color(0xFFF8BBD0))
    )
    val customTypography = CustomTypography(
        body = TextStyle(fontSize = 16.sp),
        title = TextStyle(fontSize = 32.sp)
    )
    val customElevation = CustomElevation(
        default = 4.dp,
        pressed = 8.dp
    )
    CompositionLocalProvider(
        LocalCustomColors provides customColors,
        LocalCustomTypography provides customTypography,
        LocalCustomElevation provides customElevation,
        content = content
    )
}

// Use with eg. CustomTheme.elevation.small
object CustomTheme {
    val colors: CustomColors
        @Composable
        get() = LocalCustomColors.current
    val typography: CustomTypography
        @Composable
        get() = LocalCustomTypography.current
    val elevation: CustomElevation
        @Composable
        get() = LocalCustomElevation.current
}

Sử dụng thành phần Material

Khi không có MaterialTheme, việc sử dụng các thành phần Material nguyên trạng sẽ dẫn đến các giá trị màu, kiểu và hình dáng Material không mong muốn cũng như hành vi chỉ báo không mong muốn.

Nếu bạn muốn sử dụng giá trị tuỳ chỉnh trong các thành phần, hãy gói các giá trị đó vào các hàm kết hợp của riêng bạn, trực tiếp đặt các giá trị phù hợp với hệ thống và cho thấy các giá trị khác dưới dạng tham số cho hàm chứa kết hợp:

Bạn nên truy cập vào các giá trị mà bạn đặt qua giao diện tuỳ chỉnh của mình. Ngoài ra, nếu giao diện của bạn không cung cấp Color, TextStyle, Shape hoặc các hệ thống khác, thì bạn có thể mã hoá cứng các giao diện đó.

@Composable
fun CustomButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit
) {
    Button(
        colors = ButtonDefaults.buttonColors(
            backgroundColor = CustomTheme.colors.component,
            contentColor = CustomTheme.colors.content,
            disabledBackgroundColor = CustomTheme.colors.content
                .copy(alpha = 0.12f)
                .compositeOver(CustomTheme.colors.component),
            disabledContentColor = CustomTheme.colors.content
                .copy(alpha = ContentAlpha.disabled)
        ),
        shape = ButtonShape,
        elevation = ButtonDefaults.elevation(
            defaultElevation = CustomTheme.elevation.default,
            pressedElevation = CustomTheme.elevation.pressed
            /* disabledElevation = 0.dp */
        ),
        onClick = onClick,
        modifier = modifier,
        content = {
            ProvideTextStyle(
                value = CustomTheme.typography.body
            ) {
                content()
            }
        }
    )
}

val ButtonShape = RoundedCornerShape(percent = 50)

Nếu bạn đã ra mắt các loại lớp mới – chẳng hạn như List<Color> để đại diện cho chuyển màu (gradient) – thì tốt nhất bạn nên triển khai các thành phần từ đầu thay vì gói các thành phần đó. Ví dụ: hãy xem JetsnackButton trong mẫu Jetsnack.