在 Compose 中创建自定义设计系统

虽然 Material 是我们推荐的设计系统,Jetpack Compose 附带了 Material 的实现,但您不必使用它。Material 完全基于公共 API 构建,因此您可以以相同的方式创建自己的设计系统。

您可以采用几种方法

您可能还想在自定义设计系统中继续使用 Material 组件。这样做是可行的,但需要注意一些事项以适应您采用的方法。

要了解有关 MaterialTheme 和自定义设计系统使用的低级别结构和 API 的更多信息,请查看 Compose 中主题的解剖 指南。

扩展 Material 主题

Compose Material 紧密地模仿了 Material 主题,以便简单且类型安全地遵循 Material 指南。但是,您可以使用其他值扩展颜色、排版和形状集。

最简单的方法是添加扩展属性

// Use with MaterialTheme.colorScheme.snackbarAction
val ColorScheme.snackbarAction: Color
    @Composable
    get() = if (isSystemInDarkTheme()) 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)

这为 MaterialTheme 使用 API 提供了一致性。Compose 本身定义的一个示例是 surfaceColorAtElevation,它根据高度确定应使用的表面颜色。

另一种方法是定义一个扩展主题,它“包装”MaterialTheme 及其值。

假设您要添加两种额外的颜色 — cautiononCaution,一种用于半危险操作的黄色 — 同时保留现有的 Material 颜色

@Immutable
data class ExtendedColors(
    val caution: Color,
    val onCaution: Color
)

val LocalExtendedColors = staticCompositionLocalOf {
    ExtendedColors(
        caution = Color.Unspecified,
        onCaution = Color.Unspecified
    )
}

@Composable
fun ExtendedTheme(
    /* ... */
    content: @Composable () -> Unit
) {
    val extendedColors = ExtendedColors(
        caution = Color(0xFFFFCC02),
        onCaution = Color(0xFF2C2D30)
    )
    CompositionLocalProvider(LocalExtendedColors provides extendedColors) {
        MaterialTheme(
            /* colors = ..., typography = ..., shapes = ... */
            content = content
        )
    }
}

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

这与 MaterialTheme 使用 API 类似。它还支持多个主题,因为您可以像使用 MaterialTheme 一样嵌套 ExtendedTheme

使用 Material 组件

扩展 Material 主题时,会保留现有的 MaterialTheme 值,Material 组件仍然具有合理的默认值。

如果您要在组件中使用扩展值,请将它们包装在您自己的可组合函数中,直接设置要更改的值,并将其他值作为参数公开给包含的可组合函数

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

然后,您可以在适当的位置将 Button 的使用替换为 ExtendedButton

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

替换 Material 子系统

您可能不想扩展 Material 主题,而是要替换一个或多个系统 — ColorsTypographyShapes — 使用自定义实现,同时保留其他系统。

假设您要替换类型和形状系统,同时保留颜色系统

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

使用 Material 组件

当替换 MaterialTheme 的一个或多个系统时,直接使用 Material 组件可能会导致出现意外的 Material 颜色、类型或形状值。

如果您要在组件中使用替换值,请将它们包装在您自己的可组合函数中,直接为相关系统设置值,并将其他值作为参数公开给包含的可组合函数。

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

然后,您可以在适当的位置将 Button 的使用替换为 ReplacementButton

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

实现完全自定义的设计系统

您可能希望使用完全自定义的设计系统替换 Material 主题。请注意 MaterialTheme 提供以下系统

  • ColorsTypographyShapes:Material 主题系统
  • TextSelectionColorsTextTextField 用于文本选择的颜色
  • RippleRippleThemeIndication 的 Material 实现

如果您想继续使用 Material 组件,则需要在自定义主题中替换其中一些系统,或者在组件中处理这些系统,以避免出现意外行为。

但是,设计系统并不局限于 Material 所依赖的概念。您可以修改现有系统并引入全新的系统 — 使用新类和类型 — 使其他概念与主题兼容。

在以下代码中,我们模拟了一个自定义颜色系统,该系统包括渐变 (List<Color>),包含一个类型系统,引入了一个新的高度系统,并排除了 MaterialTheme 提供的其他系统

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

使用 Material 组件

当不存在 MaterialTheme 时,直接使用 Material 组件会导致出现意外的 Material 颜色、类型和形状值以及指示行为。

如果您要在组件中使用自定义值,请将它们包装在您自己的可组合函数中,直接为相关系统设置值,并将其他值作为参数公开给包含的可组合函数。

我们建议您从自定义主题中访问您设置的值。或者,如果您的主题没有提供 ColorTextStyleShape 或其他系统,您可以将它们硬编码。

@Composable
fun CustomButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit
) {
    Button(
        colors = ButtonDefaults.buttonColors(
            containerColor = CustomTheme.colors.component,
            contentColor = CustomTheme.colors.content,
            disabledContainerColor = CustomTheme.colors.content
                .copy(alpha = 0.12f)
                .compositeOver(CustomTheme.colors.component),
            disabledContentColor = CustomTheme.colors.content
                .copy(alpha = 0.38f)
        ),
        shape = ButtonShape,
        elevation = ButtonDefaults.elevatedButtonElevation(
            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)

如果您引入了新的类类型 — 例如 List<Color> 用于表示渐变 — 那么最好从头开始实现组件,而不是包装它们。例如,请查看 Jetsnack 示例中的 JetsnackButton