Compose 主题定制

1,401 阅读7分钟

Compose 默认提供了 Material Design 的主题实现,基于这个主题可以实现 Google 亲儿子般的 UI:

material-design.png

Material 主题

Compose 的架构分层来看,Material 的实现处于最上层,也就是说如果脱离 androidx.compose.material.*, Compose 也是一样可以用的。

WeCom20211224-154433@2x.png

ComposeMaterialTheme 的定义:

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

颜色 - Color

Google 说强烈建议使用 Compose 中的 Color 类来定义和管理颜色,这样可以轻松支持主题的定制,甚至还能把主题嵌套起来用。

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

例如:定义一个支持深色和浅色的主题:

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,
    // ...
)

应用到 MaterialTheme

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

使用:

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

深色主题

通过向 MaterialTheme 提供一组不同的 colors 来实现深浅两种主题:

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

isSystemInDarkTheme() 会查找设备当前是否为深色主题。

字体排版 - Typography

theme-typefaces.png

MaterialTheme 采用 TypographyTextStyle 类实现默认的字体字型定义,构造MaterialTheme时传入:

val Rubik = FontFamily(
    Font(R.font.rubik_regular),
    Font(R.font.rubik_medium, FontWeight.W500),
    Font(R.font.rubik_bold, FontWeight.Bold)
)

val MyTypography = Typography(
    h1 = TextStyle(
        fontFamily = Rubik,
        fontWeight = FontWeight.W300,
        fontSize = 96.sp
    ),
    body1 = TextStyle(
        fontFamily = Rubik,
        fontWeight = FontWeight.W600,
        fontSize = 16.sp
    )
    /*...*/
)
MaterialTheme(typography = MyTypography, /*...*/)

然后通过 MaterialTheme.typrography 访问对应 TextStyle即可:

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

形状 - Shapes

theme-shapes.png

Compose 使用 Shapes 实现形状系统,可以设置每个大小类别的形状默认状态:

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, /*...*/)

MaterialTheme 认为默认情况下组件使用的形状基本上符合这几个大小状态,比如ButtonText 等默认为 “小”,AlertDialog 默认为“中”, Drawer 为 “大”

使用:

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

Material Design 3(下一代 Material Design)

为了适配下一代Material DesignCompose 已经开始实现 Material Design 3, 包含更新的主题和组件,以及对Material You的个性风格的支持。

注意

  • androidx.compose.material3 目前为 Alpha 版。此 API 随时可能发生变化,因此不应视为最终版本。

  • “Material Design 3”、“Material 3”和“M3”这三个术语可以互换。现有的 Material Design 规范和相应的 androidx.compose.material 库称为“Material Design 2”、“Material 2”或“M2”。

M3 主题定义了新的配置方案和文字排版单元,目前还没有对形状的支持。但是官方说 Updates to shapes coming soon

@Composable
fun MaterialTheme(
    colorScheme: ColorScheme? = MaterialTheme.colorScheme,
    typography: Typography? = MaterialTheme.typography,
    content: (@Composable () -> Unit)?
): Unit	

M3 中的 ColorScheme

M2 中的 Color 变成了 ColorScheme,同样以深色浅色举例:

private val Blue40 = Color(0xff1e40ff)
private val DarkBlue40 = Color(0xff3e41f4)
private val Yellow40 = Color(0xff7d5700)
// Remaining colors from tonal palettes

private val LightColorScheme = lightColorScheme(
    primary = Blue40,
    secondary = DarkBlue40,
    tertiary = Yellow40,
    // error, primaryContainer, onSecondary, etc.
)
private val DarkColorScheme = darkColorScheme(
    primary = Blue80,
    secondary = DarkBlue80,
    tertiary = Yellow80,
    // error, primaryContainer, onSecondary, etc.
)
// 使用:
val darkTheme = isSystemInDarkTheme()
MaterialTheme(
    colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
) {
    // M3 app content
}

M3 中使用的“primary”、“background”和“error”等等这些值有了”插槽“的概念,他们组合在一起形成一个配色方案,每个槽位的颜色获取来自”[色调模板](Color system – Material Design 3)“,

Material3 色调模板和配色方案

在Material You中使用动态配色方案

Material You 是在 Android 12 中引入的概念,以用户壁纸为原型派生自定义颜色,并将其应用到系统。M3中的动态配色方案以此为起点:

// Dynamic color is available on Android 12+
val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val colorScheme = when {
    dynamicColor && darkTheme -> dynamicDarkColorScheme(LocalContext.current)
    dynamicColor && !darkTheme -> dynamicLightColorScheme(LocalContext.current)
    darkTheme -> DarkColorScheme
    else -> LightColorScheme
}
// 使用:
Text(
    text = "Hello M3 theming",
    color = MaterialTheme.colorScheme.tertiary
)

