引语
"美的东西更好用。" ------ 唐·诺曼《情感化设计》
这句看似简单的话,道出了 Material 3 Expressive 的核心信念:好的设计不只是让人"能用",更要让人"想用"。这一课,我们从一封邮件开始,看看 Material 3 Expressive 如何在秩序之上,注入情感与生命力。
你有没有过这样的经历?
打开Android Studio,新建一个项目,然后面对一堆XML布局文件和主题配置发呆。颜色要手动定义,按钮样式要自己写drawable,深色模式要单独维护一套资源文件,更别提不同屏幕尺寸的适配了......那种感觉,就像用螺丝刀组装宜家家具------能装好,但过程实在谈不上愉快。
然后Material Design 3来了。它带着一个有点浪漫的名字------"Material You",试图回答一个朴素的问题:为什么你的手机界面不能像你的穿搭一样,有自己的风格?
而到了2025年,Google在I/O大会上正式发布了Material 3 Expressive------这是Material Design有史以来最大的一次更新,建立在此前Material You的基础上,进一步强化了情感化设计和表现力。Google为此开展了46场全球研究,测试数百种设计变体,收集了18000名参与者的反馈,结果显示表现力强的界面在趣味性、创造力、活力和友好度上全面优于传统设计。
这一课,我们不从抽象的设计原则讲起,而是从一封邮件开始。Google官方提供了一个非常经典的Material 3示例应用------Reply。它是一个邮件客户端,包含邮件列表页和详情页,麻雀虽小五脏俱全。我们通过它来认识Material 3 Expressive最核心的几个子系统:颜色、排版、形状、动效、组件和高度。而且,这一课里出现的每一行示例代码,我都会加上逐行详细注解,让你不仅知道"怎么写",更知道"为什么这么写"。
准备好了吗?让我们从一封邮件开始。
一、先看目标:Reply邮件应用长什么样
在动手写代码之前,我们先在脑子里"跑"一遍这个应用。
打开Reply,首页是邮件列表。每一封邮件是一张卡片,卡片里有发件人头像(圆形,带首字母)、姓名、邮件主题和正文预览。整体感觉是清爽、有层次,但又不花哨。
点进一封邮件,进入详情页。顶部是发件人和主题,中间是正文,底部有一个醒目的"回复"按钮。右上角还有一个悬浮操作按钮(FAB),用来写新邮件。
底部导航栏可以在"收件箱"和"已加星标"之间切换。切换时,图标的填充状态会变化,文字颜色也会跟着变。
而在Material 3 Expressive的加持下,这个应用会变得更"有生命"------按钮按下时会弹跳,卡片切换时会流动,导航栏选中时会形状变形。这些都不是花哨的装饰,而是有目的的情感表达。
这个应用不大,但颜色系统、排版系统、形状系统、动效系统、按钮、卡片、导航栏、图标、高度,全都用上了。我们这一课的目标,就是把这些东西一个一个拆开,再组装回去。
二、依赖:只加一行,但要知道为什么
在build.gradle.kts文件中,你需要加入Material 3的依赖。代码只有一行,但我们把每个部分都解释清楚:
kotlin
implementation("androidx.compose.material3:material3:$material3_version") // 引入 Material 3 组件库;冒号前是组坐标,冒号后是模块名,$material3_version 是版本变量,方便统一升级
这一行代码看起来简单,但它背后是一整套设计系统。加上它,你就获得了MaterialTheme、ColorScheme、Typography、Shapes,以及Button、Card、NavigationBar等所有Material 3组件。从Material 3 1.4.0版本开始,Expressive的新组件(如ButtonGroup、FAB Menu、LoadingIndicator等)也已包含在内。有些API被标记为实验性,使用它们时需要添加@ExperimentalMaterial3ExpressiveApi注解。这不是坏事,只是说明Google还在打磨细节。
三、Material 3 Expressive:从"一致"到"共情"
在深入代码之前,我们需要先理解Material 3 Expressive和它之前版本的根本区别。
Material Design 2的设计哲学可以用一个词概括:一致性。Google希望所有安卓应用看起来像一家人,统一的颜色、统一的组件、统一的交互模式。这在当时是合理的------安卓生态的碎片化问题太严重了,需要一套强力的规范来收拢。
Material 3带来了"Material You"理念,追求**"每个应用看起来都像你"**。动态颜色让界面适应用户的壁纸,个性化主题成为可能。
而Material 3 Expressive,则把目标又推进了一步:让界面不仅像你,还能打动你。正如Google所说,"人们越来越把设备视为自身的延伸。表现力强的UI能够产生情感影响,通过唤起更强烈的感觉或情绪来培养这种连接"。
这个理念落地为几个核心方向:
情感化设计------界面不只是功能性的,它应该让人产生情感共鸣。Expressive的动效系统使用弹簧物理(stiffness、damping、velocity)取代传统的贝塞尔曲线,让交互感觉"活"起来。
动态优先------Google的研究表明,遵循Material 3 Expressive指南设计的产品,用户定位正确屏幕元素的速度快了33%,任务完成速度快了20%。
表现力组件------15个新增或更新的组件带来了更多的尺寸、形状、功能和视觉强调选项。
扩展的形状和排版------35种新形状选项,加上Emphasized排版样式,让界面可以表达更丰富的情感状态。
在Material 3 Expressive中,动效和形状的变革尤为突出。接下来,我们先用两章的篇幅把颜色和排版的基础打好,然后用两章的篇幅深入这两个核心变革。
四、颜色系统:不是选几个好看的颜色那么简单
4.1 五组色调,十三个亮度
Material 3的颜色系统是整套设计语言中最复杂的部分,也是最值得花时间理解的部分。
传统做法是:选一个品牌色,选一个辅助色,选一个强调色,然后手动确保它们在浅色和深色模式下都有足够的对比度。这个过程既费时又容易出错。
Material 3的做法完全不同。它的起点是五种关键颜色 :Primary(主色)、Secondary(辅助色)、Tertiary(三级色)、Neutral(中性色)和Neutral Variant(中性变体色)。每一种颜色都会生成一个包含13个色调的调色板,从0(纯黑)到100(纯白),中间以10为步长分布。
这意味着什么?意味着你不需要手动为"按钮在浅色模式下的背景色"和"按钮在深色模式下的背景色"分别选颜色。系统会根据色调值自动计算:浅色主题下,Primary容器使用色调90,文字使用色调10;深色主题下则反过来。色调系统保证了对比度和可读性的一致性。
在Material 3 Expressive中,动态颜色主题进一步扩展,支持更丰富的个性化调色。M3 Expressive还引入了三个标准化的对比度级别------标准、中等和高,用户可以根据自己的可读性需求进行调整,系统会动态重新计算所有颜色角色的色调值,确保对比度始终合规。
4.2 颜色角色:每个颜色都有"职位"
色调调色板只是原料,真正让颜色系统运转起来的是颜色角色的概念。Material 3为每种关键颜色定义了一系列角色,最重要的包括:
primary / onPrimary------主色及其上的文字颜色。用于最突出的元素,比如FAB和主要按钮。
primaryContainer / onPrimaryContainer------主色的容器变体,用于需要较低强调度的场景,比如Filled Tonal按钮。
surface / onSurface------表面颜色及其上的文字颜色。这是应用中最常见的背景色。
surfaceVariant / onSurfaceVariant------表面变体,用于需要微妙区分的场景,比如卡片背景和输入框背景。
error / onError------错误状态的颜色。
这种"角色"思维的重要性在于:当你使用MaterialTheme.colorScheme.primary时,你拿到的不是一个固定的颜色值,而是一个语义化的颜色引用 。如果用户切换了动态颜色,或者你更换了品牌配色方案,这个引用会自动指向新的正确颜色。你的UI代码完全不需要改动。
4.3 动手实践:定制Reply的颜色
让我们在Reply应用中实际操作一下。在ui/theme/Color.kt中,我们定义应用的品牌颜色:
kotlin
val Blue40 = Color(0xFF1A73E8) // 定义品牌主色:一种偏亮的蓝色,40 表示浅色主题常用色调,0xFF 表示不透明
val Blue80 = Color(0xFF8AB4F8) // 定义品牌主色的浅色变体:更淡的蓝色,80 表示深色主题或容器色常用色调
val BlueGrey40 = Color(0xFF5F6368) // 定义中性偏蓝的灰色:用于次要文字或表面变体,40 表示浅色主题常用
val BlueGrey80 = Color(0xFFBDC1C6) // 定义中性偏蓝的浅灰:用于深色主题或低强调背景,80 表示深色主题常用
然后在Theme.kt中构建浅色颜色方案:
kotlin
private val LightColorScheme = lightColorScheme( // 调用 Material 3 的浅色方案构建函数,生成一套语义化颜色
primary = Blue40, // 主色:用于 FAB、主要按钮、选中状态等最高强调元素
onPrimary = Color.White, // 主色上的文字/图标颜色:白色保证在 Blue40 上有足够对比度
primaryContainer = Blue80, // 主色容器:用于 Filled Tonal 按钮、头像背景等较低强调场景
onPrimaryContainer = Color(0xFF001D36), // 主色容器上的文字/图标:深蓝黑,保证在 Blue80 上清晰可读
surface = Color(0xFFF8F9FA), // 表面色:应用最常见的背景色,接近白色但略带灰
onSurface = Color(0xFF1F1F1F), // 表面上的文字/图标:深灰黑,保证在浅色表面上有高对比度
surfaceVariant = Color(0xFFE8EAED), // 表面变体:用于卡片、输入框等需要与背景微妙区分的区域
onSurfaceVariant = Color(0xFF444746), // 表面变体上的文字/图标:中深灰,保证在 surfaceVariant 上可读
) // 浅色方案构建结束,赋给 LightColorScheme
现在,任何使用MaterialTheme.colorScheme.surface的组件都会自动获得Color(0xFFF8F9FA)作为背景色,使用onSurface的文字则会得到Color(0xFF1F1F1F)。
4.4 动态颜色:一行代码的魔法
如果你觉得手动定义颜色方案还是太麻烦,Material 3提供了一个更激进的选项------动态颜色。从Android 12开始,系统可以从用户的壁纸中提取颜色,自动生成一套完整的配色方案。
在Compose中启用动态颜色只需要一个条件判断:
kotlin
val colorScheme = when { // 声明一个不可变变量 colorScheme,用 when 表达式根据条件选择方案
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { // 条件:启用动态颜色,且系统版本是 Android 12(API 31)及以上
val context = LocalContext.current // 获取当前 Compose 环境里的 Context,用于读取系统壁纸颜色
if (darkTheme) dynamicDarkColorScheme(context) // 如果是深色主题,调用动态深色方案,从壁纸提取颜色
else dynamicLightColorScheme(context) // 否则调用动态浅色方案,同样从壁纸提取颜色
} // 动态颜色分支结束
darkTheme -> DarkColorScheme // 如果不满足动态颜色但需要深色主题,使用手写深色方案
else -> LightColorScheme // 其他情况使用手写浅色方案
} // when 表达式结束,colorScheme 得到最终颜色方案
dynamicLightColorScheme和dynamicDarkColorScheme会读取系统壁纸的色调信息,生成一套在视觉上和谐、在对比度上合规的配色。这意味着用户换了一张壁纸,你的应用界面就焕然一新------而你没有写任何额外的代码。
当然,动态颜色并不总是适合所有应用。如果你的品牌有严格的视觉规范,或者应用类型对颜色准确性有高要求(比如金融、医疗),手动定义颜色方案仍然是更好的选择。Material 3的设计允许你在这两种模式之间灵活切换。
4.5 在组件里使用颜色
颜色定义好了,接下来就是在界面里使用。下面这段代码展示了一个邮件卡片如何使用主题颜色:
kotlin
Card( // 创建一个 Material 3 卡片,作为邮件列表项的容器
colors = CardDefaults.cardColors( // 指定卡片颜色配置,不硬编码颜色
containerColor = MaterialTheme.colorScheme.surfaceVariant // 卡片背景使用表面变体色,与页面背景形成微妙层次
) // 颜色配置结束
) { // 卡片内容 lambda 开始
Text( // 创建文本组件,显示邮件主题
text = email.subject, // 文本内容来自邮件对象的 subject 属性
style = MaterialTheme.typography.titleLarge, // 文字样式使用主题中的 titleLarge,保证全局一致
color = MaterialTheme.colorScheme.onSurfaceVariant // 文字颜色使用表面变体上的颜色,保证对比度
) // Text 组件结束
} // Card 组件结束
注意,这里没有出现任何Color(0xFF...)。所有颜色都来自MaterialTheme.colorScheme。这就是Material 3的颜色角色系统在实战中的样子:你描述"这是什么角色",而不是"这是什么颜色"。
五、排版系统:五种样式,够用就好
5.1 从十五到五的减法
Material Design 2的排版系统定义了15种文本样式,从Display Large到Overline,开发者需要记住每一种样式的用途和适用场景。Material 3做了一个大胆的简化:虽然仍然保留了15种样式的定义,但官方建议在大多数应用中只使用五种:
headlineSmall------页面级标题,比如邮件详情页的主题行titleLarge------列表项的标题,比如邮件列表中的邮件主题bodyLarge------正文内容bodyMedium------次要正文内容labelMedium------标签和辅助信息,比如发件人姓名和时间戳
在Reply的Codelab中,这五种样式被精确地应用到了邮件列表和详情页的各个元素上。比如邮件列表项中,发件人姓名用labelMedium,邮件主题用titleLarge,正文预览用bodyLarge。
5.2 Emphasized排版:Expressive的新武器
Material 3 Expressive在原有排版系统的基础上,引入了Emphasized排版样式。这不是简单地加大加粗,而是一套专门用于表达情感状态和视觉焦点的排版方案。
Emphasized排版样式的特点是更大、更粗、更有存在感。Google的指南指出,这些样式应该用于"强调重要操作(如'开始录音')或关键信息(如未读消息)"。在Reply应用中,我们可以把邮件主题的排版升级为Emphasized样式,让它在列表中更有冲击力。
更强大的是,Expressive的排版系统支持可变字体。你可以动态调整字重、字宽等属性,让文字在不同场景下表达不同的情感------从柔和的阅读体验到强烈的视觉冲击。
5.3 在Compose中定义排版
在ui/theme/Type.kt中定义应用排版:
kotlin
val AppTypography = Typography( // 创建 Material 3 的排版对象,覆盖默认字体样式
titleLarge = TextStyle( // 定义 titleLarge:用于列表项标题、邮件主题等
fontWeight = FontWeight.SemiBold, // 字重设为半粗体,让标题比正文更醒目
fontSize = 18.sp, // 字号 18sp,适合手机屏幕上的重要标题
lineHeight = 32.sp, // 行高 32sp,比字号大很多,避免长标题拥挤
letterSpacing = 0.sp // 字间距为 0,保持默认紧凑感
), // titleLarge 定义结束
bodyLarge = TextStyle( // 定义 bodyLarge:用于正文、邮件预览等
fontWeight = FontWeight.Normal, // 字重正常,适合大段阅读
fontSize = 16.sp, // 字号 16sp,移动端正文的舒适大小
lineHeight = 24.sp, // 行高 24sp,约为字号的 1.5 倍,提升可读性
letterSpacing = 0.5.sp // 字间距 0.5sp,让正文略微呼吸
), // bodyLarge 定义结束
labelMedium = TextStyle( // 定义 labelMedium:用于发件人、时间戳等辅助信息
fontWeight = FontWeight.Medium, // 字重中等,比正文稍强但弱于标题
fontSize = 12.sp, // 字号 12sp,适合次要标签
lineHeight = 16.sp, // 行高 16sp,保证小字号也不拥挤
letterSpacing = 0.5.sp // 字间距 0.5sp,提升小字清晰度
) // labelMedium 定义结束
) // AppTypography 创建结束
注意titleLarge中我们把fontWeight设为SemiBold,lineHeight设为32.sp。这是Reply Codelab中的一个关键调整------邮件主题需要比普通标题更醒目,同时更大的行高让长主题在列表中不会显得拥挤。
5.4 在组件中使用排版
然后在组件中使用:
kotlin
Text( // 创建文本组件,显示发件人姓名
text = email.sender.firstName, // 文本内容来自发件人的名字
style = MaterialTheme.typography.labelMedium, // 使用主题中的 labelMedium 样式
color = MaterialTheme.colorScheme.onSurfaceVariant // 使用表面变体上的颜色
) // Text 组件结束
这里的关键是MaterialTheme.typography.labelMedium------它不是硬编码的样式,而是从主题中读取的。如果将来你把整个应用的标签字体换成另一种风格,只需要修改Type.kt一个地方。
5.5 字体的可访问性
Material 3的排版系统内置了对可访问性的支持。每种文本样式都定义了推荐的最小字号和行高,确保在不同屏幕密度和显示设置下文字仍然清晰可读。当你自定义字体时,建议遵循这些基准值,不要为了"好看"而牺牲可读性。
六、动效系统:让界面"活"起来
动效是Material 3 Expressive最核心的变革。它不是加一些花哨的动画效果,而是重新定义了界面如何回应用户的每一次触碰。Google将这一变革称为"运动物理系统"(Motion Physics System)------用弹簧物理取代了传统的贝塞尔缓动曲线。
6.1 从贝塞尔曲线到弹簧物理
传统的动画使用时间/缓动曲线来控制------你指定动画持续多少毫秒,用什么样的缓动函数(比如ease-in-out)。这种方式精确可控,但感觉机械,就像一台机器在按部就班地执行指令。
Material 3 Expressive引入了运动物理系统,使用弹簧物理来驱动动画。这不是简单的"加个弹跳效果",而是一套完整的物理模型。它基于三个核心参数:
Stiffness(刚度) ------弹簧有多"硬"。刚度越高,动画完成得越快。想象一根很紧的弹簧和一根很松的弹簧,松手后回弹的速度完全不同。
Damping(阻尼) ------弹跳消退的速度。阻尼为1时完全没有弹跳,阻尼越低弹跳越明显。这就像在弹簧上加了不同程度的缓冲器。
Initial Velocity(初速度) ------动画开始时的初始速度。如果你快速滑动一个元素,它会带着这个速度继续运动,而不是突然改变速度。
Material 3 Expressive官方给出了默认的弹簧参数:空间弹簧(用于位置、尺寸变化)的阻尼为0.8,刚度为380;效果弹簧(用于颜色、透明度变化)的阻尼为1.0,刚度为1600。效果弹簧阻尼为1.0意味着完全没有弹跳------因为颜色的变化不应该有回弹感。
6.2 空间弹簧与效果弹簧
Expressive的运动系统区分了两种弹簧类型:
Spatial Springs(空间弹簧) ------用于位置、尺寸、旋转等空间属性的变化。它们模拟物体在真实空间中的运动方式,让动画感觉清晰、可预测。
Effects Springs(效果弹簧) ------用于颜色、透明度等效果属性的变化。它们确保这些变化平滑自然,不会产生突兀的视觉跳跃。
这种区分很重要。如果一个按钮按下时,位置变化带有弹跳(空间弹簧)但颜色变化没有弹跳(效果弹簧),整个交互就会感觉自然。反之,如果颜色也在"弹跳",那就会显得怪异。
6.3 在Compose中使用弹簧动画
kotlin
val scale by animateFloatAsState( // 创建一个动画状态,跟踪缩放值
targetValue = if (isPressed) 0.95f else 1f, // 按下时缩小到 0.95,松开时恢复
animationSpec = spring( // 使用弹簧动画规格
dampingRatio = Spring.DampingRatioMediumBouncy, // 阻尼比:中等弹跳,有明显的回弹效果
stiffness = Spring.StiffnessMedium // 刚度:中等,动画完成速度适中
), // 弹簧规格结束
label = "buttonScale" // 动画标签,方便调试
) // 动画状态结束
Box( // 创建可缩放容器
modifier = Modifier // 修饰符开始
.scale(scale) // 应用缩放动画值
.pointerInput(Unit) { // 添加指针输入处理
detectTapGestures( // 检测点击手势
onPress = { // 按下时
isPressed = true // 设为按下状态,触发缩小动画
awaitRelease() // 等待释放
isPressed = false // 恢复状态,触发回弹动画
} // 按下回调结束
) // 手势检测结束
} // 指针输入结束
) { // Box 内容开始
// 按钮内容
} // Box 内容结束
这段代码展示了Expressive风格的核心------按钮在被按下时会缩小,松开时带着弹簧般的感觉弹回 。这不是简单的线性动画,而是通过spring函数控制的物理模拟,dampingRatio和stiffness决定了回弹的手感。
6.4 触觉反馈与动效的配合
Material 3 Expressive强调动效与触觉反馈(Haptic Feedback) 的配合。当用户关闭一个通知时,不仅会看到平滑的分离动画,还会感受到恰到好处的震动。这种多感官的反馈让交互更加立体和满足。
在Compose中,你可以通过LocalHapticFeedback来实现:
kotlin
val haptic = LocalHapticFeedback.current // 获取当前环境的触觉反馈实例
// 在合适的时机触发
haptic.performHapticFeedback(HapticFeedbackType.LongPress) // 执行长按触觉反馈,配合动画增强感知
七、形状系统:35种形状的视觉语言
如果说动效是Expressive的"灵魂",那么形状就是它的"骨架"。Material 3 Expressive将形状系统从6种基础形状扩展到了35种形状,这是一个质的飞跃。
7.1 从基础形状到扩展形状库
在Material 3中,基础形状系统包括:
extraSmall(4dp)------用于小尺寸组件,如Chip和小的输入框
small(8dp)------用于中等尺寸组件,如Card和Dialog
medium(12dp)------用于较大的容器
large(16dp)------用于更大的表面
extraLarge(28dp)------用于模态底部弹窗等
full------完全圆形,用于FAB和IconButton
7.2 Expressive形状库:从装饰到动态
这些新形状不只是更多的圆角选项。它们包括了装饰性形状------比如特殊的角形、不规则多边形,可以用在图像裁剪、头像、加载指示器等元素上。
更重要的是,Expressive引入了形状变形(Shape Morphing) ------一个形状可以平滑过渡到另一个形状。比如一个方形按钮被按下时可以变成圆形,一个卡片展开时可以改变轮廓形状。形状变形使用Expressive运动方案(带有弹跳感)作为默认行为,也可以切换到标准运动方案。
在Reply应用中,我们可以让邮件卡片的头像在选中时从圆形变形为水滴形,或者让FAB在展开时从圆形变为圆角方形。这些形状变化不只是视觉装饰------它们是状态的视觉信号,帮助用户理解界面当前的状态。
7.3 在Compose中定义形状
kotlin
val AppShapes = Shapes( // 创建 Material 3 形状对象,统一管理圆角
extraSmall = RoundedCornerShape(4.dp), // 超小圆角:用于 Chip、小输入框等精致元素
small = RoundedCornerShape(8.dp), // 小圆角:用于小卡片、小按钮等
medium = RoundedCornerShape(12.dp), // 中圆角:用于普通卡片、对话框等
large = RoundedCornerShape(16.dp), // 大圆角:用于大表面、底部弹窗等
extraLarge = RoundedCornerShape(28.dp) // 超大圆角:用于模态底部弹窗等强调容器
) // AppShapes 创建结束
使用起来同样简单:
kotlin
Card( // 创建卡片组件
shape = MaterialTheme.shapes.medium, // 卡片形状使用主题中的 medium 圆角,保持全局一致
colors = CardDefaults.cardColors( // 指定卡片颜色配置
containerColor = MaterialTheme.colorScheme.surfaceVariant // 背景使用表面变体色
) // 颜色配置结束
) { // 卡片内容开始
// 卡片内容
} // 卡片内容结束
八、组件实战:从按钮到导航栏
8.1 五种按钮,五种强调级别
Material 3定义了五种按钮样式,它们的区别不在于外观,而在于强调级别:
Filled Button------最高强调。用于最终确认操作,比如"发送""保存""确认"。
Filled Tonal Button------中高强调。用于次要的确认操作,视觉上比Filled柔和。
Elevated Button------中强调。带轻微阴影,用于需要在视觉上"浮起"的场景。
Outlined Button------中低强调。用于次要操作,比如"取消""稍后"。
Text Button------最低强调。用于最不重要的操作,比如对话框中的"了解更多"。
在Reply中,邮件详情页底部的"回复"按钮使用了FilledButton,因为这是页面最重要的操作:
kotlin
FilledButton( // 创建填充按钮,最高强调级别
onClick = { /* 回复邮件 */ }, // 点击时执行回复逻辑,这里用注释占位
modifier = Modifier.fillMaxWidth() // 修饰符:让按钮宽度填满父容器
) { // 按钮内容 lambda 开始
Icon( // 创建图标组件
imageVector = Icons.Default.Reply, // 使用默认的回复图标
contentDescription = null // 内容描述设为 null,因为旁边有文字说明,避免重复朗读
) // 图标组件结束
Spacer(Modifier.width(8.dp)) // 创建间距,宽度 8dp,把图标和文字分开
Text("回复") // 创建文字"回复",告诉用户按钮功能
} // 按钮内容结束
注意按钮同时包含了图标和文字------这是Material 3推荐的做法,图标帮助快速识别,文字提供明确含义。
8.2 Expressive新组件:Button Group
Material 3 Expressive引入了全新的Button Group组件------一组相关按钮水平排列,按下时会进行形状变形。这在Reply应用中非常有用------比如在邮件操作区域放置"回复""转发""标记"三个按钮组:
kotlin
ButtonGroup( // 创建 Material 3 Expressive 按钮组
modifier = Modifier.padding(16.dp) // 外边距 16dp
) { // 按钮组内容开始
FilledTonalButton( // 第一个按钮:回复
onClick = { /* 回复邮件 */ }, // 点击回调
shape = ButtonGroupDefaults.connectedLeadingButtonShape // 使用按钮组连接形状------左侧圆角
) { // 按钮内容开始
Icon(Icons.Default.Reply, contentDescription = null) // 回复图标
Spacer(Modifier.width(8.dp)) // 图标和文字间距
Text("回复") // 按钮文字
} // 按钮内容结束
FilledTonalButton( // 第二个按钮:转发
onClick = { /* 转发邮件 */ }, // 点击回调
shape = ButtonGroupDefaults.connectedMiddleButtonShape // 使用按钮组连接形状------中间方形
) { // 按钮内容开始
Icon(Icons.Default.Forward, contentDescription = null) // 转发图标
Spacer(Modifier.width(8.dp)) // 图标和文字间距
Text("转发") // 按钮文字
} // 按钮内容结束
FilledTonalButton( // 第三个按钮:标记
onClick = { /* 标记邮件 */ }, // 点击回调
shape = ButtonGroupDefaults.connectedTrailingButtonShape // 使用按钮组连接形状------右侧圆角
) { // 按钮内容开始
Icon(Icons.Default.Star, contentDescription = null) // 星标图标
Spacer(Modifier.width(8.dp)) // 图标和文字间距
Text("标记") // 按钮文字
} // 按钮内容结束
} // 按钮组结束
Button Group不只是把按钮排在一起,它还内置了形状变形动画------当用户触摸组内某个按钮时,该按钮的形状会动态变化,提供即时的视觉反馈。
8.3 FAB Menu:从单一操作到多操作
Material 3 Expressive用FAB Menu取代了之前的Speed Dial和堆叠的Mini FAB。一个FAB菜单可以包含2到6个相关操作,展开时带有弹簧动画和触觉反馈。
在Reply中,写邮件FAB可以展开为"新邮件""回复全部""草稿箱"等多个操作:
kotlin
FloatingActionButtonMenu( // 创建 FAB 菜单,替代传统 Speed Dial
expanded = expanded, // 当前展开状态
onExpandedChange = { expanded = it }, // 展开状态变化回调
fab = { // 主 FAB 定义
FloatingActionButton( // 创建主浮动操作按钮
onClick = { expanded = !expanded }, // 点击切换展开状态
shape = MaterialTheme.shapes.extraLarge, // Expressive 大圆角形状
containerColor = MaterialTheme.colorScheme.primaryContainer, // 主色容器背景
contentColor = MaterialTheme.colorScheme.onPrimaryContainer // 主色容器上的颜色
) { // FAB 内容
Icon( // 主图标
imageVector = if (expanded) Icons.Default.Close else Icons.Default.Edit, // 展开时显示关闭,收起时显示编辑
contentDescription = if (expanded) "关闭菜单" else "写邮件" // 无障碍描述
) // 图标结束
} // FAB 结束
} // 主 FAB 定义结束
) { // FAB 菜单内容开始
FloatingActionButtonMenuItem( // 菜单项:新邮件
onClick = { expanded = false }, // 点击后收起菜单
icon = { Icon(Icons.Default.Email, contentDescription = null) }, // 菜单项图标
text = { Text("新邮件") } // 菜单项文字
) // 菜单项结束
FloatingActionButtonMenuItem( // 菜单项:回复全部
onClick = { expanded = false }, // 点击后收起菜单
icon = { Icon(Icons.Default.ReplyAll, contentDescription = null) }, // 菜单项图标
text = { Text("回复全部") } // 菜单项文字
) // 菜单项结束
} // FAB 菜单结束
8.4 卡片:内容的容器
卡片是Material 3中最常用的容器组件。Reply的邮件列表就是由一系列Card组成的:
kotlin
Card( // 创建邮件卡片
onClick = { /* 打开邮件详情 */ }, // 点击卡片时打开详情页
modifier = Modifier.fillMaxWidth(), // 让卡片宽度填满父容器
colors = CardDefaults.cardColors( // 指定卡片颜色
containerColor = MaterialTheme.colorScheme.surfaceVariant // 背景使用表面变体色
) // 颜色配置结束
) { // 卡片内容开始
Row( // 创建横向布局,把头像和文字排成一行
modifier = Modifier.padding(16.dp), // 内边距 16dp,让内容不贴边
verticalAlignment = Alignment.CenterVertically // 垂直居中对齐子元素
) { // Row 内容开始
Box( // 创建盒子,作为头像容器
modifier = Modifier // 修饰符开始
.size(40.dp) // 设置尺寸为 40dp
.background( // 设置背景
color = MaterialTheme.colorScheme.primaryContainer, // 背景色使用主色容器
shape = CircleShape // 形状为圆形
), // 背景设置结束
contentAlignment = Alignment.Center // 内容居中对齐
) { // Box 内容开始
Text( // 创建文本,显示发件人名字首字母
text = email.sender.firstName.first().toString(), // 取名字第一个字符并转成字符串
style = MaterialTheme.typography.titleLarge, // 使用 titleLarge 样式
color = MaterialTheme.colorScheme.onPrimaryContainer // 使用主色容器上的颜色
) // Text 结束
} // Box 结束
Spacer(Modifier.width(16.dp)) // 创建 16dp 宽的间距,隔开头像和文字
Column(modifier = Modifier.weight(1f)) { // 创建纵向布局,并占满剩余宽度
Text( // 显示发件人姓名
text = email.sender.firstName, // 文本为发件人名字
style = MaterialTheme.typography.labelMedium, // 使用 labelMedium 样式
color = MaterialTheme.colorScheme.onSurfaceVariant // 使用表面变体上的颜色
) // Text 结束
Text( // 显示邮件主题
text = email.subject, // 文本为邮件主题
style = MaterialTheme.typography.titleLarge, // 使用 titleLarge 样式
modifier = Modifier.padding(vertical = 4.dp) // 上下各加 4dp 内边距
) // Text 结束
Text( // 显示邮件正文预览
text = email.body, // 文本为邮件正文
maxLines = 2, // 最多显示两行
overflow = TextOverflow.Ellipsis, // 超出部分用省略号表示
style = MaterialTheme.typography.bodyLarge, // 使用 bodyLarge 样式
color = MaterialTheme.colorScheme.onSurfaceVariant // 使用表面变体上的颜色
) // Text 结束
} // Column 结束
} // Row 结束
} // Card 结束
这段代码展示了Material 3的典型用法:所有颜色都来自MaterialTheme.colorScheme,所有文字样式都来自MaterialTheme.typography。没有硬编码的颜色值,没有手动定义的文字大小。这意味着当你切换主题或启用动态颜色时,整个卡片的外观会自动适配。
8.5 底部导航栏
在手机等紧凑设备上,底部导航栏是最常见的导航模式。Material 3中对应的组件是NavigationBar和NavigationBarItem:
kotlin
NavigationBar( // 创建 Material 3 底部导航栏
containerColor = MaterialTheme.colorScheme.surface // 导航栏背景使用表面色
) { // 导航栏内容开始
NavigationBarItem( // 创建第一个导航项
selected = selectedItem == 0, // 当 selectedItem 为 0 时表示选中
onClick = { selectedItem = 0 }, // 点击后把选中项设为 0
icon = { Icon(Icons.Default.Home, contentDescription = "收件箱") }, // 图标为首页,描述为收件箱
label = { Text("收件箱") } // 标签文字为收件箱
) // 第一个导航项结束
NavigationBarItem( // 创建第二个导航项
selected = selectedItem == 1, // 当 selectedItem 为 1 时表示选中
onClick = { selectedItem = 1 }, // 点击后把选中项设为 1
icon = { Icon(Icons.Default.Star, contentDescription = "已加星标") }, // 图标为星标,描述为已加星标
label = { Text("已加星标") } // 标签文字为已加星标
) // 第二个导航项结束
} // 导航栏内容结束
注意从Material 2迁移过来的开发者:BottomNavigation和BottomNavigationItem已经被NavigationBar和NavigationBarItem取代了。这不是简单的改名------新组件的视觉语言、交互反馈和可访问性支持都有显著提升。
九、高度(Elevation):不只是阴影
在Material 2中,高度主要通过阴影来表达------一个组件在Z轴上的位置越高,阴影越大。Material 3改变了这种做法,转而使用色调叠加来表示高度。
什么是色调叠加?简单说,当一个组件"浮起"时,它的背景色不是变得更暗或更亮,而是在表面色上叠加一层主色调。高度越高,叠加的主色调越明显。
在Compose中,Surface组件通过tonalElevation参数来实现这个效果:
kotlin
Surface( // 创建表面容器,用于承载内容并表达层级
tonalElevation = 5.dp, // 色调高度 5dp,表面会叠加约 5% 主色调,表达轻微浮起
color = MaterialTheme.colorScheme.surface // 表面颜色使用主题表面色
) { // 表面内容开始
// 内容
} // 表面内容结束
5dp的色调高度意味着表面色会叠加约5%的主色调。这个变化很微妙,但在视觉上创造了一种层次感------高层的表面看起来"更接近"用户,也就更应该被注意到。
Material 3定义了六个高度级别:
- Level 0:0dp------贴在背景上
- Level 1:1dp------轻微抬起
- Level 2:3dp------卡片级别
- Level 3:6dp------FAB级别
- Level 4:8dp------导航栏级别
- Level 5:12dp------模态弹窗级别
这些级别不是强制性的,但它们为不同组件提供了合理的默认值。你可以根据实际需要调整。
十、图标与加载:更多Expressive组件
10.1 从Material Icons到Material Symbols
Material 3引入了一套全新的图标系统------Material Symbols 。它取代了之前的Material Icons,提供了三种风格:Outlined (描边)、Rounded (圆角)和Sharp(锐角)。
更重要的是,Material Symbols是可变字体 ,这意味着你可以动态调整四个属性:Weight (字重)、Fill (填充)、Grade (等级)和Optical Size(视觉尺寸)。其中Fill属性特别有用------你可以在选中/未选中状态之间平滑过渡图标的填充程度。
10.2 Loading Indicator:取代传统的圆形进度条
Material 3 Expressive引入了一个新的Loading Indicator 组件,专门用于替换不定量的圆形进度指示器。它的设计目标是处理5秒以内的等待 ------比如页面加载、下拉刷新等场景。与传统的旋转圆圈不同,Loading Indicator使用波浪形状,Google指出"波浪形状可以让较长的流程感觉不那么静态",并支持成功和错误状态的切换,整个过渡都有弹簧动画的加持。
kotlin
LoadingIndicator( // 创建 Material 3 Expressive 加载指示器
modifier = Modifier.size(48.dp), // 设置尺寸 48dp
containerColor = MaterialTheme.colorScheme.primary, // 容器颜色使用主色
indicatorColor = MaterialTheme.colorScheme.onPrimary // 指示器颜色使用主色上的颜色
) // 加载指示器结束
对于超过5秒的等待,仍然使用传统的线性或圆形进度指示器,但它们也获得了更新------变得更粗,并且采用波浪形而非平面形状。
10.3 Split Button:一分为二的智能按钮
Split Button是另一个Expressive新组件,它将一个主要操作和一个相关的次要操作合并到一个视觉容器中。左侧是主操作按钮(带图标和/或文字),右侧是一个动画箭头,点击后展开更多选项。
在Reply中,"发送"按钮可以是Split Button------点击左侧直接发送,点击右侧箭头则展开"定时发送""保存草稿"等选项。
10.4 在Compose中使用图标
对于需要Fill状态切换的场景(比如星标图标):
kotlin
Icon( // 创建图标组件
imageVector = if (isStarred) Icons.Filled.Star else Icons.Outlined.Star, // 根据是否星标选择填充或描边星形图标
contentDescription = if (isStarred) "取消星标" else "添加星标", // 根据状态提供不同的无障碍描述
tint = if (isStarred) MaterialTheme.colorScheme.primary // 如果已星标,使用主色
else MaterialTheme.colorScheme.onSurfaceVariant // 否则使用表面变体上的颜色
) // 图标组件结束
注意tint参数------图标的颜色应该始终来自MaterialTheme.colorScheme,而不是硬编码。这样图标才能随着主题变化自动调整。
十一、把它们组装起来
现在我们已经覆盖了Material 3 Expressive的核心概念和组件。让我们回到Reply应用,看看这些元素如何协同工作。
应用的整体主题由MaterialTheme包裹:
kotlin
@Composable // 声明这是一个 Compose 可组合函数
fun ReplyTheme( // 定义主题函数 ReplyTheme
darkTheme: Boolean = isSystemInDarkTheme(), // 是否深色主题,默认跟随系统
dynamicColor: Boolean = true, // 是否启用动态颜色,默认开启
content: @Composable () -> Unit // 要包裹的界面内容
) { // 函数体开始
val colorScheme = when { // 计算颜色方案
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { // 开启动态颜色且 Android 12+
val context = LocalContext.current // 获取当前 Context
if (darkTheme) dynamicDarkColorScheme(context) // 深色动态方案
else dynamicLightColorScheme(context) // 浅色动态方案
} // 分支结束
darkTheme -> DarkColorScheme // 手写深色方案
else -> LightColorScheme // 手写浅色方案
} // when 结束
MaterialTheme( // 应用 Material 3 主题
colorScheme = colorScheme, // 注入颜色方案
typography = AppTypography, // 注入排版方案
shapes = AppShapes, // 注入形状方案
content = content // 注入内容
) // MaterialTheme 结束
} // ReplyTheme 结束
然后在MainActivity中使用:
kotlin
class MainActivity : ComponentActivity() { // 定义主 Activity,继承 ComponentActivity
override fun onCreate(savedInstanceState: Bundle?) { // 重写 onCreate 生命周期方法
super.onCreate(savedInstanceState) // 调用父类实现,完成系统初始化
setContent { // 设置 Compose 内容
ReplyTheme { // 用 ReplyTheme 包裹整个应用界面
ReplyApp() // 调用 ReplyApp 可组合函数,展示邮件应用
} // ReplyTheme 结束
} // setContent 结束
} // onCreate 结束
} // MainActivity 结束
ReplyApp中包含了邮件列表页和详情页的导航。列表页使用Card展示邮件,详情页展示完整的邮件内容和回复按钮。底部导航栏在列表页和"已加星标"页之间切换。FAB展开为FAB Menu,提供多个邮件操作。
当你运行这个应用时,你会注意到几件事:
颜色的层次感是自然形成的------你不需要手动调整每个元素的颜色,它们之间的关系由角色系统自动维护。
排版的可读性在浅色和深色模式下都很好------色调系统保证了文字和背景之间的对比度。
圆角的一致性贯穿始终------按钮、卡片、对话框都遵循同一套形状规则。
而在Expressive的加持下,每一次交互都带有弹簧般的生命力------按钮按下时微微缩小、松开时带着俏皮的回弹;导航栏切换时图标形状平滑变形;FAB展开时菜单项像弹簧一样依次弹出。这些微小的动效让应用从"能用"变成了"好用且有乐趣"。
如果你在支持动态颜色的设备上运行,换一张壁纸,整个应用的配色会随之改变,而你的代码没有做任何处理。
十二、本课要点回顾
这一课我们从Reply应用的案例出发,认识了Material 3 Expressive的几个核心领域,也感受了它的设计风格:
设计理念------Material 3 Expressive是Material 3的扩展包,以Material You为基础,专注于情感化设计和表现力。Google的研究表明,遵循Expressive指南的设计能让用户任务完成速度提高20%,定位正确元素的速度提高33%。
颜色系统------五组色调调色板、十三个亮度级别、语义化的颜色角色。动态颜色进一步降低了定制门槛,让应用"像用户"。Expressive还增加了三档对比度控制级别。
排版系统------简化到五种核心样式,新增Emphasized排版用于表达情感状态和视觉焦点,支持可变字体动态调整。
形状系统------从6种基础形状扩展到35种,配合形状变形动画,为界面提供更丰富的视觉表达。
动效系统------这是Expressive最核心的变革。弹簧物理取代传统缓动曲线,stiffness、damping、velocity三个参数控制动画手感,空间弹簧和效果弹簧分工协作,配合触觉反馈创造多感官体验。
组件生态------新增ButtonGroup、FAB Menu、LoadingIndicator、Split Button等15个组件,所有组件共享主题的颜色、排版和形状,并带有Expressive动效。
高度系统------用色调叠加替代阴影来表达层级,让浮起效果在不同背景下都能保持视觉一致性。
下一课我们将深入Material 3 Expressive的动效细节,看看弹簧物理系统如何精确控制动画手感,以及如何为不同场景选择合适的运动模式。但在此之前,建议你先在Android Studio中运行Reply示例,亲手改一改颜色、字体、圆角和动效参数,感受一下Material 3 Expressive的主题系统是如何把零散的样式参数收拢成一个统一的体验的。
设计系统的价值不在于它提供了多少组件,而在于它让好的默认值成为常态。你不需要成为设计专家就能做出好看的应用------这就是Material 3 Expressive想帮你实现的事。
十三、练习题:把知识变成手感
练习1:自定义一套颜色方案,并用到邮件卡片上
题目 :在 Color.kt 中定义品牌色,在 Theme.kt 中构建浅色方案,然后写一个 EmailCard,要求卡片背景用 surfaceVariant,发件人姓名用 onSurfaceVariant,头像背景用 primaryContainer,首字母用 onPrimaryContainer。
参考答案代码:
kotlin
// Color.kt
val ReplyBlue = Color(0xFF1A73E8) // 品牌主色:偏亮的蓝色
val ReplyBlueContainer = Color(0xFFD3E3FD) // 主色容器:浅蓝,用于头像背景
val ReplyOnBlueContainer = Color(0xFF041E49) // 主色容器上的文字:深蓝黑,保证对比度
val ReplySurface = Color(0xFFF8F9FA) // 表面色:接近白色的页面背景
val ReplySurfaceVariant = Color(0xFFE8EAED) // 表面变体:卡片背景,与页面背景微妙区分
val ReplyOnSurface = Color(0xFF1F1F1F) // 表面上的文字:深灰黑
val ReplyOnSurfaceVariant = Color(0xFF444746) // 表面变体上的文字:中深灰
// Theme.kt
private val LightColorScheme = lightColorScheme( // 构建浅色颜色方案
primary = ReplyBlue, // 主色:用于 FAB、主要按钮、选中状态
onPrimary = Color.White, // 主色上的文字/图标:白色
primaryContainer = ReplyBlueContainer, // 主色容器:用于头像、Filled Tonal 按钮
onPrimaryContainer = ReplyOnBlueContainer, // 主色容器上的文字/图标:深蓝黑
surface = ReplySurface, // 表面色:页面背景
onSurface = ReplyOnSurface, // 表面上的文字/图标:深灰黑
surfaceVariant = ReplySurfaceVariant, // 表面变体:卡片、输入框背景
onSurfaceVariant = ReplyOnSurfaceVariant // 表面变体上的文字/图标:中深灰
)
@Composable
fun ReplyTheme(content: @Composable () -> Unit) { // 定义应用主题
MaterialTheme( // 应用 Material 3 主题
colorScheme = LightColorScheme, // 注入颜色方案
typography = AppTypography, // 注入排版方案
shapes = AppShapes, // 注入形状方案
content = content // 注入界面内容
)
}
// EmailCard.kt
@Composable
fun EmailCard(email: Email) { // 邮件卡片组件
Card( // 创建 Material 3 卡片
modifier = Modifier.fillMaxWidth(), // 宽度填满父容器
colors = CardDefaults.cardColors( // 配置卡片颜色
containerColor = MaterialTheme.colorScheme.surfaceVariant // 背景用表面变体色
)
) {
Row( // 横向排列头像和文字
modifier = Modifier.padding(16.dp), // 内边距 16dp
verticalAlignment = Alignment.CenterVertically // 垂直居中
) {
Box( // 头像容器
modifier = Modifier
.size(40.dp) // 尺寸 40dp
.background( // 设置背景
color = MaterialTheme.colorScheme.primaryContainer, // 主色容器
shape = CircleShape // 圆形
),
contentAlignment = Alignment.Center // 内容居中
) {
Text( // 显示发件人名字首字母
text = email.sender.firstName.first().toString(), // 取第一个字符
style = MaterialTheme.typography.titleLarge, // 标题样式
color = MaterialTheme.colorScheme.onPrimaryContainer // 主色容器上的颜色
)
}
Spacer(Modifier.width(16.dp)) // 头像和文字之间留 16dp
Column(modifier = Modifier.weight(1f)) { // 文字列占满剩余宽度
Text( // 发件人姓名
text = email.sender.firstName,
style = MaterialTheme.typography.labelMedium, // 辅助标签样式
color = MaterialTheme.colorScheme.onSurfaceVariant // 表面变体上的文字色
)
Text( // 邮件主题
text = email.subject,
style = MaterialTheme.typography.titleLarge, // 列表标题样式
modifier = Modifier.padding(vertical = 4.dp) // 上下留 4dp
)
Text( // 正文预览
text = email.body,
maxLines = 2, // 最多两行
overflow = TextOverflow.Ellipsis, // 超出省略
style = MaterialTheme.typography.bodyLarge, // 正文样式
color = MaterialTheme.colorScheme.onSurfaceVariant // 表面变体上的文字色
)
}
}
}
}
解读 :这道题训练的是"颜色角色"思维。你没有在组件里写任何 Color(0xFF...),而是全部通过 MaterialTheme.colorScheme 获取。好处是:以后切换深色模式或动态颜色,EmailCard 一行都不用改。头像用 primaryContainer 而不是 primary,是因为头像不需要最高强调,容器色更柔和。
练习2:用弹簧动画让按钮"活"起来
题目 :创建一个按钮,按下时缩小到0.95倍,松开时用弹簧动画弹回1倍。使用 Spring.DampingRatioMediumBouncy 和 Spring.StiffnessMedium。
参考答案代码:
kotlin
var isPressed by remember { mutableStateOf(false) } // 保存按钮是否被按下
val scale by animateFloatAsState( // 创建缩放动画状态
targetValue = if (isPressed) 0.95f else 1f, // 按下时缩小到 0.95,松开时恢复
animationSpec = spring( // 使用弹簧动画规格
dampingRatio = Spring.DampingRatioMediumBouncy, // 中等弹跳阻尼,回弹明显但不过分
stiffness = Spring.StiffnessMedium // 中等刚度,动画速度适中
), // 弹簧规格结束
label = "buttonScale" // 动画标签
) // 动画状态结束
Box( // 创建可缩放容器
modifier = Modifier // 修饰符开始
.scale(scale) // 应用缩放动画
.pointerInput(Unit) { // 添加手势处理
detectTapGestures( // 检测点击手势
onPress = { // 按下回调
isPressed = true // 设为按下状态
awaitRelease() // 等待释放
isPressed = false // 恢复状态
} // 按下回调结束
) // 手势检测结束
} // 手势处理结束
) { // Box 内容
FilledButton(onClick = { /* 操作 */ }) { // 填充按钮
Text("按下我") // 按钮文字
} // 按钮结束
} // Box 结束
解读 :这是Material 3 Expressive动效系统的入门练习。spring函数是核心------dampingRatio控制弹跳程度,stiffness控制动画速度。DampingRatioMediumBouncy会让按钮在松开时有一个明显的回弹效果,这就是Expressive所追求的"让交互感觉有生命力"。
练习3:用形状变形实现头像切换
题目 :创建一个头像,默认是圆形,点击后平滑变形为圆角方形。使用 animateFloatAsState 控制圆角变化。
参考答案代码:
kotlin
var isSelected by remember { mutableStateOf(false) } // 保存选中状态
val cornerPercent by animateFloatAsState( // 创建圆角动画
targetValue = if (isSelected) 0.3f else 0.5f, // 选中时圆角比例 0.3(圆角方形),未选中 0.5(圆形)
animationSpec = spring( // 弹簧动画规格
dampingRatio = Spring.DampingRatioLowBouncy, // 低弹跳,变形更柔和
stiffness = Spring.StiffnessLow // 低刚度,变形速度较慢更优雅
), // 弹簧规格结束
label = "cornerMorph" // 动画标签
) // 动画结束
Box( // 创建头像容器
modifier = Modifier // 修饰符开始
.size(48.dp) // 尺寸 48dp
.clip(RoundedCornerShape(percent = (cornerPercent * 100).toInt())) // 用百分比控制圆角形状变形
.background(MaterialTheme.colorScheme.primaryContainer) // 主色容器背景
.clickable { isSelected = !isSelected }, // 点击切换选中状态
contentAlignment = Alignment.Center // 内容居中
) { // Box 内容
Text( // 头像文字
text = "A", // 示例首字母
style = MaterialTheme.typography.titleLarge, // 标题样式
color = MaterialTheme.colorScheme.onPrimaryContainer // 主色容器上的颜色
) // 文字结束
} // Box 结束
解读 :形状变形是Material 3 Expressive的标志性特征之一。通过动画化圆角百分比,头像可以在圆形和方形之间平滑过渡。Spring.DampingRatioLowBouncy让变形更柔和,RoundedCornerShape(percent)是Compose中实现形状变形的简洁方式。Expressive的扩展形状库提供了更多形状选项,但即使只用基础的圆角变化,也能创造出很好的变形效果。
练习4:用Button Group组织操作按钮
题目 :使用 ButtonGroup 创建一组"回复""转发""标记"三个按钮,分别使用连接形状的 leading、middle、trailing 样式。
参考答案代码:
kotlin
ButtonGroup( // 创建 Material 3 Expressive 按钮组
modifier = Modifier // 修饰符开始
.fillMaxWidth() // 宽度填满
.padding(horizontal = 16.dp) // 水平内边距 16dp
) { // 按钮组内容
FilledTonalButton( // 第一个按钮:回复
onClick = { /* 回复邮件 */ }, // 点击回调
shape = ButtonGroupDefaults.connectedLeadingButtonShape // 左侧按钮形状------左圆右方
) { // 按钮内容
Icon(Icons.Default.Reply, contentDescription = null) // 回复图标
Spacer(Modifier.width(8.dp)) // 图标和文字间距
Text("回复") // 按钮文字
} // 按钮结束
FilledTonalButton( // 第二个按钮:转发
onClick = { /* 转发邮件 */ }, // 点击回调
shape = ButtonGroupDefaults.connectedMiddleButtonShape // 中间按钮形状------方形
) { // 按钮内容
Icon(Icons.Default.Forward, contentDescription = null) // 转发图标
Spacer(Modifier.width(8.dp)) // 图标和文字间距
Text("转发") // 按钮文字
} // 按钮结束
FilledTonalButton( // 第三个按钮:标记
onClick = { /* 标记邮件 */ }, // 点击回调
shape = ButtonGroupDefaults.connectedTrailingButtonShape // 右侧按钮形状------右圆左方
) { // 按钮内容
Icon(Icons.Default.Star, contentDescription = null) // 星标图标
Spacer(Modifier.width(8.dp)) // 图标和文字间距
Text("标记") // 按钮文字
} // 按钮结束
} // 按钮组结束
解读 :ButtonGroup 是Material 3 Expressive引入的全新组件。它不是简单地把按钮排在一起,而是通过 connectedLeadingButtonShape、connectedMiddleButtonShape、connectedTrailingButtonShape 让三个按钮形成一个视觉上连贯的整体。当用户触摸组内按钮时,会触发形状变形动画,带来即时的视觉反馈。这是Expressive"表现力"理念的完美体现------功能不变,但感觉完全不同。
练习5:底部导航栏选中时切换图标填充
题目 :实现一个底部导航栏,两个项:"收件箱"和"已加星标"。选中时图标用 Filled,未选中时用 Outlined。用 remember 保存当前选中项。
参考答案代码:
kotlin
var selectedItem by remember { mutableIntStateOf(0) } // 保存当前选中项:0 收件箱,1 星标
NavigationBar( // Material 3 底部导航栏
containerColor = MaterialTheme.colorScheme.surface // 背景用表面色
) {
NavigationBarItem( // 第一个导航项:收件箱
selected = selectedItem == 0, // 当前选中 0 时高亮
onClick = { selectedItem = 0 }, // 点击后选中 0
icon = {
Icon(
imageVector = if (selectedItem == 0) Icons.Filled.Home else Icons.Outlined.Home, // 选中填充,未选中描边
contentDescription = "收件箱" // 无障碍描述
)
},
label = { Text("收件箱") } // 标签文字
)
NavigationBarItem( // 第二个导航项:已加星标
selected = selectedItem == 1, // 当前选中 1 时高亮
onClick = { selectedItem = 1 }, // 点击后选中 1
icon = {
Icon(
imageVector = if (selectedItem == 1) Icons.Filled.Star else Icons.Outlined.Star, // 选中填充,未选中描边
contentDescription = "已加星标" // 无障碍描述
)
},
label = { Text("已加星标") } // 标签文字
)
}
解读 :Material 3 的导航项通过 selected 自动处理颜色、指示器和高亮。图标在 Filled 和 Outlined 之间切换,是一种低成本但很有效的状态反馈。在Material 3 Expressive中,这个切换还会伴随着弹簧动画和形状变形,让状态变化更加生动。remember { mutableIntStateOf(0) } 保存选中状态,配置变化时如果希望保留,可以换成 rememberSaveable。
练习6:动态颜色优先,手动方案回退
题目 :写一个 ReplyTheme,要求 Android 12 及以上且开启动态颜色时使用动态方案,否则深色模式用手写深色方案,浅色模式用手写浅色方案。
参考答案代码:
kotlin
@Composable
fun ReplyTheme( // 应用主题
darkTheme: Boolean = isSystemInDarkTheme(), // 是否深色主题,默认跟随系统
dynamicColor: Boolean = true, // 是否启用动态颜色,默认开启
content: @Composable () -> Unit // 要包裹的界面内容
) {
val colorScheme = when { // 根据条件选择颜色方案
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { // 开启动态颜色且 Android 12+
val context = LocalContext.current // 获取当前 Context
if (darkTheme) dynamicDarkColorScheme(context) // 动态深色方案
else dynamicLightColorScheme(context) // 动态浅色方案
}
darkTheme -> DarkColorScheme // 手写深色方案
else -> LightColorScheme // 手写浅色方案
}
MaterialTheme( // 应用 Material 3 主题
colorScheme = colorScheme, // 注入颜色方案
typography = AppTypography, // 注入排版方案
shapes = AppShapes, // 注入形状方案
content = content // 注入内容
)
}
解读 :这是 Material 3 主题配置的标准写法。动态颜色让应用"像用户",手动方案保证低版本和品牌一致性。when 的顺序很重要:先判断动态颜色,再判断深色模式,最后回退到浅色。这样逻辑清晰,也不会出现"明明开了动态颜色却走了手动方案"的问题。
练习7:用 tonalElevation 表达表面浮起
题目 :创建一个 Surface,设置 tonalElevation = 5.dp,背景用 surface,里面放一段文字。再写一个 tonalElevation = 0.dp 的版本,对比效果。
参考答案代码:
kotlin
Surface( // 创建表面容器
tonalElevation = 5.dp, // 色调高度 5dp:表面会叠加约 5% 主色调,表达轻微浮起
color = MaterialTheme.colorScheme.surface, // 表面颜色用主题表面色
shape = MaterialTheme.shapes.medium, // 中等圆角
modifier = Modifier.fillMaxWidth() // 宽度填满
) {
Text( // 表面内的文字
text = "这条表面浮起了 5dp", // 文字内容
modifier = Modifier.padding(16.dp), // 内边距 16dp
style = MaterialTheme.typography.bodyLarge // 正文样式
)
}
Surface( // 对比:不浮起的表面
tonalElevation = 0.dp, // 色调高度 0dp:贴在背景上
color = MaterialTheme.colorScheme.surface, // 同样的表面色
shape = MaterialTheme.shapes.medium, // 同样的圆角
modifier = Modifier.fillMaxWidth() // 宽度填满
) {
Text( // 表面内的文字
text = "这条表面贴在背景上", // 文字内容
modifier = Modifier.padding(16.dp), // 内边距 16dp
style = MaterialTheme.typography.bodyLarge // 正文样式
)
}
解读 :Material 3 用"色调叠加"代替 Material 2 的阴影来表达高度。tonalElevation = 5.dp 不会让表面变黑,而是叠加一层很淡的主色调,看起来"离用户更近"。这种效果在不同背景、深色模式下都更稳定,不会像阴影那样在深色背景上显得脏。
做完这七道题,你应该已经亲手摸过 Material 3 Expressive 的颜色、排版、形状、动效、按钮、导航栏、动态主题和高度系统了。下一课我们会深入更复杂的组件组合和动效定制,但只要你把这七道题里的 "角色思维" 、 "主题思维" 和 "弹簧思维" 记住,后面的组件都只是同一套逻辑的延伸。