使用Jetpack Compose Theme为app轻松换肤

3,024 阅读6分钟

在这里插入图片描述

1. Compose挑战赛第三周

看过我之前文章的朋友应该对最近举行的Compose挑战赛有所了解,本周挑战赛进入到第三轮。 #AndroidDevChallenge Week 3

在这里插入图片描述

与前两轮规则不同,本轮主要是比拼速度。只有第一个按要求完成并提交的人能胜出,奖品是Pixel 5手机一台。题目要求基于Compose完成以下三个页面,Google会提供完成页面必须的一些资源以及视觉设计稿。

在这里插入图片描述

题目本身难度不高,主要是拼手速。自从结婚后老夫的手速退化严重,top1出线就不指望了,但本着重在参与的精神仍然坚持完成了项目,主要是希望从中找到一些可与大家分享的东西。

整个开发过程中,除了会用到LayoutModifier等基本技术以外,最大的体会就是Compose的Theme太好用了!,这也是Google想在这个题目中考察和传达的重点。虽然不使用Theme也可以完成上面三个页面,但无疑开发效率会大大折扣。


2. Compose Theme

传统Android开发中也需要配置Theme,即主题。Theme可以为UI控件提供统一的颜色和样式等,保证App视觉的一致性。主要区别在与:传统Theme依赖xml,而Compose完全基于Kotlin类型更安全、性能更优秀、使用更简单!

Kotlin的优势

当我们在AndroidStudio新建一个Compose模板工程时,IDE会自动创建theme文件夹

在这里插入图片描述

Color.ktShape.ktType.kt中通过Kotlin的常量分别定义各种样式, Theme.kt中将这些样式应用到全局主题:

//Thmem.kt
private val DarkColorPalette = darkColors(
        primary = purple200,
        primaryVariant = purple700,
        secondary = teal200
)

private val LightColorPalette = lightColors(
        primary = purple500,
        primaryVariant = purple700,
        secondary = teal200
)

@Composable
fun MyAppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
    //根据theme的不同设置不同颜色
    val colors = if (darkTheme) {
        DarkColorPalette
    } else {
        LightColorPalette
    }

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

如上,使用Kotlin定义和切换theme都是如此简单,在Composable中基于if语句选择配置,然后静等下次composition生效就好了。

Theme工作原理

每个工程都提供${app name}Theme,用于自定义主题。例如MyAppTheme,最终会调用MaterialTheme,通过一些列Provider将配置映射为环境变量:

@Composable
fun MaterialTheme(
    colors: Colors = MaterialTheme.colors,
    typography: Typography = MaterialTheme.typography,
    shapes: Shapes = MaterialTheme.shapes,
    content: @Composable () -> Unit
) {
    val rememberedColors = remember { colors }.apply { updateColorsFrom(colors) }
    val rippleIndication = rememberRipple()
    val selectionColors = rememberTextSelectionColors(rememberedColors)
    CompositionLocalProvider(
        LocalColors provides rememberedColors,
        LocalContentAlpha provides ContentAlpha.high,
        LocalIndication provides rippleIndication,
        LocalRippleTheme provides MaterialRippleTheme,
        LocalShapes provides shapes,
        LocalTextSelectionColors provides selectionColors,
        LocalTypography provides typography
    ) {
        ProvideTextStyle(value = typography.body1, content = content)
    }
}

后续的UI都创建在MyAppTheme的content中,共享Provider提供的配置

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyAppTheme {
                // A surface container using the 'background' color from the theme
                ...
            }
        }
    }
}

当需要使用主题配置时,通过MaterialTheme静态对象访问,如下:

@Composable
fun Scaffold(
    ...
    drawerShape: Shape = MaterialTheme.shapes.large,
    ...
    drawerBackgroundColor: Color = MaterialTheme.colors.surface,
    ...
    backgroundColor: Color = MaterialTheme.colors.background,
    ...
    content: @Composable (PaddingValues) -> Unit
)

MaterialTheme从Provider中获取当前配置。

object MaterialTheme {

    val colors: Colors
        @Composable
        @ReadOnlyComposable
        get() = LocalColors.current

    val typography: Typography
        @Composable
        @ReadOnlyComposable
        get() = LocalTypography.current

    val shapes: Shapes
        @Composable
        @ReadOnlyComposable
        get() = LocalShapes.current
}

3. 实战Theme

Bloom是这次挑战赛项目的名字,借助于Compose的Theme,我基本还原了设计稿的要求。

以下是完成效果,代码地址:Bloom

在这里插入图片描述

定义Theme

根据设计稿中的要求,我们在代码中定义Theme:

Color

在这里插入图片描述

首先在Color.kt中定义相关常量

//Color.kt
val pink100 = Color(0xFFFFF1F1)
val pink900 = Color(0xFF3f2c2c)
val gray = Color(0xFF232323)
val white = Color.White
val whit850 = Color.White.copy(alpha = .85f)
val whit150 = Color.White.copy(alpha = .15f)
val green900 = Color(0xFF2d3b2d)
val green300 = Color(0xFFb8c9b8)

然后通过lightColors定义白天的颜色

private val LightColorPalette = lightColors(
    primary = pink100,
    primaryVariant = purple700,
    secondary = pink900,
    background = white,
    surface = whit850,
    onPrimary = gray,
    onSecondary = white,
    onBackground = gray,
    onSurface = gray,
)

其中,primary等的定义来自MaterialDesign设计规范,根据颜色的使用场景频次等进行区分。有兴趣的可以参考MD的设计规范。

onPrimary等表示对应的背景色下的默认前景色,例如text,icon的颜色等: 在这里插入图片描述

相应的,夜间主题定义如下:

在这里插入图片描述

