Jetpack-Compose 学习笔记(六)——Compose 主题 Theme 一探究竟,换肤还能如此 Easy & Silky?

1,401 阅读13分钟

断更一时爽,一直断更一直爽~ 哈哈哈,就当给自己放了个长假吧。最近的行情太糟了,身边有同学已经被毕业,两个多月终于降薪找到下家··· 这里呼吁大家一定要存好六个月没有工作还能正常生活的银子,以备不时之需!希望疫情能早日平息,经济可以快速恢复吧~

自己也没想到这个系列可以到第六篇,断更确实很久了,居然还收到了小伙伴的催更,感谢你们的不离不弃。闲话少说,我们这次要介绍的是 Compose 主题,那么 Compose 主题 Theme 到底有什么?用 Compose 实现换肤简单吗?一起来看看吧!

Jetpack Compose 的主题 Theme 就是一套 UI 风格,其中包括字体、字号、色值等等,类比于 Android View 体系中的 Theme.MaterialComponents.DayNight.DarkActionBar等等的主题样式。与 View 体系最大的不同在于,它完全抛弃了 xml 文件的设置,所有样式都是通过代码设置的,主题样式大体可以分为 色值、文案样式、形状样式 三大类。先来看看主题中的色值。

1. Color 色值

许多组件不仅支持设置它自己的背景色,还可以设置它包含的其他可组合项的默认色值,使用 contentColorFor方法就可以实现。例如下面 code 1:

// code 1
Surface (color = Color.Yellow,contentColor = Color.Red) {
    Text(text = "July 2021",style = typography.body2)
}

你会发现,Surface的背景色为黄色,而 Text中文案为 红色,如果将 Text换为 Icon,那么 Icon的色调也会变为红色,感兴趣的同学可以试试。

类似 Surface的还有 TopAppBar可组合项,下面是它们的实现源码:

