
“断更一时爽,一直断更一直爽~ 哈哈哈,就当给自己放了个长假吧。最近的行情太糟了,身边有同学已经被毕业,两个多月终于降薪找到下家··· 这里呼吁大家一定要存好六个月没有工作还能正常生活的银子,以备不时之需!希望疫情能早日平息,经济可以快速恢复吧~
自己也没想到这个系列可以到第六篇,断更确实很久了,居然还收到了小伙伴的催更,感谢你们的不离不弃。闲话少说,我们这次要介绍的是 Compose 主题,那么 Compose 主题 Theme 到底有什么?用 Compose 实现换肤简单吗?一起来看看吧!
Jetpack Compose 的主题 Theme 就是一套 UI 风格,其中包括字体、字号、色值等等,类比于 Android View 体系中的 Theme.MaterialComponents.DayNight.DarkActionBar等等的主题样式。与 View 体系最大的不同在于,它完全抛弃了 xml 文件的设置,所有样式都是通过代码设置的,主题样式大体可以分为 色值、文案样式、形状样式 三大类。先来看看主题中的色值。
许多组件不仅支持设置它自己的背景色,还可以设置它包含的其他可组合项的默认色值,使用 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 中 Surface的 color属性就默认设置了 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.medium,alpha值的大小排序为:high > medium > disabled。具体的值可以查看源码,它还分了高对比度和低对比度两种情况。
Compose 在暗夜模式支持方面也做的不错。比如,是否在浅色模式中运行的判断很简单:
// code 5
val isLightTheme = MaterialTheme.colors.isLight
此外,如果在实际中就是使用的 MaterialTheme中的色值来设置,那么需要注意的是,Compose 默认的可组合项中常见的情况是在浅色模式中将容器设为 primary色值,在暗夜模式中将其设为 surface色值,许多组件默认都是使用这种模式,例如TopAppBar(应用栏) 和 BottomNavigation(底部导航栏)。
文案样式也可以复用 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 中的字体大小
)
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)

SpanStyle是设置文案的样式的,作用于字符单位;而如果要针对文案的行高、对齐方式等进行设置,则需要使用ParagraphStyle,顾名思义它是针对段落样式的。
MaterialTheme主题中也有 Shape形状属性,在许多的官方 Composable 组件中都有这个 Shape属性,比如 Button组件的 Shape属性默认值就是 MaterialTheme.shapes.small。
// code 8
fun Button(
···
shape: Shape = MaterialTheme.shapes.small,
···
) {
}
Shapes.kt提供了 small、medium、large3 种不同的属性值,其实都是 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)等等。比较简单,感兴趣的同学可以试试。
上面说了这么多,其实都是针对单个主题说的,在实际应用中,我们可以做个切换主题的小功能,如下图所示:

其中包含了色值、字体、形状的切换,用到的思路和原理都是一样的,所以这里就只拿主题色值的切换来说明。想要实现这一功能,首先需要明白的是,点击事件之后切换主题的回调该怎么做?
总不能给所有设置色值的地方都设置一个监听器吧?那样做想想都觉得“酸爽”。其实,在 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,只不过将官方的 MaterialTheme中 colors设置成了我们自己的 colors,同理,我们还可以设置文案 typography和 形状 shapes等参数。
其实,所谓的色值组就是一个 Colors对象,Compose 中默认就有 lightColors和 darkColors两种 Colors对象,分别用于暗夜模式和白天模式的主题色值的设置,我们这里统一是以白天模式的 lightColors对象为基准来进行其他主题色值的设置,作为例子这里就重写了 primary和 background两个属性,分别用来设置文案色值和背景色的色值。
定义好自定义主题中的各个色值组后,别忘了最后还是要设置到 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的色值组就切换为新选中主题的色值组了。
另外文案字体和大小,以及图片的圆角大小,都是类似的原理,不再赘述,文末见源码获取方法。
这就完了么?作为主题切换功能来讲,已经实现完了,但,刚刚的切换过程是不是感觉比较生硬?有没有更加丝滑的做法?答案当然是有的。

如上图所示,每次切换时,背景色和字体大小、圆角大小都是渐变的,切换过程丝滑,过渡自然。
要想实现丝滑的效果,先得认识一位新的朋友:animateXxxAsState。
看前缀就知道是为动画而生的,Xxx 是因为它有许多重载的参数方法,比如 Color、Dp、Float 等,我们这里色值的渐变就是用到的 animateColorAsState方法。同样地,文案字体大小的动画以及圆角的动画,分别使用的是 animateFloatAsState和 animateDpAsState方法。
这一类方法非常好用,官方文档上是这么介绍 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 组件树上被移除,那么这个动画方法不会被取消或被停止。
从上一节可以得知,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 主题内容不多,且比较简单,所以就想借着主题切换的功能来巩固和运用这一知识点,希望大家能够学有所得~ 如有问题欢迎留言探讨~
赞人玫瑰,手留余香!欢迎点赞、转发~ 转发请注明出处~