private val DarkColorPalette = darkColors(
    primary = green900,
    primaryVariant = purple700,
    secondary = green300,
    background = gray,
    surface = whit150,
    onPrimary = white,
    onSecondary = gray,
    onBackground = white,
    onSurface = whit850,
)

Type

在这里插入图片描述

//type.kt
val typography = Typography(
    h1 = TextStyle(
        fontFamily = FontFamily.SansSerif,
        fontWeight = FontWeight.Bold,
        fontSize = 18.sp,
    ),

    h2 = TextStyle(
        fontFamily = FontFamily.SansSerif,
        fontWeight = FontWeight.Bold,
        fontSize = 14.sp,
        letterSpacing = 0.15.sp
    ),

    subtitle1 = TextStyle(
        fontFamily = FontFamily.SansSerif,
        fontWeight = FontWeight.Light,
        fontSize = 16.sp
    ),

    body1 = TextStyle(
        fontFamily = FontFamily.SansSerif,
        fontWeight = FontWeight.Light,
        fontSize = 11.sp
    ),

    body2 = TextStyle(
        fontFamily = FontFamily.SansSerif,
        fontWeight = FontWeight.Light,
        fontSize = 12.sp
    ),

    button = TextStyle(
        fontFamily = FontFamily.SansSerif,
        fontWeight = FontWeight.SemiBold,
        fontSize = 14.sp,
        letterSpacing = 1.sp
    ),

    caption = TextStyle(
        fontFamily = FontFamily.SansSerif,
        fontWeight = FontWeight.SemiBold,
        fontSize = 12.sp
    )

)

Typography定义文字样式。h1body1等也是来自MaterialDesign中对于文字用途的定义。

Shape

在这里插入图片描述

//Shape.kt
val shapes = Shapes(
    small = RoundedCornerShape(4.dp),
    medium = RoundedCornerShape(26.dp),
    large = RoundedCornerShape(0.dp)
)

使用Theme

接下来,在代码中通过MaterialTheme获取当前配置就OK了,无需关心当前究竟是何主题。

在这里插入图片描述

以欢迎页的Beautiful home garden solutionsText为例,文字颜色需要根据主题(Light or Dart)变化。

如下,通过MaterialTheme设置Color可以避免if语句的出现

Text(
	"Beautiful home graden solutions",
	style = MaterialTheme.typography.subtitle1,
	// color = MaterialTheme.colors.onPrimary, //可省略
	modifier = Modifier.align(Alignment.CenterHorizontally),
)

前文介绍过,当背景色为primary时,前景默认会使用onPrimary,所以此处即使不设置Color,也会自动选择最合适的颜色。

再看下面Create accountButton

 Button(
	onClick = {},
	modifier = Modifier
		.height(48.dp)
		.fillMaxWidth()
		.padding(start = 16.dp, end = 16.dp)
		.clip(MaterialTheme.shapes.medium),
		//.background(MaterialTheme.colors.secondary),//Modifier设置背景色
        colors = ButtonDefaults.buttonColors(
			backgroundColor = MaterialTheme.colors.secondary
		)
) {
		Text(
			 "Create account",
			// style = MaterialTheme.typography.button // 可省略
		)
}

文字需要以typography.button的样式显示,Button内部的text默认套用button样式,所以此处也可以省略。

Note:需要注意Button有专用的颜色设置字段,使用Modifier设置background无效

由于Button设置了backgroundColorMaterialTheme.colors.secondary,所以,内部的Text的颜色自动应用onSecondary,无需额外指定。

可见,Theme不仅有利于样式的统一配置,还可以节省不少代码量。


4. 活用@Preview


视觉的调教有时需要反复确认,如果每次都要安装到设备查看效果将非常耗时。相对于传统xml布局鸡肋的预览效果,Compose提供的@Preview可以达到与真机无异的预览效果,而且还可以同屏预览多个主题、多种分辨率,便于对比。

@Preview(widthDp = 360, heightDp = 640)
@Composable
fun PreviewWelcomeLight() {
    MyTheme(darkTheme = false) {
        Surface(color = MaterialTheme.colors.background) {
            WelcomeScreen(darkTheme = false)
        }
    }
}

@Preview(widthDp = 360, heightDp = 640)
@Composable
fun PreviewWelcomeDark() {
    MyTheme(darkTheme = true) {
        Surface(color = MaterialTheme.colors.background) {
            WelcomeScreen(darkTheme = true)
        }
    }
}

如上,分别对DarkThemeLightTheme进行预览,@Preview中设置分辨率,然后就可以实时看到预览效果了。

在这里插入图片描述

@Preview是通过Composabl的实际运行实现真实的预览效果的,因此预览之前需要build,但是相对于安装到设备查看的方式已经快多了。

基于runtime的preview还有好处就是连交互也可以预览,点击右上角“手指”icon可以与preview进行交互;点击“手机”icon可以将预览画面部署到真机查看。

需要注意的是,因为预览需要保证Composable是可运行的,所以Preview只能接受无参的Composable。对于携带参数的Composable可以通过@PreviewParameter进行mock,但是mock数据本身也有成本,所以我们在设计Composable接口签名时要考虑对Preview是否友好,是否可以减少不必要的参数传递,或者为其提供默认值。


5. 最后


最后对Theme的功能以及使用心得做一些总结:

  1. Compose的Theme相对于xml方式更加高效、方便
  2. 合理地使用Theme还有助于减少代码量
  3. 建议在项目开始之前,要求PM或者设计出具详细的Theme定义,提高RD开发效率
  4. 为Composable创建配套的@Preview,将大大提高UI的开发体验

参考

AndroidDevChallenge #Bloom

Theming in Compose