// code 2
Surface(
  color: Color = MaterialTheme.colors.surface,
  contentColor: Color = contentColorFor(color),
  ...

TopAppBar(
  backgroundColor: Color = MaterialTheme.colors.primarySurface,
  contentColor: Color = contentColorFor(backgroundColor),
  ...

Compose 官方推荐使用 Surface来给任何可组合项设置颜色,因为它会设置适当的内容颜色 CompositionLocal值,看 code 2 中 Surfacecolor属性就默认设置了 MaterialTheme.colors.surface色值。不推荐直接调用 Modifier.background设置颜色,因为它并没有设置任何的默认色值。在实际开发中,其实咱也没咋用到 MaterialTheme,所以这里还是看个人吧~

// code 3
-Row(Modifier.background(MaterialTheme.colors.primary)) {    // 不推荐
+Surface(color = MaterialTheme.colors.primary) {    // 推荐
+  Row(
...

在可组合项中,一些 UI 的参数是有默认值的,比如 Alpha 透明度、ContentColor 内容色等。我们可以使用CompositionLocalProvider类去自定义这些属性的默认值。比如:

// code 4
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) {
    Text(text = "Hello, 修之竹~")
}

对比没有加 CompositionLocalProvider的情况,会发现文案颜色更浅。这是因为,默认情况下 Text文案的 alpha值为 ContentAlpha.high,这里设置为 ContentAlpha.disabled,还有一个 ContentAlpha.mediumalpha值的大小排序为:high > medium > disabled。具体的值可以查看源码,它还分了高对比度和低对比度两种情况。

Compose 在暗夜模式支持方面也做的不错。比如,是否在浅色模式中运行的判断很简单:

// code 5
val isLightTheme = MaterialTheme.colors.isLight

此外,如果在实际中就是使用的 MaterialTheme中的色值来设置,那么需要注意的是,Compose 默认的可组合项中常见的情况是在浅色模式中将容器设为 primary色值,在暗夜模式中将其设为 surface色值,许多组件默认都是使用这种模式,例如TopAppBar(应用栏) 和 BottomNavigation(底部导航栏)。

2. 文案样式

文案样式也可以复用 MaterialTheme中已有的字体样式,当然也可以先将已有的样式 copy 一份,然后修改其中的某些属性。比如可以修改字间距:

// code 6
    Text(
        text = "Hello, 修之竹~",
        // style = MaterialTheme.typography.body1    // 复用 MaterialTheme 中的字体样式
        style = MaterialTheme.typography.body1.copy(    // copy 已有样式并修改字间距属性的值
             letterSpacing = 5.sp
        ),
        fontSize = 20.sp    // 在Text中设置 fontSize 可重写覆盖 MaterialTheme.typography.body1 TextStyle 中的字体大小
    )

2.1 AnnotatedString 类来设置多种样式

AnnotatedString用来代替 SpannableString最好不过了,因为它真的比 SpannableString好用多了!再也不用担心使用 SpannableString引发的数组越界问题了。代码及效果如下,当然还可以实现许多其他的文案样式,感兴趣的同学可以自行查阅 SpanStyle的官方文档。

// code 7
val annotatedString = buildAnnotatedString {
    withStyle(SpanStyle(color = Color.Red, fontWeight = FontWeight.Bold)) {
        append("Kotlin ")
    }
    append("是世上 ")
    withStyle(SpanStyle(fontSize = 24.sp)) {
        append("最好的语言")
    }
}
Text(text = annotatedString)

图 1 SpanStyle是设置文案的样式的,作用于字符单位;而如果要针对文案的行高、对齐方式等进行设置,则需要使用ParagraphStyle,顾名思义它是针对段落样式的。

3. 形状样式

MaterialTheme主题中也有 Shape形状属性,在许多的官方 Composable 组件中都有这个 Shape属性,比如 Button组件的 Shape属性默认值就是 MaterialTheme.shapes.small

// code 8
fun Button(
    ···
    shape: Shape = MaterialTheme.shapes.small,
    ···
) {
}

Shapes.kt提供了 smallmediumlarge3 种不同的属性值,其实都是 RoundedCornerShape的具体实现,只不过圆角的大小不太一样罢了,具体数值可查看源码。

如果需要在自定义 Composable 组件中使用 Shape,有两种方法:一是使用拥有 Shape属性的官方 Composable 组件;二是使用 Modifier中可设置 shape的方法去接收自定义 Composable 组件传进来的 Shape参数值。先来看看第一种方法,如 code 9 所示。

// code 9
@Composable
fun RoundedCornerImage(painter: Painter, cornerSize: Int) {
    Surface(
        shape = RoundedCornerShape(cornerSize.dp)
    ) {
        Image(
            painter = painter,
            contentDescription = "圆角图片"
        )
    }
}

这是个可以设置图片圆角大小的自定义 Composable 组件,因为需要用到 Shape设置圆角,所以使用了 Surface这个组件的 Shape 属性来具体实现。

第二种方法就是借助 Modifier的方法,比如 Modifier.clip(shape: Shape)Modifier.background(color: Color, shape: Shape = RectangleShape)Modifier.border(width: Dp, brush: Brush, shape: Shape)等等。比较简单,感兴趣的同学可以试试。

4. 切换主题

上面说了这么多,其实都是针对单个主题说的,在实际应用中,我们可以做个切换主题的小功能,如下图 2 所示:

图 2

其中包含了色值、字体、形状的切换,用到的思路和原理都是一样的,所以这里就只拿主题色值的切换来说明。想要实现这一功能,首先需要明白的是,点击事件之后切换主题的回调该怎么做?

总不能给所有设置色值的地方都设置一个监听器吧?那样做想想都觉得“酸爽”。其实,在 Compose 中,我们可以将当前主题用一个 MutableState对象来保存,然后将主题中的色值集合与这个状态相关联,当用户切换主题改变了这个 MutableState值之后,与之关联的色值集合就会收到回调进行切换,同时通知 Compose 进行重组,这样就使用新的色值集合进行渲染了。

关于 MutableState状态的相关知识,可以查阅我的另一篇文章:Jetpack-Compose 学习笔记(五)—— State 状态是个啥?又是新概念?

OK,整体的思路有了,咱们再详细看看具体是如何实现的。按照之前的分析,我们需要在每次渲染页面的时候读取当前主题的值,所以,首先得先获取当前的主题值。我这里是使用 MMKV存储当前主题值,主题值是 String类型,如下 code 10 所示:

// code 10
    //获取选中的主题 id
    val chosenThemeId = remember {
        mutableStateOf(
            MMKV.defaultMMKV().getString(MMKVConstant.ChosenThemeCode, ThemeKinds.DEFAULT.name)
                ?: ThemeKinds.DEFAULT.name
        )
    }
    
enum class ThemeKinds {
    DEFAULT,    //默认主题
    RED,    //红色主题
    YELLOW,    //黄色主题
    BLUE    //蓝色主题
}

然后自定义主题,在这里需要规定主题用到的色值、文案样式、形状样式等。在每次切换主题后,在这里还需要根据传入的当前主题值,设置相应的色值组等等。详细如下代码:

// code 11
@Composable
fun CustomTheme(
    chosenThemeId: MutableState<String>,
    content: @Composable () -> Unit
) {
    //自定义主题色值
    val colors = when (chosenThemeId.value) {
        ThemeKinds.DEFAULT.name -> {
            LightColors
        }
        ThemeKinds.RED.name -> {
            RedThemeColors
        }
        ThemeKinds.YELLOW.name -> {
            YellowThemeColors
        }
        ThemeKinds.BLUE.name -> {
            BlueThemeColors
        }
        else -> {
            DarkColors
        }
    }

    MaterialTheme(
        colors = colors,
        typography = typography,
        shapes = shapes
    ) {
        content()
    }
}

//红色主题色值
private val RedThemeColors = lightColors(
    primary = Color(0xFFFF4040),
    background = Color(0x66FF4040)
)

//黄色主题色值
private val YellowThemeColors = lightColors(
    primary = Color(0xFFDAA520),
    background = Color(0x66FFD700)
)

//蓝色主题色值
private val BlueThemeColors = lightColors(
    primary = Color(0xFF436EEE),
    background = Color(0x6600FFFF)
)

private val DarkColors = darkColors(
    primary = Color.White,
    primaryVariant = Red700,
    onPrimary = Color.Black,
    secondary = Red300,
    onSecondary = Color.Black,
    error = Red200
)

private val LightColors = lightColors(
    primary = Color.Black,
    primaryVariant = Red900,
    onPrimary = Color.White,
    secondary = Red700,
    secondaryVariant = Red900,
    onSecondary = Color.White,
    error = Red800,
)

可以看到,在我们自定义的主题 CustomTheme最后,还是使用的 MaterialTheme,只不过将官方的 MaterialThemecolors设置成了我们自己的 colors,同理,我们还可以设置文案 typography和 形状 shapes等参数。

其实,所谓的色值组就是一个 Colors对象,Compose 中默认就有 lightColorsdarkColors两种 Colors对象,分别用于暗夜模式和白天模式的主题色值的设置,我们这里统一是以白天模式的 lightColors对象为基准来进行其他主题色值的设置,作为例子这里就重写了 primarybackground两个属性,分别用来设置文案色值和背景色的色值。

定义好自定义主题中的各个色值组后,别忘了最后还是要设置到 MaterialTheme中的 colors属性中,然后我们才可以通过调用 MaterialTheme colors来使用自定义主题中的各个色值。下面的代码就是使用样例:

// code 12
CustomTheme(chosenThemeId) {
        Surface(color = MaterialTheme.colors.background) {
            ···
        }
    }

所以,如果我们要新增一组色值,我们只需要在 CustomTheme中新增一组主题色值就可以了,不用去改动设置色值的代码,改动代码量较少。

再来看看切换主题的点击触发事件,显然是在这几个小方块里,而且每个方块代表一种主题,具体的代码如下:

// code 13
@Composable
fun ThemeColorCube(themeItem: ThemeItem, chosenThemeId: MutableState<String>, onClick: () -> Unit) {
    Surface(
        shape = RoundedCornerShape(10.dp),
        elevation = 5.dp,
        color = themeItem.mainColor,
        modifier = Modifier
            .size(85.dp)
            .padding(10.dp)
            .clickable {
                onClick()
            }
    ) {
        Row(
            horizontalArrangement = Arrangement.Center,
            verticalAlignment = Alignment.CenterVertically
        ) {
            if (themeItem.id.name == chosenThemeId.value) {
                Image(
                    modifier = Modifier.size(20.dp),
                    painter = painterResource(id = R.drawable.ic_checkbox_selected_gray),
                    contentScale = ContentScale.FillBounds,
                    contentDescription = "被选中标记图"
                )
            } else {
                Text(
                    text = themeItem.name,
                    textAlign = TextAlign.Center,
                    style = TextStyle(color = MaterialTheme.colors.primary)
                )
            }
        }
    }
}

data class ThemeItem(
    val id: ThemeKinds,    //主题 id
    val name: String,    //主题 name
    val mainColor: Color,    //主色
)

点击事件的回调在主页面 LazyRow列表的方法中:

// code 14
LazyRow() {
    items(themeList) { item: ThemeItem ->
        ThemeColorCube(themeItem = item, chosenThemeId) {
            //点击色块选择其中的一种颜色
            MMKV.defaultMMKV().putString(MMKVConstant.ChosenThemeCode, item.id.name)
            chosenThemeId.value = item.id.name
        }
    }
}

可以看到,点击之后,需要将选中的主题 id存储在本地,以便下次打开 App 可以获取到选中的主题并设置相应的主题色值组,更为重要的是更新 MutableState对象,即通过 CustomTheme传进来的 chosenThemeId的值。由于 MutableState的特性,所有引用它的地方,都会触发重组,从而会使得 CustomTheme重组,重组会根据到更新后的 chosenThemeId的值来设置色值组,那么 MaterialTheme.colors的色值组就切换为新选中主题的色值组了。

另外文案字体和大小,以及图片的圆角大小,都是类似的原理,不再赘述,文末见源码获取方法。

5. 彩蛋 —— 切换主题进阶版

这就完了么?作为主题切换功能来讲,已经实现完了,但,刚刚的切换过程是不是感觉比较生硬?有没有更加丝滑的做法?答案当然是有的。 图 3 如图3 所示,每次切换时,背景色和字体大小、圆角大小都是渐变的,切换过程丝滑,过渡自然。

要想实现丝滑的效果,先得认识一位新的朋友:animateXxxAsState。

5.1 animateXxxAsState

看前缀就知道是为动画而生的,Xxx 是因为它有许多重载的参数方法,比如 Color、Dp、Float 等,我们这里色值的渐变就是用到的 animateColorAsState方法。同样地,文案字体大小的动画以及圆角的动画,分别使用的是 animateFloatAsStateanimateDpAsState方法。

这一类方法非常好用,官方文档上是这么介绍 animateColorAsState方法的:

Fire-and-forget animation function for Color.

只需要触发调用它即可,不用管其他的事情。这里只对 animateColorAsState方法进行举例说明,其他方法以此类推。先来看看它的声明:

// code 15
@Composable
fun animateColorAsState(
    targetValue: Color,
    animationSpec: AnimationSpec<Color> = colorDefaultSpring,
    finishedListener: ((Color) -> Unit)? = null
): State<Color>

第一个参数就是设置色值渐变的终值,一旦设置的终值改变,渐变的动画就会自动触发。当动画还未结束终值又有变化时,则动画会调整动画路径到新的终值。

第二个参数可以设置动画的执行规范,实现了 AnimationSpec接口的有 1)FloatSpringSpec;2)FloatTweenSpec;3)InfiniteRepeatableSpec;4)KeyframesSpec;5)RepeatableSpec;6)SnapSpec;7)SpringSpec;8)TweenSpec. 这些都是针对动画进行的设置,例如动画时间,以及动画速度的变化,类似于插值器。