M3 中的 Typography

M3 中用了新的 Typography 和已有的 TextStyle, 新增了字体比例的定义,同时命名和分组简化为: 显示、大标题、标题、正文和标签,每个都有大号、中号和小号。

Material 3 字体比例与 Material 2 字体比例

val KarlaFontFamily = FontFamily(
    Font(R.font.karla_regular),
    Font(R.font.karla_bold, FontWeight.Bold)
)

val AppTypography = Typography(
    bodyLarge = TextStyle(
        fontFamily = KarlaFontFamily,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp,
        lineHeight = 24.sp,
        letterSpacing = 0.15.sp
    ),
    // titleMedium, labelSmall, etc.
)

MaterialTheme(
    typography = AppTypography
) {
    // M3 app content
}

// 使用:
Text(
    text = "Hello M3 theming",
    style = MaterialTheme.typography.bodyLarge
)

自定义主题

MaterialTheme 的设计看上去在 UI 层面考虑已经比较全面了,但是回到现实世界问题依然很明显:很多情况下依然无法满足设计上的需求与多样化。我们需要比现成的Material Theme更复杂的主题。 在实际开发中更方便的做法是基于 Material Design 之上对主题进行自定义。

扩展 Material

一种比较常见的情况是尽管主题中已经提供了很多颜色,但是依然无法满足我们的需求,如果我们的使用需求与MaterialThemeAPI是一致的,只是不够丰富,那么我们对已有的Colors进行扩展即可:

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

同样的情况也适用于 Typography Shapes

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

基于 CompositionLocal 扩展 Material

CompositionLocal 可以在 Composable 中以某个节点开始将数据“向下”传递到每一个子节点。用 Google 官方的话说就是将数据的作用域限定在局部。 为什么要这样用呢?

对于广泛使用的常用数据,在Composable 中显示的传递是一个非常麻烦的过程,特别是这种主题相关的数据:

@Composable
fun MyApp() {
    // Theme information tends to be defined near the root of the application
    val colors = …
}

// Some composable deep in the hierarchy
@Composable
fun SomeTextLabel(labelText: String) {
    Text(
        text = labelText,
        color = // ← need to access colors here
    )
}

为了让 colors 无需显式传递给大多数子节点ComposableCompose 提供 CompositionLocal 来创建以树为作用域的对象,它通常在界面树的某个节点以值的形式提供,这个值可以在子节点中使用,而无需将这个对象声明为参数。

MaterialTheme ColorTypographyShapes 就是基于这个组件实现的,对应 LocalColorsLocalShapesLocalTypography 属性。

CompositionLocal 实例的作用域限定在Composable中,因此可以在树的不同级别提供不同的值。如果需要更新CompositionLocal 提供新值,需使用 CompositionLocalProviderinfix 函数 providerComposable 作为 CompositionLocalProvidercontent lambda,会获取 CompositionLocalcurrent 以达到在任意节点读取新值的目的。

例如: LocalContentAlpha CompositionLocal 的值是给作用域内的文本和图标提供当前主题定义的 Aplha。强调或弱化界面不同部分,在其内部的Composable 在获取LocalContentAlpha时,就会获取到最近一次提供的新的值,如下示例 CompositionLocalProvider 用于为 Composable 的不同部分提供不同的值:

@Composable
fun CompositionLocalExample() {
    MaterialTheme { // MaterialTheme sets ContentAlpha.high as default
        Column {
            Text("Uses MaterialTheme's provided alpha")
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("Medium value provided for LocalContentAlpha")
                Text("This Text also uses the medium value")
                CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) {
                    DescendantExample()
                }
            }
        }
    }
}

@Composable
fun DescendantExample() {
    // CompositionLocalProviders also work across composable functions
    Text("This Text uses the disabled alpha now")
}

// Text Composable 的内部实现:
@Composable
fun Text(
    text: String,
    modifier: Modifier = Modifier,
    color: Color = Color.Unspecified,
    ...
) {

    val textColor = color.takeOrElse {
        style.color.takeOrElse {
          // 读取 colorAlpha
          LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
        }
    }
    
    ...
}

compositionlocal-alpha.png

由此引入另一种方式: 封装 MaterialTheme, 保留其原本的设定的情况下,扩展出其它值。

例如颜色:

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

应用到 Material 组件:

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

替换 Material 系统

MaterialTheme 中的一个或多个单元(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组件:

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

实现完全自定义主题系统

设计并非仅限于 Material Design,完全自主定义一套设计语言也是可行的。 正如最开始所说,如果整个系统完全不依赖Material层,也是完全可行的。

如下示例就实现了一套跟Material Design 一丝儿关系都没有的主题。

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