断更一时爽,一直断更一直爽~ 哈哈哈,就当给自己放了个长假吧。最近的行情太糟了,身边有同学已经被毕业,两个多月终于降薪找到下家··· 这里呼吁大家一定要存好六个月没有工作还能正常生活的银子,以备不时之需!希望疫情能早日平息,经济可以快速恢复吧~
自己也没想到这个系列可以到第六篇,断更确实很久了,居然还收到了小伙伴的催更,感谢你们的不离不弃。闲话少说,我们这次要介绍的是 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 中 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
(底部导航栏)。
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)
SpanStyle
是设置文案的样式的,作用于字符单位;而如果要针对文案的行高、对齐方式等进行设置,则需要使用ParagraphStyle
,顾名思义它是针对段落样式的。
3. 形状样式
MaterialTheme
主题中也有 Shape
形状属性,在许多的官方 Composable 组件中都有这个 Shape
属性,比如 Button
组件的 Shape
属性默认值就是 MaterialTheme.shapes.small
。
// code 8
fun Button(
···
shape: Shape = MaterialTheme.shapes.small,
···
) {
}
Shapes.kt
提供了 small
、medium
、large
3 种不同的属性值,其实都是 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 所示:
其中包含了色值、字体、形状的切换,用到的思路和原理都是一样的,所以这里就只拿主题色值的切换来说明。想要实现这一功能,首先需要明白的是,点击事件之后切换主题的回调该怎么做?
总不能给所有设置色值的地方都设置一个监听器吧?那样做想想都觉得“酸爽”。其实,在 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
的色值组就切换为新选中主题的色值组了。
另外文案字体和大小,以及图片的圆角大小,都是类似的原理,不再赘述,文末见源码获取方法。
5. 彩蛋 —— 切换主题进阶版
这就完了么?作为主题切换功能来讲,已经实现完了,但,刚刚的切换过程是不是感觉比较生硬?有没有更加丝滑的做法?答案当然是有的。
如图3 所示,每次切换时,背景色和字体大小、圆角大小都是渐变的,切换过程丝滑,过渡自然。
要想实现丝滑的效果,先得认识一位新的朋友:animateXxxAsState。
5.1 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 组件树上被移除,那么这个动画方法不会被取消或被停止。
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
中的 primaryColor
State 值,进而重组所有引用了 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换肤
赞人玫瑰,手留余香!欢迎点赞、转发~ 转发请注明出处~
更多内容,欢迎关注公众号: 修之竹