第三个参数就很好理解了,即动画完成后的回调方法。

返回值是一个 State状态对象,所以它可以不断地去更新值,直至动画完成。

需要注意的是,只要动画所作用的可组合项没有从 Compose 组件树上被移除,那么这个动画方法不会被取消或被停止。

5.2 Color 渐变实现

从上一节可以得知,animateColorAsState方法返回的是个 State状态,我们需要这个返回值去重组更新调用了该色值的 Composable 组件,所以,每种需要渐变的色值都需要声明一个 State状态对象,我这里统一都放在 ViewModel中管理了:

// code 16
class MainViewModel : ViewModel() {
    var primaryColor: Color by mutableStateOf(Color(0xFF000000)) // 用于文案色值渐变
    var backgroundColor: Color by mutableStateOf(Color(0xFFFFFFFF)) // 用于背景色渐变
    ···
    val chosenThemeId = mutableStateOf(
        MMKV.defaultMMKV().getString(MMKVConstant.ChosenThemeCode, ThemeKinds.DEFAULT.name)
            ?: ThemeKinds.DEFAULT.name
    )
}

当切换主题后,主题 id 存储的 MutableState触发重组,然后根据新的主题 id 获取到新的色值组,这时 animateColorAsState中的 targetValue就发生了变化,触发渐变动画,从而不断更新 ViewModel中的 primaryColorState 值,进而重组所有引用了 primaryColor值的可组合项,这时渐变效果出现。下面是 CustomTheme部分代码:

