Notas de estudio de Jetpack-Compose (6) - Echa un vistazo más de cerca al tema de Compose, ¿cómo puede ser tan fácil y sedoso desollar?

Es genial interrumpir la actualización por un tiempo, y siempre es genial detener la actualización ~ Jajaja, solo date unas largas vacaciones. El mercado reciente es tan malo, algunos de mis compañeros se han graduado, y después de más de dos meses, finalmente obtuve un recorte de salario para encontrar un nuevo hogar... Aquí hacemos un llamado a todos a ahorrar dinero por seis meses sin trabajo. y seguir viviendo una vida normal, por si acaso. Espero que la epidemia disminuya pronto y que la economía pueda recuperarse rápidamente~

No esperaba que esta serie llegara al sexto capítulo. Ha pasado mucho tiempo desde que rompí la actualización. Incluso recibí un recordatorio de mis amigos. Gracias por su perseverancia. Sin más preámbulos, vamos a presentar el tema Compose esta vez, entonces, ¿qué es exactamente el tema Compose? ¿Es fácil desollar con Compose? ¡Echemos un vistazo!

El tema de Jetpack Compose es un conjunto de estilos de interfaz de usuario, que incluyen fuentes, tamaños de fuente, valores de color, etc., que son análogos a los estilos de tema de, Theme.MaterialComponents.DayNight.DarkActionBaretc. . La mayor diferencia con el sistema View es que abandona por completo la configuración del archivo xml. Todos los estilos se establecen a través del código. Los estilos de tema se pueden dividir aproximadamente en tres categorías: valor de color, estilo de copia y estilo de forma. Primero veamos los valores de color en el tema.

1. Valor de color

Muchos componentes admiten no solo la configuración de su propio color de fondo, sino también la configuración del valor de color predeterminado de otros elementos componibles que contiene, mediante el contentColorFormétodo . Por ejemplo, el código 1 a continuación:

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

Encontrará que Surfaceel color de fondo de , es amarillo, y el caso Textchino es rojo. Si lo Textreemplaza con Icon, Iconel color de , también cambiará a rojo. Los estudiantes interesados ​​pueden probarlo.

SurfaceHay elementos componibles similares TopAppBar, y el siguiente es su código fuente de implementación:

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

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

Supongo que te gusta

Origin juejin.im/post/7117127014565281800
Recomendado
Clasificación