Codelab
Jetpack Compose 主题设置
一个 Material 主题由颜色、排版和形状属性组成。如果您自定义这些属性,相应设置会自动反映在您用来构建应用的组件中
Material 主题设置
- Material 颜色工具
- Material字体比例生成器工具
- Material形状自定义工具
- Material 默认采用“基准”主题
定义主题
- 在 Jetpack Compose 中实现主题设置的核心元素是
MaterialTheme
可组合项。如果将此可组合项放在 Compose 层次结构中,您就可以为其中的所有组件指定颜色、字体和形状的自定义设置
-
创建主题
@Composable fun JetnewsTheme(content: @Composable () -> Unit) { MaterialTheme(content = content) }
-
颜色
-
ompose 中的颜色是使用
Color
类定义的。借助多个构造函数,您可以将颜色指定为ULong
,也可以按单独的颜色通道来指定颜色val Red700 = Color(0xffdd0d3c) val Red800 = Color(0xffd00036) val Red900 = Color(0xffc20029)
注意:若要从用于指定颜色的常用“#dd0d3c”格式进行转换,请将“#”替换为“0xff”,即
Color(0xffdd0d3c)
,其中前两位表示透明度,“ff”表示完整的 Alpha 值,即不透明注意:在定义颜色时,我们要根据颜色值“照字面意义”命名颜色,而不要“从语义上”命名颜色。例如,命名为
Red500
而不是primary
。这样一来,我们就可以定义多个主题。例如,在深色主题中或样式设置不同的屏幕上,系统可能会将另一种颜色视为primary
-
使用 [
lightColors
](https://developer.android.google.cn/reference/kotlin/androidx/compose/material/package-summary#lightColors(androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color)) 函数来构建Colors
,这样即可提供合理的默认值,让我们不必将构成 Material 调色板的所有颜色全都指定出来private val LightColors = lightColors( primary = Red700, primaryVariant = Red900, onPrimary = Color.White, secondary = Red700, secondaryVariant = Red900, onSecondary = Color.White, error = Red800 )
-
-
排版
-
Compose 目前不支持 Android 的可下载字体功能
-
利用
FontFamily
(结合了每个Font
的不同粗细)定义字体private val Montserrat = FontFamily( Font(R.font.montserrat_regular), Font(R.font.montserrat_medium, FontWeight.W500), Font(R.font.montserrat_semibold, FontWeight.W600) )
-
利用
Typography
定义排版val JetnewsTypography = Typography( h4 = TextStyle( fontFamily = Montserrat, fontWeight = FontWeight.W600, fontSize = 30.sp ), subtitle1 = TextStyle( fontFamily = Montserrat, fontWeight = FontWeight.W600, fontSize = 16.sp ), ... )
-
-
形状
-
Compose 提供了
RoundedCornerShape
类和CutCornerShape
类,可用于定义形状主题val JetnewsShapes = Shapes( small = CutCornerShape(topStart = 8.dp), medium = CutCornerShape(topStart = 24.dp), large = RoundedCornerShape(8.dp) )
-
-
深色主题
- Material 提供了关于如何创建深色主题的设计指南
-
添加颜色
val Red200 = Color(0xfff297a2) val Red300 = Color(0xffea6d7e)
-
定义颜色集合
private val DarkColors = darkColors( primary = Red300, primaryVariant = Red700, onPrimary = Color.Black, secondary = Red300, onSecondary = Color.Black, error = Red200 )
-
定义(或更新)主题
@Composable fun JetnewsTheme( // 默认设为查询设备的全局设置,但也可以通过传参修改为特定值 darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit ) { MaterialTheme( colors = if (darkTheme) DarkColors else LightColors, typography = JetnewsTypography, shapes = JetnewsShapes, content = content ) }
一般深色主题只需要修改颜色即可,排版和形状不变
处理颜色
-
对于自定义主题,所有 Material 组件开箱即可使用这些自定义功能。例如,
FloatingActionButton
可组合项默认使用主题中的secondary
颜色@Composable fun FloatingActionButton( backgroundColor: Color = MaterialTheme.colors.secondary, ... ) {
-
原色
-
在静态声明颜色定义时,请务必小心,因为这些定义会导致更难/无法支持不同的主题(例如,浅色/深色主题)
Surface(color = Color.LightGray) { Text( text = "Hard coded colors don't respond to theme changes :(", // 硬编码,不会随主题而改变 textColor = Color(0xffff00ff) ) }
-
-
主题颜色
-
一种更灵活的方法是从主题中检索颜色,其
colors
属性会返回在MaterialTheme
可组合项中设置的Colors
Surface(color = MaterialTheme.colors.primary)
-
由于主题中的每种颜色都是
Color
实例,因此我们还可以使用copy
方法轻松地“派生”颜色// alpha 不透明度 val derivedColor = MaterialTheme.colors.onSurface.copy(alpha = 0.1f)
-
-
Surface 颜色和内容颜色
-
许多组件都接受一对颜色和“内容颜色(为包含在其中的可组合项提供默认颜色)“
Surface( color: Color = MaterialTheme.colors.surface, contentColor: Color = contentColorFor(color), ... TopAppBar( backgroundColor: Color = MaterialTheme.colors.primarySurface, contentColor: Color = contentColorFor(backgroundColor), ...
contentColorFor
方法可以为任何主题颜色检索适当的“on”颜色,例如,如果您设置primary
背景,它就会返回onPrimary
作为内容颜色。如果您设置非主题背景颜色,则应自行提供合理的内容颜色 -
可以使用
LocalContentColor
来检索与当前背景形成对比的颜色BottomNavigationItem( selectedContentColor = LocalContentColor.current ...
-
当设置任何元素的颜色时,最好使用
Surface
来实现此目的,因为它会设置适当的内容颜色CompositionLocal
值。请慎用直接Modifier.background
调用,这种调用不会设置适当的内容颜色-Row(Modifier.background(MaterialTheme.colors.primary)) { +Surface(color = MaterialTheme.colors.primary) { + Row( ...
-
-
内容 Alpha 值
-
通常情况下,我们希望通过强调或弱化内容来突出重点并体现出视觉上的层次感。Material Design 建议采用不同的不透明度来传达这些不同的重要程度
-
Jetpack Compose 通过
LocalContentAlpha
实现此功能。您可以通过为此CompositionLocal
提供一个值来为层次结构指定内容 Alpha 值,子可组合项可以使用此值。Material 指定了一些标准 Alpha 值(high
、medium
、disabled
),这些值由ContentAlpha
对象建模。请注意,MaterialTheme
默认将LocalContentAlpha
设置为ContentAlpha.high
// By default, both Icon & Text use the combination of LocalContentColor & // LocalContentAlpha. De-emphasize content by setting a different content alpha CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { Text(...) } CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) { Icon(...) Text(...) }
-
-
深色主题
-
检查是否在浅色主题中运行
// 此值由 lightColors/darkColors 构建器函数设置 val isLightTheme = MaterialTheme.colors.isLight
-
在 Material 中,如果采用的是深色主题,则高度较高的 Surface 会获得高度叠加层(其背景颜色会变浅)。在使用深色调色板时,系统会自动实现此效果
Surface( elevation = 2.dp, color = MaterialTheme.colors.surface, // color will be adjusted for elevation ...
默认情况下,
TopAppBar
和Card
的高度分别设为 4dp 和 1dp,因此,在深色主题中,它们的背景颜色会自动变浅,以更好地表现相应高度 -
Material Design 建议避免在深色主题中使用大面积的明亮颜色。一种常见模式是在浅色主题中将容器设为
primary
颜色,并在深色主题中将其设为surface
颜色;许多组件都默认使用此策略,例如应用栏和底部导航栏。为了便于实现,Colors
提供了primarySurface
颜色,以准确完成上述行为,并且这些组件都默认使用此颜色
-
处理文本
-
组件本身往往不会显示文本,而是提供槽 API,让您能够传入
Text
可组合项。那么,组件是如何设置主题排版样式的呢?在后台,它们使用ProvideTextStyle
可组合项(本身就使用CompositionLocal
)来设置“current”TextStyle
。如果您未提供具体的textStyle
参数,Text
可组合项会默认查询此“current”样式@Composable fun Button( // many other parameters content: @Composable RowScope.() -> Unit ) { ... ProvideTextStyle(MaterialTheme.typography.button) { //set the "current" text style ... content() } } @Composable fun Text( // many, many parameters style: TextStyle = LocalTextStyle.current // get the value set by ProvideTextStyle ) { ...
-
主题文本样式
-
MaterialTheme.typography
会检索在MaterialTheme
可组合项中设置的Typography
实例,让您能够使用自己定义的样式 -
如果您需要自定义
TextStyle
,可以对其执行copy
操作并替换相关属性(它只是一个data class
),或者让Text
可组合项接受大量样式参数,这些参数会叠加到任何TextStyle
的上层Text( text = "Hello World", style = MaterialTheme.typography.body1.copy( background = MaterialTheme.colors.secondary ) )
Text( text = "Hello World", style = MaterialTheme.typography.subtitle2, fontSize = 22.sp // explicit size overrides the size in the style )
TopAppBar
将其title
的样式设为h6
,而ListItem
将其主要文本和辅助文本的样式分别设为subtitle1
和body2
-
-
多种样式
-
如果需要对某些文本应用多种样式,可以使用
AnnotatedString
类来应用标记,从而为一系列文本添加SpanStyle
。您可以动态添加这些元素,也可以使用 DSL 语法来创建内容val text = buildAnnotatedString { append("This is some unstyled text\n") withStyle(SpanStyle(color = Color.Red)) { append("Red text\n") } withStyle(SpanStyle(fontSize = 24.sp)) { append("Large text") } }
可以使用
MaterialTheme.typography.xxx.toSpanStyle().copy()
定义与主题类似的SpanStyle
-
处理形状
-
与颜色一样,Material 组件使用默认参数,因此您可以直接查看组件将要使用的形状类别,或提供替代方案。如需查看组件和形状类别的完整对应关系,请参阅此文档
@Composable fun Button( ... shape: Shape = MaterialTheme.shapes.small ) {
请注意,有些组件会使用经过修改的主题形状,以适应其上下文的要求,例如
TextField
-
创建自己的组件时,可以自行使用各种形状;为此,需要使用接受形状的可组合项或
Modifier
(例如,Surface
、Modifier.clip
、Modifier.background
、Modifier.border
等)
组件“样式”
- 所有组件都是由较低级别的构建块构造而成的,可以参考各种组件的源码使用同样的构建块来自定义 Material 组件库
Jetpack Compose 动画
为简单的值变化添加动画效果
animate*AsState
API
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PYENmI3x-1668352183140)(https://api.onedrive.com/v1.0/shares/s!AoF7mN_tKB-G0B_4ymnWAKSURzoi/root/content)]
-
举例
val backgroundColor = if (tabPage == TabPage.Home) Purple100 else Green300
其中,
tabPage
是由State
对象支持的一项Int
。背景颜色可以在紫色和绿色之间切换,具体取决于其值。如需为诸如此类的简单值变化添加动画效果,我们可以使用animate*AsState
API。使用animate*AsState
可组合项的相应变体(在本例中为animateColorAsState
)封装更改值,即可创建动画值。返回的值是State<T>
对象,因此我们可以使用包含by
声明的本地委托属性,以将该值视为普通变量val backgroundColor by animateColorAsState(if (tabPage == TabPage.Home) Purple100 else Green300)
为可见性添加动画效果
-
为可见性变化添加动画效果使用
AnimatedVisibility
可组合项,每次指定的Boolean
值发生变化时,AnimatedVisibility
会运行其动画。默认情况下,AnimatedVisibility
会以淡入和展开的方式显示元素,以淡出和缩小的方式隐藏元素,也可以自定义行为AnimatedVisibility(extended) { Text( text = stringResource(R.string.edit), modifier = Modifier .padding(start = 8.dp, top = 3.dp) ) }
-
使用
AnimatedVisibility
为进入和消失添加动画效果,可以使用slideInVertically
函数为进入过渡创建EnterTransition
,使用slideOutVertically
函数为退出过渡创建ExitTransition
。slideInVertically
和slideOutVertically
的默认行为只使用项高度的一半,也就是说只有一半有动画效果AnimatedVisibility( visible = shown, enter = slideInVertically(), exit = slideOutVertically() )
-
对于进入过渡:我们可以通过设置
initialOffsetY
参数来调整默认行为,以便使用项的完整高度来正确添加动画效果。initialOffsetY
应该是返回初始位置的 lambda。lambda 会收到一个表示元素高度的参数。为确保项从屏幕顶部滑入,我们会返回其负值,因为屏幕顶部的值为 0。我们希望动画从
-height
开始到0
(其最终静止位置),以便其从屏幕上方开始以动画形式滑入。使用
slideInVertically
时,滑入后的目标偏移量始终为0
(像素)。可使用 lambda 函数将initialOffsetY
指定为绝对值或元素全高度的百分比。同样,
slideOutVertically
假定初始偏移量为 0,因此只需指定targetOffsetY
AnimatedVisibility( visible = shown, enter = slideInVertically( // Enters by sliding down from offset -fullHeight to 0. initialOffsetY = { fullHeight -> -fullHeight } ), exit = slideOutVertically( // Exits by sliding up from offset 0 to -fullHeight. targetOffsetY = { fullHeight -> -fullHeight } ) )
-
可以使用
animationSpec
参数进一步自定义动画效果。animationSpec
是包括EnterTransition
和ExitTransition
在内的许多动画 API 的通用参数。可以传递各种AnimationSpec
类型中的一种,以指定动画值应如何随时间变化AnimatedVisibility( visible = shown, enter = slideInVertically( // Enters by sliding down from offset -fullHeight to 0. initialOffsetY = { fullHeight -> -fullHeight }, animationSpec = tween(durationMillis = 150, easing = LinearOutSlowInEasing) ), exit = slideOutVertically( // Exits by sliding up from offset 0 to -fullHeight. targetOffsetY = { fullHeight -> -fullHeight }, animationSpec = tween(durationMillis = 250, easing = FastOutLinearInEasing) ) )
在本示例中,我们使用基于时长的简单
AnimationSpec
。它可以使用tween
函数创建。时长为 150 毫秒,加/减速选项为LinearOutSlowInEasing
。对于退出动画,我们为animationSpec
参数使用相同的tween
函数,但时长为 250 毫秒,加/减速选项为FastOutLinearInEasing
为内容大小变化添加动画效果
-
可以添加
animateContentSize
修饰符,为大小变化添加动画效果,也可以使用自定义animationSpec
来自定义animateContentSize
Column( modifier = Modifier .fillMaxWidth() .padding(16.dp) .animateContentSize() ) { // ... the title and the body }
为多个值添加动画效果
Transition
API
借助该 API,可以制作更复杂的动画。可以使用 Transition
API 跟踪 Transition
上的所有动画何时完成,而使用前述各个 animate*AsState
API 却无法做到这一点。Transition
API 还让我们能够在不同状态之间转换时定义不同的 transitionSpec
-
如需同时为多个值添加动画效果,可使用
Transition
。可使用updateTransition
函数创建Transition
,每个动画值都可以使用Transition
的animate*
扩展函数进行声明val transition = updateTransition(tabPage, label = "Tab indicator") val indicatorLeft by transition.animateDp(label = "Indicator left") { page -> tabPositions[page.ordinal].left } val indicatorRight by transition.animateDp(label = "Indicator right") { page -> tabPositions[page.ordinal].right } val color by transition.animateColor(label = "Border color") { page -> if (page == TabPage.Home) Purple700 else Green800 }
更改
tabPage
状态的值时,与transition
关联的所有动画值会开始以动画方式切换至为目标状态指定的值 -
可以指定
transitionSpec
参数来自定义动画行为val transition = updateTransition( tabPage, label = "Tab indicator" ) val indicatorLeft by transition.animateDp( transitionSpec = { if (TabPage.Home isTransitioningTo TabPage.Work) { // Indicator moves to the right. // The left edge moves slower than the right edge. spring(stiffness = Spring.StiffnessVeryLow) } else { // Indicator moves to the left. // The left edge moves faster than the right edge. spring(stiffness = Spring.StiffnessMedium) } }, label = "Indicator left" ) { page -> tabPositions[page.ordinal].left } val indicatorRight by transition.animateDp( transitionSpec = { if (TabPage.Home isTransitioningTo TabPage.Work) { // Indicator moves to the right // The right edge moves faster than the left edge. spring(stiffness = Spring.StiffnessMedium) } else { // Indicator moves to the left. // The right edge moves slower than the left edge. spring(stiffness = Spring.StiffnessVeryLow) } }, label = "Indicator right" ) { page -> tabPositions[page.ordinal].right } val color by transition.animateColor( label = "Border color" ) { page -> if (page == TabPage.Home) Purple700 else Green800 }
可以让靠近目标页面的一边比另一边移动得更快来实现指示器的弹性效果。可以在
transitionSpec
lambda 中使用isTransitioningTo
infix 函数来确定状态变化的方向 -
Android Studio 支持在 Compose 预览中检查过渡效果。如需使用动画预览,请在预览中点击可组合项右上角的“Start Animation Preview”图标(
图标),以开始交互模式。如果找不到该图标,则应按照此处的说明,在实验设置中启用此功能。尝试点击
PreviewHomeTabBar
可组合项的图标。系统随即会打开一个新的“Animations”窗格。可以点击“Play”图标按钮来播放动画,也可以拖动拖动条来查看各个动画帧。为了更好地描述动画值,可在
updateTransition
和animate*
方法中指定label
参数。
重复呈现动画效果
InfiniteTransition
API
此 API 与上一部分中的 Transition
API 类似。两者都是为多个值添加动画效果,但 Transition
会根据状态变化为值添加动画效果,而 InfiniteTransition
则无限期地为值添加动画效果
-
使用
rememberInfiniteTransition
函数创建InfiniteTransition
,可以使用InfiniteTransition
的一个animate*
扩展函数声明每个动画值变化。还可以为此动画指定AnimationSpec
,但此 API 仅接受InfiniteRepeatableSpec
,可以使用infiniteRepeatable
函数创建一个,此AnimationSpec
会封装任何基于时长的AnimationSpec
,使其可重复val infiniteTransition = rememberInfiniteTransition() val alpha by infiniteTransition.animateFloat( initialValue = 0f, targetValue = 1f, animationSpec = infiniteRepeatable( animation = keyframes { durationMillis = 1000 // 多个关键帧 0.7f at 500 0.9f at 800 }, repeatMode = RepeatMode.Reverse ) )
repeatMode
的默认值为RepeatMode.Restart
,这会从initialValue
过渡为targetValue
,并再次从initialValue
开始。将repeatMode
设置为RepeatMode.Reverse
后,动画会从initialValue
播放到targetValue
,然后从targetValue
播放到initialValue
keyFrames
动画是另一种类型的animationSpec
(另外还有一些是tween
和spring
),可允许以不同的毫秒数来更改播放中的值。最初将durationMillis
设置为 1000 毫秒。然后,我们可以在动画中定义关键帧,例如,在动画播放 500 毫秒时,我们希望 alpha 值为 0.7f。这会更改动画的播放进度:动画在 500 毫秒内会从 0 快速播放到 0.7,而在 500 毫秒到 1000 毫秒之间会从 0.7 慢速播放到 1.0
手势动画
private fun Modifier.swipeToDismiss(
onDismissed: () -> Unit
): Modifier = composed {
// This Animatable stores the horizontal offset for the element.
val offsetX = remember { Animatable(0f) }
pointerInput(Unit) {
// Used to calculate a settling position of a fling animation.
val decay = splineBasedDecay<Float>(this)
// Wrap in a coroutine scope to use suspend functions for touch events and animation.
coroutineScope {
while (true) {
// Wait for a touch down event.
val pointerId = awaitPointerEventScope { awaitFirstDown().id }
// Interrupt any ongoing animation.
offsetX.stop()
// Prepare for drag events and record velocity of a fling.
val velocityTracker = VelocityTracker()
// Wait for drag events.
awaitPointerEventScope {
horizontalDrag(pointerId) { change ->
// Record the position after offset
val horizontalDragOffset = offsetX.value + change.positionChange().x
launch {
// Overwrite the Animatable value while the element is dragged.
offsetX.snapTo(horizontalDragOffset)
}
// Record the velocity of the drag.
velocityTracker.addPosition(change.uptimeMillis, change.position)
// Consume the gesture event, not passed to external
change.consumePositionChange()
}
}
// Dragging finished. Calculate the velocity of the fling.
val velocity = velocityTracker.calculateVelocity().x
// Calculate where the element eventually settles after the fling animation.
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
// The animation should end as soon as it reaches these bounds.
offsetX.updateBounds(
lowerBound = -size.width.toFloat(),
upperBound = size.width.toFloat()
)
launch {
if (targetOffsetX.absoluteValue <= size.width) {
// Not enough velocity; Slide back to the default position.
offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
} else {
// Enough velocity to slide away the element to the edge.
offsetX.animateDecay(velocity, decay)
// The element was swiped away.
onDismissed()
}
}
}
}
}
// Apply the horizontal offset to the element.
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
}
Jetpack Compose 中的状态
应用的“状态”是指可以随时间变化的任何值。这是一个非常宽泛的定义,从 Room 数据库到类的变量,全部涵盖在内。所有 Android 应用都会向用户显示状态
Compose 中的状态
- 优秀实践是为所有可组合函数提供默认的
Modifier
,从而提高可重用性。它应作为第一个可选参数显示在参数列表中,位于所有必需参数之后 - 任何会导致状态修改的操作都称为“事件”
Compose 中的事件
-
应用的状态说明了要在界面中显示的内容,而事件则是一种机制,可在状态发生变化时导致界面发生变化
-
所有 Android 应用都有核心界面更新循环,如下所示:
- 事件:由用户或程序的其他部分生成
- 更新状态:事件处理脚本会更改界面所使用的状态
- 显示状态:界面会更新以显示新状态
-
警告:您可能想要在 Logcat 中添加日志来调试可组合函数,以尝试确定这些函数是否正常运行。不过请注意,在使用 Compose 时,此过程并不一定非常可靠。这有多种原因,例如,重组被舍弃,如 Compose 编程思想中所述
可组合函数中的记忆功能
-
Compose 应用通过调用可组合函数将数据转换为界面。组合是指 Compose 在执行可组合项时构建的界面描述。如果发生状态更改,Compose 会使用新状态重新执行受影响的可组合函数,从而创建更新后的界面。这一过程称为“重组”。Compose 还会查看各个可组合项需要哪些数据,以便仅重组数据发生了变化的组件,而避免重组未受影响的组件
-
组合:Jetpack Compose 在执行可组合项时构建的界面描述
-
初始组合:通过首次运行可组合项创建组合
-
重组:在数据发生变化时重新运行可组合项以更新组合
-
-
使用 Compose 的
State
和MutableState
类型让 Compose 能够观察到状态。Compose 会跟踪每个读取状态value
属性的可组合项,并在其value
更改时触发重组 -
可以使用
mutableStateOf
函数来创建可观察的MutableState
,它接受初始值作为封装在State
对象中的参数,这样便可使其value
变为可观察Column(modifier = modifier.padding(16.dp)) { // Changes to count are now tracked by Compose val count: MutableState<Int> = mutableStateOf(0) Text("You've had ${count.value} glasses.") Button(onClick = { count.value++ }, Modifier.padding(top = 8.dp)) { Text("Add one") } }
-
可以使用
remember
可组合内嵌函数,系统会在初始组合期间将由remember
计算的值存储在组合中,并在重组期间一直保持存储的值可以将
remember
视为一种在组合中存储单个对象的机制,就像私有 val 属性在对象中执行的操作一样 -
remember
和mutableStateOf
通常在可组合函数中一起使用Column(modifier = modifier.padding(16.dp)) { val count: MutableState<Int> = remember { mutableStateOf(0) } Text("You've had ${count.value} glasses.") Button(onClick = { count.value++ }, Modifier.padding(top = 8.dp)) { Text("Add one") } }
-
可以使用关键字 by 将
count
定义为 var,通过添加委托的 getter 和 setter 导入内容,可以间接读取count
并将其设置为可变,而无需每次都显式引用MutableState
的value
属性Column(modifier = modifier.padding(16.dp)) { var count by remember { mutableStateOf(0) } Text("You've had $count glasses.") Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) { Text("Add one") } }
-
可能已经在使用其他可观察类型,例如使用 LiveData、StateFlow、Flow 和 RxJava 的 Observable 在应用中存储状态。如需允许 Compose 使用此状态,并在状态发生变化时自动执行重组,您需要将其映射到 State。一些扩展函数可以实现此目的,因此请务必在 Compose 和其他库文档中查询这些函数
状态驱动型界面
-
Compose 是一个声明性界面框架。它描述界面在特定状况下的状态,而不是在状态发生变化时移除界面组件或更改其可见性。调用重组并更新界面后,可组合项最终可能会进入或退出组合。
此方法可避免像针对视图系统那样手动更新视图的复杂性。这也不太容易出错,因为您不会忘记根据新状态更新视图,因为系统会自动执行此过程
-
如果在初始组合期间或重组期间调用了可组合函数,则认为其存在于组合中。未调用的可组合函数(例如,由于该函数在 if 语句内调用且未满足条件)不存在于组合中
-
界面是相对用户而言的,界面状态是相对应用而言的
-
Android Studio 的布局检查器工具可用于检查 Compose 生成的应用布局
如需在检查器中查看 Compose 节点,请使用 API 大于或等于 29 的设备,并且使用的是
Compose 1.2.0-alpha03
或更高版本 -
界面的不同部分可以依赖于相同的状态
组合中的记忆功能
remember
会将对象存储在组合中,而如果在重组期间未再次调用之前调用remember
的来源位置,则会忘记对象
在 Compose 中恢复状态
-
如果更改语言、在深色模式与浅色模式之间切换、改变屏幕方向,或者执行任何导致 Android 重新创建运行中 activity 的其他配置更改,则系统会在配置更改后重新创建 activity,因此已保存状态会被忘记
-
remember
可在重组后保持状态,但不会在配置更改后保持状态;在重新创建 activity 或进程后,可以使用rememberSaveable
恢复界面状态,除了在重组后保持状态之外,rememberSaveable
还会在重新创建 activity 和进程之后保留状态rememberSaveable
会自动保存可保存在Bundle
中的任何值,对于其他值,可以将其传入自定义 Saver 对象。如需详细了解如何在 Compose 中恢复状态,请参阅相关文档
状态提升
-
使用
remember
存储对象的可组合项包含内部状态,这会使该可组合项有状态。在调用方不需要控制状态,并且不必自行管理状态便可使用状态的情况下,“有状态”会非常有用。但是,具有内部状态的可组合项往往不易重复使用,也更难测试。不保存任何状态的可组合项称为无状态可组合项,相应的可组合函数称为无状态函数。如需创建无状态可组合项,一种简单的方法是使用状态提升无状态可组合项是指不具有任何状态的可组合项,这意味着它不会存储、定义或修改新状态
有状态可组合项是一种具有可以随时间变化的状态的可组合项
-
Compose 中的状态提升是一种将状态移至可组合项的调用方以使可组合项无状态的模式。Jetpack Compose 中的常规状态提升模式是将状态变量替换为两个参数
- value: T:要显示的当前值
- onValueChange: (T) -> Unit:请求更改值的事件,其中 T 是建议的新值
状态下降、事件上升的这种模式称为单向数据流 (UDF),而状态提升就是我们在 Compose 中实现此架构的方式。如需了解相关详情,请参阅 Compose 架构文档
-
以这种方式提升的状态具有一些重要的属性
- 单一可信来源:通过移动状态,而不是复制状态,我们可确保只有一个可信来源。这有助于避免 bug
- 可共享:可与多个可组合项共享提升的状态
- 可拦截:无状态可组合项的调用方可以在更改状态之前决定忽略或修改事件
- 分离:无状态可组合函数的状态可以存储在任何位置。例如,存储在 ViewModel 中
-
提升状态时,有三条规则可帮助您弄清楚状态应去向何处:
- 状态应至少提升到使用该状态(读取)的所有可组合项的最低共同父项
- 状态应至少提升到它可以发生变化(写入)的最高级别
- 如果两种状态发生变化以响应相同的事件,它们应提升到同一级别
-
由于可以共享提升的状态,因此请务必仅传递可组合项所需的状态,以避免不必要的重组并提高可重用性,设计可组合项的最佳实践是仅向它们传递所需要的参数
使用列表
-
如果在 Android Studio 的编辑器区域键入
WC
,系统会打开一个建议框。如果您按下Enter
并选择第一个选项,系统会显示可供使用的Column
模板。如需详细了解 Android Studio 中适用于 Compose 的实时模板和其他实用工具,请参阅 Compose 工具文档 -
当一个项退出组合时,系统会忘记之前记住的状态。对于
LazyColumn
上的项,当您滚动至项不可见的位置时,这些不可见的项会完全退出组合,使用rememberSaveable
,因为它采用保存的实例状态机制,可确保存储的值在重新创建 activity 或进程(如项退出组合)之后继续保留 -
状态参数使用由公共
rememberX
函数提供的默认值是内置可组合函数中的常见模式,如LazyColumn
、LazyRow
、Scafflod
@Composable fun LazyColumn( ... state: LazyListState = rememberLazyListState(), ...
可观察的可变列表
-
如需添加从列表中移除任务的行为,需要创建一个可由 Compose 观察的
MutableList
实例(即可观察的且可变的列表),此结构可允许 Compose 跟踪更改,以便在列表中添加或移除项时重组界面 -
扩展函数
toMutableStateList()
用于根据初始可变或不可变的Collection
(例如List
)来创建可观察的MutableList
,或者,也可以使用工厂方法mutableStateListOf
来创建可观察的MutableList
,然后为初始状态添加元素mutableStateOf
函数会返回一个类型为MutableState<T>
的对象mutableStateListOf
和toMutableStateList
函数会返回一个类型为SnapshotStateList
的对象,“可观察的MutableList
”一词表示此类 -
items
方法会接收一个key
参数。默认情况下,每个项的状态均与该项在列表中的位置相对应。在可变列表中,当数据集发生变化时,这会导致问题,因为实际改变位置的项会丢失任何记住的状态,可以指定key
参数来确保项的状态不会因数据集改变而丢失 -
不应使用
rememberSaveable
来存储需要长时间序列化或反序列化操作的大量数据或复杂数据结构,如列表,否则会报错cannot be saved using the current SaveableStateRegistry...
,使用 activity 的onSaveInstanceState
时,应遵循类似的规则请参阅保存界面状态文档
ViewModel 中的状态
-
界面状态描述屏幕上显示的内容,而应用逻辑则描述应用的行为方式以及应如何响应状态变化。逻辑分为两种类型:第一种是界面行为或界面逻辑,第二种是业务逻辑
- 界面逻辑涉及如何在屏幕上显示状态变化(例如导航逻辑或显示信息提示控件)
- 业务逻辑决定如何处理状态更改(例如付款或存储用户偏好设置)。该逻辑通常位于业务层或数据层,但绝不会位于界面层
-
ViewModel 提供界面状态以及对位于应用其他层中的业务逻辑的访问。此外,ViewModel 还会在配置更改后继续保留,因此其生命周期比组合更长。ViewModel 可以遵循 Compose 内容(即 activity 或 fragment)的主机的生命周期,也可以遵循导航图的目的地的生命周期(如果使用的是 Compose Navigation 库)
ViewModel 并不是组合的一部分。因此,不应保留可组合项中创建的状态(例如,记住的值),因为这可能会导致内存泄漏
-
viewModel()
会返回一个现有的ViewModel
,或在给定作用域内创建一个新的 ViewModel。只要作用域处于活动状态,ViewModel 实例就会一直保留。例如,如果在某个 activity 中使用了可组合项,则在该 activity 完成或进程终止之前,viewModel()
会返回同一实例//引入依赖 implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1" @Composable fun WellnessScreen( modifier: Modifier = Modifier, wellnessViewModel: WellnessViewModel = viewModel() ) { ... }
-
ViewModel 在任何情况下(例如,对于系统发起的进程终止)都不会自动保留应用的状态
如需详细了解如何保留应用的界面状态,请参阅相关文档
-
建议将 ViewModel 用于屏幕级可组合项,即靠近从导航图的 activity、fragment 或目的地调用的根可组合项。绝不应将 ViewModel 传递给其他可组合项,而是应当仅向它们传递所需的数据以及以参数形式执行所需逻辑的函数
如需了解详情,请参阅 ViewModel 和状态容器部分以及 Compose 和其他库的相关文档
Jetpack Compose 中的高级状态和附带效应
-
接入Google地图需要获取个人 API 密钥,如“地图”文档中所述,并按如下方式在
local.properties
文件中添加该密钥// local.properties file google.maps.key={insert_your_api_key_here}
从 ViewModel 使用流
-
在可组合函数中使用
StateFlow.collectAsState()
函数时,collectAsState()
会从StateFlow
收集值,并通过 Compose 的状态 API 表示最新值,这样会使读取该状态值的 Compose 代码在发出新项时重组 -
Compose 为最热门的基于数据流的 Android 解决方案提供了 API:
LiveData.observeAsState()
包含在androidx.compose.runtime:runtime-livedata:$composeVersion
工件中Observable.subscribeAsState()
包含在androidx.compose.runtime:runtime-rxjava2:$composeVersion
或androidx.compose.runtime:runtime-rxjava3:$composeVersion
工件中
LaunchedEffect 和 rememberUpdatedState
-
着陆屏幕将占据整个屏幕,并在屏幕中间显示应用的徽标。理想情况下,我们会显示该屏幕,在所有数据加载完毕之后,我们会通知调用方可以使用回调关闭着陆屏幕
-
建议使用 Kotlin 协程在 Android 中执行异步操作,Jetpack Compose 提供了可在界面层中安全使用协程的 API
-
**Compose 中的附带效应是指发生在可组合函数作用域之外的应用状态的变化。**例如,将状态更改为显示/隐藏着陆屏幕的操作将发生在
onTimeout
回调中,由于在调用onTimeout
之前我们需要先使用协程加载内容,因此状态变化必须发生在协程的上下文中 -
如需从可组合项内安全地调用挂起函数,请使用
LaunchedEffect
API,该 API 会在 Compose 中触发协程作用域限定的附带效应。当LaunchedEffect
进入组合时,它会启动一个协程,并将代码块作为参数传递。如果LaunchedEffect
退出组合,协程将取消@Composable fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) { Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { // Start a side effect to load things in the background // and call onTimeout() when finished. // Passing onTimeout as a parameter to LaunchedEffect // is wrong! Don't do this. We'll improve this code in a sec. LaunchedEffect(onTimeout) { delay(SplashWaitTime) // Simulates loading things onTimeout() } Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null) } }
错误原因:不希望在
onTimeout
发生更改时重新开始执行效应 -
某些附带效应 API(如
LaunchedEffect
)将可变数量的键作为参数,用于在其中一个键发生更改时重新开始执行效应。如需在可组合项的生命周期内仅触发一次附带效应,请将常量用作键,例如LaunchedEffect(true) { ... }
-
上述代码中,如果
onTimeout
在附带效应正在进行时发生更改,不能保证在效应结束时会调用最后一个onTimeout
(即更改后的onTimeOut
)。如需通过捕获和更新到新值来保证这一点,请使用rememberUpdatedState
API:@Composable fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) { Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { // This will always refer to the latest onTimeout function that // LandingScreen was recomposed with val currentOnTimeout by rememberUpdatedState(onTimeout) // Create an effect that matches the lifecycle of LandingScreen. // If LandingScreen recomposes or onTimeout changes, // the delay shouldn't start again. LaunchedEffect(true) { delay(SplashWaitTime) currentOnTimeout() } Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null) } }
rememberCoroutineScope
DrawerState
具有以程序化方式打开和关闭抽屉式导航栏的方法,DrawerState
包含在ScaffoldState
中,可以使用rememberScaffoldState()
获得ScaffoldState
- 挂起函数除了能够运行异步代码之外,还可以帮助表示随着时间的推移出现的概念。由于打开抽屉式导航栏需要一些时间和移动,而且还有可能需要动画,这可以通过挂起函数完美地反映出来,所以
DrawerState
的open()
和close()
均为挂起函数 - 理想情况下,我们希望
CoroutineScope
能够遵循其调用点的生命周期。为此,请使用rememberCoroutineScope
API。一旦退出组合,作用域将自动取消。利用该作用域,不在组合中(例如,在openDrawer
回调中)时,可以启动协程 - LaunchedEffect 与 rememberCoroutineScope区别
LaunchedEffect
可以保证当对该可组合项的调用使其进入组合时将会执行附带效应- 使用
rememberCoroutineScope
和scope.launch
,则 Compose 每次调用该可组合项时都会执行协程,而不管该调用是否使其进入组合
状态容器
@Composable
fun CraneEditableUserInput(
hint: String,
caption: String? = null,
@DrawableRes vectorImageId: Int? = null,
onInputChanged: (String) -> Unit
) {
// TODO Codelab: Encapsulate this state in a state holder
var textState by remember { mutableStateOf(hint) }
val isHint = { textState == hint }
...
}
用于更新 textState
以及确定显示的内容是否对应于提示的逻辑全部都在 CraneEditableUserInput
可组合项的主体中。这就带来了一些缺点:
TextField
的值未提升,因而无法从外部进行控制,这使得测试更加困难- 此可组合项的逻辑可能会变得更加复杂,并且内部状态可能会更容易不同步
通过创建负责此可组合项的内部状态的状态容器,可以将所有状态变化集中在一个位置。这样,状态不同步就更难了,并且相关的逻辑全部归在一个类中。此外,此状态很容易向上提升,并且可以从此可组合项的调用方使用
创建状态容器
// 在同一文件中创建状态容器
class EditableUserInputState(private val hint: String, initialText: String) {
var text by mutableStateOf(initialText)
val isHint: Boolean
get() = text == hint
}
该类应具有以下特征:
text
是String
类型的可变状态,就像在CraneEditableUserInput
中一样。请务必使用mutableStateOf
,以便 Compose 跟踪值的更改,并在发生更改时重组text
是一个var
,这样就可以直接从该类外部改变它- 该类将
initialText
作为用于初始化text
的依赖项 - 用于判断
text
是否为提示的逻辑在按需执行检查的isHint
属性中
记住状态容器
// 最好在同一文件中创建一个方法,始终记住状态容器,以使其留在组合中,而不是每次都创建一个新的
@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
remember(hint) {
EditableUserInputState(hint, hint)
}
使用 remember
记住此状态,它在 activity 重新创建后不会继续留存。对于可以存储在 Bundle
内的对象,rememberSaveable
可以在 activity 和进程重新创建后会继续留存存储的值,而无需任何额外的操作。对于在项目中创建的 EditableUserInputState
类,需要告知 rememberSaveable
如何使用 Saver
保存和恢复此类的实例
创建自定义保存器
Saver
描述了如何将对象转换为 Saveable
(可保存)的内容。Saver
的实现需要替换两个函数:
save
- 将原始值转换为可保存的值restore
- 将恢复的值转换为原始类的实例
可以使用一些现有的 Compose API,如 listSaver
或 mapSaver
(用于存储要保存在 List
或 Map
中的值),以减少我们需要编写的代码量,而不是从头为类创建 Saver
的自定义实现
class EditableUserInputState(private val hint: String, initialText: String) {
var text by mutableStateOf(initialText)
val isHint: Boolean
get() = text == hint
companion object {
// 最好将 Saver 定义放置在与其一起使用的类附近,可以使用 companion object 提供静态访问
val Saver: Saver<EditableUserInputState, *> = listSaver(
save = { listOf(it.hint, it.text) },
restore = {
EditableUserInputState(
hint = it[0],
initialText = it[1],
)
}
)
}
}
修改之前创建的 rememberEditableUserInputState
方法:
@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
rememberSaveable(hint, saver = EditableUserInputState.Saver) {
EditableUserInputState(hint, hint)
}
使用状态容器
状态提升,以便调用方可以控制 CraneEditableUserInput
的状态:
@Composable
fun CraneEditableUserInput(
state: EditableUserInputState = rememberEditableUserInputState(""),
caption: String? = null,
@DrawableRes vectorImageId: Int? = null
) { /* ... */ }
注意:
onInputChanged
参数不存在了!由于状态可以提升,因此如果调用方想要知道输入是否发生了更改,它们可以控制状态并将该状态传入此函数
状态容器调用方
@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
CraneEditableUserInput(
state = editableUserInputState,
caption = "To",
vectorImageId = R.drawable.ic_plane
)
}
snapshotFlow
上面的代码缺少在输入更改时通知 ToDestinationUserInput
的调用方的功能,可以在每次输入更改时使用 LaunchedEffect
触发附带效应,并调用 onToDestinationChanged
lambda:
@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
...
val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
LaunchedEffect(editableUserInputState) {
snapshotFlow { editableUserInputState.text }
.filter { !editableUserInputState.isHint }
.collect {
currentOnDestinationChanged(editableUserInputState.text)
}
}
}
snapshotFlow
API 将 Compose State<T>
对象转换为 Flow。当在 snapshotFlow
内读取的状态发生变化时,Flow 会向收集器发出新值。在本例中,我们将状态转换为 Flow,以使用 Flow 运算符的强大功能。这样,我们就可以在 text
不是 hint
时使用 filter
进行过滤,并使用 collect
收集发出的项,以通知父级发生了变化
DisposableEffect
- 如果在 Compose 中使用了 View ,那么View应该遵循使用它的 activity 的生命周期,而不是组合的生命周期
例如,由于 MapView
是 View 而不是可组合项,意味着需要创建一个 LifecycleEventObserver
来监听生命周期事件并在 MapView
上调用正确的方法,并将此观察器添加到当前 activity 的生命周期
private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
Lifecycle.Event.ON_START -> mapView.onStart()
Lifecycle.Event.ON_RESUME -> mapView.onResume()
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
Lifecycle.Event.ON_STOP -> mapView.onStop()
Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
else -> throw IllegalStateException()
}
}
现在,我们需要将此观察器添加到当前的生命周期,可以使用当前的 LifecycleOwner
与 LocalLifecycleOwner
组合局部函数来获取该生命周期
@Composable
fun rememberMapViewWithLifecycle(): MapView {
val context = LocalContext.current
val mapView = remember {
MapView(context).apply {
id = R.id.map
}
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(key1 = lifecycle, key2 = mapView) {
// Make MapView follow the current lifecycle
val lifecycleObserver = getMapLifecycleObserver(mapView)
lifecycle.addObserver(lifecycleObserver)
onDispose {
lifecycle.removeObserver(lifecycleObserver)
}
}
return mapView
}
仅仅添加观察器是不够的,还需要将其移除
-
DisposableEffect
会在键发生变化或可组合项退出组合后调用onDispose
方法,可以在方法中作清理操作,然后再次调用重启对于上例
DisposableEffect
中的key
,如果lifecycle
或mapView
发生变化,系统会移除观察器并再次将其添加到正确的lifecycle
produceState
-
屏幕状态建模
data class DetailsUiState( // 要在屏幕上显示的数据 val cityDetails: ExploreModel? = null, // 加载 val isLoading: Boolean = false, // 错误信号 val throwError: Boolean = false )
-
可以使用一个数据流(即
DetailsUiState
类型的StateFlow
)映射屏幕需要显示的内容和 ViewModel 层中的UiState
,ViewModel 会在信息准备就绪时更新该数据流,而 Compose 会使用collectAsState()
API 收集该数据流 -
produceState
可将非 Compose 状态转换为 Compose 状态。它会启动一个作用域限定为组合的协程,该协程可使用value
属性将值推送到返回的State
,produceState
采用键来取消和重新开始计算val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) { // In a coroutine, this can call suspend functions or move // the computation to different Dispatchers val cityDetailsResult = viewModel.cityDetails value = if (cityDetailsResult is Result.Success<ExploreModel>) { DetailsUiState(cityDetailsResult.data) } else { DetailsUiState(throwError = true) } } when { uiState.cityDetails != null -> { DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize()) } uiState.isLoading -> { Box(modifier.fillMaxSize()) { CircularProgressIndicator( color = MaterialTheme.colors.onSurface, modifier = Modifier.align(Alignment.Center) ) } } else -> { onErrorLoading() } }
derivedStateOf
-
当想要的某个 Compose
State
衍生自另一个State
时,会使用derivedStateOf
。使用此函数可保证仅当计算中使用的状态之一发生变化时才会进行计算// Show the button if the first visible item is past // the first item. We use a remembered derived state to // minimize unnecessary compositions val showButton by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }
该 API 仅在
listState.firstVisibleItemIndex
发生变化时计算showButton
-
Jetpack Compose Navigation
Navigation 是一个 Jetpack 库,用于在应用中从一个目的地导航到另一个目的地。Navigation 库还提供了一个专用工件( navigation-compose
),用于使用 Jetpack Compose 实现一致而惯用的导航方式
Navigation 的 3 个主要部分是 NavController
、NavGraph
和 NavHost
NavController
始终与一个NavHost
可组合项相关联NavHost
充当容器,负责显示导航图的当前目的地。当在可组合项之间进行导航时,NavHost
的内容会自动进行重组。此外,它还会将NavController
与导航图 (NavGraph
) 相关联NavGraph
用于标出能够在其间进行导航的可组合目的地,它实际上是一系列可提取的目的地
NavController
-
使用 Compose 中的 Navigation 时,
NavController
是核心组件。它可跟踪返回堆栈可组合条目、使堆栈向前移动、支持对返回堆栈执行操作,以及在不同目的地状态之间导航。由于NavController
是导航的核心,因此在设置 Compose Navigation 时必须先创建它 -
NavController
是通过调用rememberNavController()
函数获取的。这将创建并记住NavController
,它可以在配置更改后继续存在(使用rememberSaveable
) -
应始终创建
NavController
并将其放置在可组合项层次结构的顶层(通常位于App
可组合项中)。之后,所有需要引用NavController
的可组合项都可以访问它。这遵循状态提升的原则,并且可确保NavController
是在可组合屏幕之间导航和维护返回堆栈的主要可信来源 -
使用 Compose 中的 Navigation 时,导航图中的每个可组合目的地都与一个路线相关联。路线用字符串表示,用于定义指向可组合项的路径,并指引
navController
到达正确的位置。可以将其视为指向特定目的地的隐式深层链接。每个目的地都必须有一条唯一的路线 -
通过
navController.navigate(route)
执行导航操作为了使代码具有可测试性且可重复使用,建议不要将整个
navController
直接传递给可组合项。不过,应该始终提供回调,定义希望触发的确切导航操作 -
可以使用
navController.currentBackStackEntryAsState()
以State
的形式获取返回堆栈,然后获取destination
以获取当前目的地的实时更新现在也支持直接通过 Navigation Component 进行返回行为导航,无需为它进行任何其他设置。在目的地之间切换,然后按返回按钮后,系统会正确弹出返回堆栈并转到上一个目的地
NavHost
-
每个
NavController
都必须与一个NavHost
相关联 -
NavHost
需要一个startDestination
路线才能知道在应用启动时显示哪个目的地 -
NavHost
最后一个形参builder: NavGraphBuilder.() -> Unit
负责定义和构建导航图
@Composable
public fun NavHost(
navController: NavHostController,
startDestination: String,
modifier: Modifier = Modifier,
route: String? = null,
builder: NavGraphBuilder.() -> Unit
) { ... }
NavGraph
-
Navigation Compose 提供了 [
NavGraphBuilder.composable
](https://developer.android.google.cn/reference/kotlin/androidx/navigation/compose/package-summary#(androidx.navigation.NavGraphBuilder).composable(kotlin.String, kotlin.collections.List, kotlin.collections.List, kotlin.Function1)) 扩展函数,以便轻松将各个可组合目的地添加到导航图中,并定义必要的导航信息NavHost( // NavController navController = navController, // 应用启动页面 startDestination = Overview.route, modifier = Modifier.padding(innerPadding) ) { // 唯一字符串route,将目的地添加到导航图中 composable(route = Overview.route) { // 定义导航到此目的地时要显示的实际界面 Overview.screen() } }
NavOptionsBuilder
NavOptionsBuilder
中提供了一些标志进一步控制和自定义导航行为
-
launchSingleTop = true
- 可确保返回堆栈顶部最多只有给定目的地的一个副本 -
popUpTo(destination) { saveState = true }
- 一直弹出直到遇到第一个匹配的目的地,例如弹出到导航图的起始目的地,以免在选择标签页时在返回堆栈上构建大型目的地堆栈 -
restoreState = true
- 确定此导航操作是否应恢复PopUpToBuilder.saveState
或popUpToSaveState
属性之前保存的任何状态。请注意,如果之前未使用要导航到的目的地 ID 保存任何状态,此项不会产生任何影响navController.navigate(route) { launchSingleTop = true popUpTo(Overview.route) { saveState = true } restoreState = true }
如果需要有关管理多个返回堆栈的更多指导,请参阅有关支持多个返回堆栈的文档
实参
-
实参会将一个或多个实参传递给路线,从而使导航路线变为动态形式。它支持根据所提供的不同实参显示不同的信息
-
如需在导航时随路线一起传递实参,需要按照以下模式将它们附加在一起:
"route/{argument}"
composable( // 为了提高代码安全性和处理任何极端情况,可以将默认值设置为实参并明确指定其类型 route = "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}" ) { SingleAccountScreen() }
具名实参的定义方式是附加到路线并用花括号括起来,如下所示:
{argument}
。其语法与 Kotlin 的 String 模板语法类似,均在必要时使用美元符号$
来转义变量名称,例如:{${argument}}
-
可以定义其
arguments
形参让composable
知道它应该接受实参,可以根据需要定义任意数量的实参,因为composable
函数默认接受实参列表composable( route = "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}", arguments = listOf( // 将其类型指定为 String,可提高安全性,如果未明确设置类型,系统将根据此实参的默认值推断出其类型 navArgument(SingleAccount.accountTypeArg) { type = NavType.StringType } ) ) { SingleAccountScreen() }
-
在 Compose Navigation 中,每个
NavHost
可组合函数都可以访问当前的NavBackStackEntry
,该类用于保存当前路线的相关信息,以及返回堆栈中条目的已传递实参。可以使用该类从navBackStackEntry
中获取所需的arguments
列表,然后搜索并检索所需的确切实参,将其进一步向下传递给可组合屏幕NavHost(...) { // ... composable( route = "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}", arguments = SingleAccount.arguments ) { navBackStackEntry -> // Retrieve the passed argument val accountType = navBackStackEntry.arguments?.getString(SingleAccount.accountTypeArg) // Pass accountType to SingleAccountScreen SingleAccountScreen(accountType) } }
可以为实参提供一个默认值(如果尚未提供)作为占位符,并包含这种极端情况,以提高代码的安全性
-
起始目的地提供并传递实参,到达目的地接受实参并使用它显示正确信息
// 注意:传递时不需要花括号 navController.navigate("${SingleAccount.route}/$accountType")
深层链接
-
在 Android 中,深层链接是指将用户直接转到应用内特定目的地的链接,简单来说,就是提供一个接口,可以让其他应用调用,从而直接打开该应用的特定页面
-
除了添加实参之外,还可以添加深层链接,将特定网址、操作和/或 MIME 类型与可组合项关联起来
-
由于向外部应用公开深层链接这一功能默认处于未启用状态,因此还必须向应用的
manifest.xml
文件添加<intent-filter>
元素-
向应用的
AndroidManifest.xml
添加深层链接,需要通过<activity>
内的<intent-filter>
创建一个新的 intent 过滤器,相应操作为VIEW
,类别为BROWSABLE
和DEFAULT
-
在该过滤器内,需要使用
data
标记添加scheme
(例如rally
- 应用名称)和host
(例如single_account
- 导航到可组合项的路线),以定义精确的深层链接,这将提供rally://single_account
作为深层链接网址<activity android:name=".RallyActivity" android:windowSoftInputMode="adjustResize" android:label="@string/app_name" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> <!--无需在 AndroidManifest 中声明实参,实参会附加到 NavHost 可组合函数内--> <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="rally" android:host="single_account" /> </intent-filter> </activity>
-
-
可以定义其
navDeepLink
形参让composable
知道它应该接受实参,可以根据需要定义多个指向同一目的地的深层链接,因为composable
函数默认接受深层链接列表。传递uriPattern
,匹配清单AndroidManifest.xml
的intent-filter
中定义的一个 uriPattern,但还需附加其accountTypeArg
实参
composable(
route = SingleAccount.routeWithArgs,
// ...
deepLinks = listOf(navDeepLink {
uriPattern = "rally://${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
})
)
-
使用 adb 测试深层链接
# 有空格时要加 \ zhuan'yi adb shell am start -d "rally://single_account/Checking" -a android.intent.action.VIEW
Android Studio自带的ADB工具在
{SDK目录}\platform-tools
下
在 Jetpack Compose 中进行测试
-
测试包含许多内容:
-
测试标签页是否会显示预期图标和文本
-
测试动画是否符合规范
-
测试触发的导航事件是否正确
-
测试界面元素在不同状态下的放置位置和距离
-
截取该栏的屏幕截图,并将其与之前截取的屏幕截图进行比较
-
-
Compose 提供一个
ComposeTestRule
,调用createComposeRule()
即可获得此规则class TopAppBarTest { @get:Rule val composeTestRule = createComposeRule() // TODO: Add tests }
-
在 Compose 中,可以通过对组件进行隔离测试来大幅简化测试工作。可以选择要在测试中使用的 Compose 界面内容,这可通过
ComposeTestRule
的setContent
方法完成,并且可以在任何位置调用它(但只能调用一次) -
查找界面元素、检查其属性和执行操作是按照以下模式通过测试规则完成的:
composeTestRule{.finder}{.assertion}{.action}
-
Compose 测试使用称为语义树的结构来查找屏幕上的元素并读取其属性
// 在Logcat中搜索 currentLabelExists 查看输出 composeTestRule.onRoot().printToLog("currentLabelExists")
警告:可组合项没有 ID,也无法使用树中显示的节点编号来匹配它们。如果将节点与其语义属性匹配不可行或不可能,可以将
testTag
修饰符和hasTestTag
匹配器作为最后手段 -
语义树总是会尽可能地精简,仅显示相关的信息,属性
MergeDescendants = 'true'
表示,此节点有后代,但已合并到此节点中。在测试中,常常需要访问所有节点,所有查找器都有一个名为useUnmergedTree
的参数,可以将useUnmergedTree = true
传递给查找器,查询未合并的语义树 -
所编写的任何测试都必须与被测对象正确同步。例如,当使用
onNodeWithText
等查找器时,测试会一直等到应用进入空闲状态后才查询语义树。如果不同步,测试就可能会在元素显示之前查找元素,或者不必要地等待// 不同步报错 androidx.compose.ui.test.junit4.android.ComposeNotIdleException: Idling resource timed out: possibly due to compose being busy. IdlingResourceRegistry has the following idling resources registered: - [busy] androidx.compose.ui.test.junit4.android.ComposeIdlingResource@d075f91
出现不同步的原因可能是不会停止的动画,但无限动画是 Compose 测试可以理解的一种特殊情况,因此不会导致测试一直忙碌
模式通过测试规则完成的:
composeTestRule{.finder}{.assertion}{.action}
-
Compose 测试使用称为语义树的结构来查找屏幕上的元素并读取其属性
// 在Logcat中搜索 currentLabelExists 查看输出 composeTestRule.onRoot().printToLog("currentLabelExists")
警告:可组合项没有 ID,也无法使用树中显示的节点编号来匹配它们。如果将节点与其语义属性匹配不可行或不可能,可以将
testTag
修饰符和hasTestTag
匹配器作为最后手段 -
语义树总是会尽可能地精简,仅显示相关的信息,属性
MergeDescendants = 'true'
表示,此节点有后代,但已合并到此节点中。在测试中,常常需要访问所有节点,所有查找器都有一个名为useUnmergedTree
的参数,可以将useUnmergedTree = true
传递给查找器,查询未合并的语义树 -
所编写的任何测试都必须与被测对象正确同步。例如,当使用
onNodeWithText
等查找器时,测试会一直等到应用进入空闲状态后才查询语义树。如果不同步,测试就可能会在元素显示之前查找元素,或者不必要地等待// 不同步报错 androidx.compose.ui.test.junit4.android.ComposeNotIdleException: Idling resource timed out: possibly due to compose being busy. IdlingResourceRegistry has the following idling resources registered: - [busy] androidx.compose.ui.test.junit4.android.ComposeIdlingResource@d075f91
出现不同步的原因可能是不会停止的动画,但无限动画是 Compose 测试可以理解的一种特殊情况,因此不会导致测试一直忙碌
[外链图片转存中…(img-zrASNCEG-1668352183142)]