// code 17
    val targetColors: AppColors
    if (isSystemInDarkTheme()) {
        //如果是深色模式,则只能是深色模式的色值组,无法切换
        targetColors = DarkColors
    } else {
        targetColors = when (mainViewModel.chosenThemeId.value) {
            ThemeKinds.RED.name -> {
                RedThemeColors
            }
            ThemeKinds.YELLOW.name -> {
                YellowThemeColors
            }
            ThemeKinds.BLUE.name -> {
                BlueThemeColors
            }
            else -> {
                DefaultColors
            }
        }
    }
    //渐变实现
    mainViewModel.primaryColor = animateColorAsState(targetColors.primary, TweenSpec(500)).value
    mainViewModel.backgroundColor = animateColorAsState(targetColors.background, TweenSpec(500)).value

这里设置的渐变时长为 500ms,并且为了方便管理,将所有色值放在 AppColors类中进行管理,各个不同的主题有着各自不同的 AppColors类对象,如下所示:

// code 18
@Stable
data class AppColors (
    val primary: Color,
    val background: Color
)

//红色主题色值
private val RedThemeColors = AppColors(
    primary = Color(0xFFFF4040),
    background = Color(0x66FF4040)
)

//黄色主题色值
private val YellowThemeColors = AppColors(
    primary = Color(0xFFDAA520),
    background = Color(0x66FFD700)
)

至于圆角大小以及文字大小的渐变,都是一样的实现方法,就是需要在 ViewModel中定义需要的 MutableState状态对象,然后使用相应的 animateXxxAsState进行渐变动画的实现即可。

碎碎念:其实 Compose 官方教程中的 Theme 主题内容不多,且比较简单,所以就想借着主题切换的功能来巩固和运用这一知识点,希望大家能够学有所得~ 如有问题欢迎留言探讨~

如需文中源码,请在公众号回复:Compose换肤

赞人玫瑰,手留余香!欢迎点赞、转发~ 转发请注明出处~

更多内容,欢迎关注公众号:修之竹

参考文献

  1. Compose主题切换——让你的APP也能一键换肤;Zhujiang https://juejin.cn/post/7070671629713408031
  2. Android Jetpack Compose 实现主题切换(换肤);九狼 https://juejin.cn/post/7057418707357663246
  3. Jetpack Compose - animateXxxAsState;乐翁龙 https://blog.csdn.net/u010976213/article/details/114488661

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