查看原文
其他

使用 Jetpack Compose 为 JetLagged 构建响应式仪表盘布局

Android Android 开发者
2024-12-27

作者 / 开发者关系工程师 Rebecca Franks 


这篇文章是我们 "Adaptive Spotlight Week" 系列的内容之一。在该系列中,我们会提供文章、视频、示例代码等资源,以帮助您将应用适配到手机、可折叠设备、平板电脑、ChromeOS 甚至是车载系统中。您可以查阅更多关于 Adaptive Spotlight Week 的内容。

🔗 查阅更多关于 Adaptive Spotlight Week 的内容
https://android-developers.googleblog.com/2024/10/adaptive-spotlight-week.html


我们了解到,在 Jetpack Compose 中创建自适应布局比以往任何时候都更加简便。作为一款声明式界面工具包,Jetpack Compose 非常适合设计和实现能够根据不同屏幕尺寸调整显示内容的布局。通过结合使用窗口大小类别 (Window Size Classes)、流式布局 (Flow layouts)、movableContentOf 和 LookaheadScope,我们可以确保在 Jetpack Compose 中实现流畅的响应式布局。

🔗 创建自适应布局
https://developer.android.google.cn/jetpack/compose/layouts/adaptive
🔗 窗口大小类别
https://developer.android.google.cn/guide/topics/large-screens/support-different-screen-sizes#window_size_classes
🔗 流式布局
https://developer.android.google.cn/jetpack/compose/layouts/flow
🔗 movableContentOf
https:/developer.android.google.cn/reference/kotlin/androidx/compose/runtime/package-summary#movableContentOf%28kotlin.Function4%29
🔗 LookaheadScope
https://developer.android.google.cn/reference/kotlin/androidx/compose/ui/layout/LookaheadScope


在 2023 年 Google I/O 大会上发布了 JetLagged 示例之后,我们决定添加更多示例。具体来说,我们希望展示如何使用 Compose 创建一个美观的仪表盘式布局。本文将介绍我们如何实现这一目标。

△ Jetlagged 中的响应式设计,各个项目的位置会自动调整


🔗 JetLagged
https://github.com/android/compose-samples/tree/main/JetLagged



借助 FlowRow 和 FlowColumn 

构建能够响应不同屏幕尺寸的布局


使用流式布局 (FlowRow 和 FlowColumn) 可以更轻松地实现响应式、可重排布局,这些布局可以响应屏幕尺寸,并在行或列中的可用空间已满时,自动对内容进行换行处理。

🔗 流式布局
https://developer.android.google.cn/jetpack/compose/layouts/flow

在 JetLagged 的示例中,我们使用了 FlowRow,并将 maxItemsInEachRow 设置为 3。这可以确保我们最大程度地利用仪表盘的可用空间,并将每个独立的卡片放置在一行或一列中,合理利用空间。在移动设备上,我们通常每行放置 1 张卡片,只有当项目较小时,才会出现每行两张卡片的情况。


一些卡片使用了没有指定确切大小的修饰符 (Modifiers),因此这些卡片可以根据可用宽度进行扩展,例如使用 Modifier.widthIn(max = 400.dp),或者设定一个特定的大小,如 Modifier.width(200.dp)。
FlowRow( modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center, maxItemsInEachRow = 3) { Box(modifier = Modifier.widthIn(max = 400.dp)) Box(modifier = Modifier.width(200.dp)) Box(modifier = Modifier.size(200.dp)) // etc }

我们还可以利用权重修饰符来分配行或列的剩余区域。您可以查阅项目权重的文档了解更多信息。

🔗 项目权重
https://developer.android.google.cn/jetpack/compose/layouts/flow#item-weights


使用 WindowSizeClasses

区分不同设备



WindowSizeClasses 对于在界面中建立断点非常有用,它可以确定元素何时应该以不同的方式显示。在 JetLagged 中,我们使用该类来确定应该将卡片包含在 Column 中,还是让它们连续流动排列。

🔗 WindowSizeClasses 
https://developer.android.google.cn/guide/topics/large-screens/support-different-screen-sizes#window_size_classes


例如,如果 WindowWidthSizeClassCOMPACT,我们将项目保留在相同的 FlowRow 中;而如果布局大于紧凑型,则将项目放置在一个嵌套于 FlowRow 内的 FlowColumn 中:
FlowRow( modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center, maxItemsInEachRow = 3 ) { JetLaggedSleepGraphCard(uiState.value.sleepGraphData) if (windowSizeClass == WindowWidthSizeClass.COMPACT) { AverageTimeInBedCard() AverageTimeAsleepCard() } else { FlowColumn { AverageTimeInBedCard() AverageTimeAsleepCard() } } if (windowSizeClass == WindowWidthSizeClass.COMPACT) { WellnessCard(uiState.value.wellnessData) HeartRateCard(uiState.value.heartRateData) } else { FlowColumn { WellnessCard(uiState.value.wellnessData) HeartRateCard(uiState.value.heartRateData) } } }

根据上述逻辑,界面将在不同尺寸的设备上以如下方式呈现:

△ 不同尺寸设备上的不同界面



使用 movableContentOf
以在屏幕尺寸变化时
保持部分界面状态



借助可移动内容 (Movable content),您可以保存可组合项 (Composable) 的内容,以便在布局层次结构中移动它,而不丢失状态。它应该用于那些被视为相同内容,只是在屏幕位置不同的情况。


想象一下,您要搬家到另一个城市,打包了一个装有时钟的箱子。在新家打开箱子时,您会发现时钟仍然从您离开时的时间点继续走动。虽然该时间可能不是您新时区的正确时间,但它肯定是从您离开时的那个时间点继续走动的。箱子里的物体在其移动时并不会重置其内部状态。


如果我们能够在 Compose 中使用同样的概念来移动屏幕上的项目,而不丢失其内部状态,会发生什么呢?


请考虑以下场景:定义不同的 Tile 可组合项,这些项目会在 5,000 毫秒内显示 0 到 100 无限循环的动画。
@Composablefun Tile1() { val repeatingAnimation = rememberInfiniteTransition()
val float = repeatingAnimation.animateFloat( initialValue = 0f, targetValue = 100f, animationSpec = infiniteRepeatable(repeatMode = RepeatMode.Reverse, animation = tween(5000)) ) Box(modifier = Modifier .size(100.dp) .background(purple, RoundedCornerShape(8.dp))){ Text("Tile 1 ${float.value.roundToInt()}", modifier = Modifier.align(Alignment.Center)) }}
然后我们使用 Column 布局在屏幕上展示这些项目。以下便是这些项目持续进行时的无限动画效果:

但如果我们想根据手机的不同屏幕方向 (或不同屏幕尺寸) 来重新排列 Tile,并且不希望动画值停止运行,该怎么办呢?我们可能会想到以下方法:
@Composablefun WithoutMovableContentDemo() { val mode = remember { mutableStateOf(Mode.Portrait) } if (mode.value == Mode.Landscape) { Row { Tile1() Tile2() } } else { Column { Tile1() Tile2() } }}
虽然这样的做法看起来相当标准,但在设备上运行时,我们会发现在这两种布局之间切换会导致动画重新启动。

此时是使用可移动内容的最佳时机,因为屏幕上的可组合项本质上是相同的,只是位置不同。那么我们该如何使用呢?我们只需要在 movableContentOf 块中定义 Tile,并使用 remember 来确保其状态在不同的组合中得以保存:
val tiles = remember { movableContentOf { Tile1() Tile2() } }

🔗 movableContentOf 
https://developer.android.google.cn/reference/kotlin/androidx/compose/runtime/package-summary#movableContentOf%28kotlin.Function0%29
🔗 remember
https://developer.android.google.cn/reference/kotlin/androidx/compose/runtime/package-summary#remember%28kotlin.Any,kotlin.Any,kotlin.Any,kotlin.Function0%29

现在,我们不是分别在 Column 和 Row 中调用可组合项,而是改为调用 tiles()。
@Composablefun MovableContentDemo() { val mode = remember { mutableStateOf(Mode.Portrait) } val tiles = remember { movableContentOf { Tile1() Tile2() } } Box(modifier = Modifier.fillMaxSize()) { if (mode.value == Mode.Landscape) { Row { tiles() } } else { Column { tiles() } }
Button(onClick = { if (mode.value == Mode.Portrait) { mode.value = Mode.Landscape } else { mode.value = Mode.Portrait } }, modifier = Modifier.align(Alignment.BottomCenter)) { Text("Change layout") } }}

这样系统就会记住由这些可组合项生成的节点,并保留这些可组合项当前的内部状态。

我们现在可以看到,动画状态在不同的组合中保持一致。"箱子中的时钟" 现在在世界各地移动时,也会保持其状态。


利用这个概念,我们可以通过将卡片放置在 movableContentOf 中,以保持卡片上的动画气泡状态:
Language
val timeSleepSummaryCards = remember { movableContentOf { AverageTimeInBedCard() AverageTimeAsleepCard() } } LookaheadScope { FlowRow( modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center, maxItemsInEachRow = 3 ) { //.. if (windowSizeClass == WindowWidthSizeClass.Compact) { timeSleepSummaryCards() } else { FlowColumn { timeSleepSummaryCards() } } // } }
这使得卡片的状态得以保存,并且卡片不会被重新组合。这一点在观察卡片背景中的气泡时尤为明显:即使在屏幕尺寸变化时,气泡动画也会继续,而不会重新启动



使用 Modifier.animateBounds()

在不同窗口大小之间

实现流畅的动画效果


从上面的例子中,我们可以看到,虽然在布局大小 (或布局本身) 发生变化时状态得以保持,但切换布局时的变化有些不连贯。我们希望在两种状态切换时实现流畅的动画过渡。


在 compose-bom-alpha (2024.09.03) 中,我们新增了一个实验性的自定义修饰符 Modifier.animateBounds()。animateBounds 修饰符需要配合 LookaheadScope 使用。

🔗 compose-bom-alpha
https://developer.android.google.cn/develop/ui/compose/bom#what_if_i_want_to_try_out_alpha_or_beta_releases_of_compose_libraries
🔗 Modifier.animateBounds() 
https://developer.android.google.cn/reference/kotlin/androidx/compose/animation/package-summary#%28androidx.compose.ui.Modifier%29.animateBounds%28androidx.compose.ui.layout.LookaheadScope,androidx.compose.ui.Modifier,androidx.compose.animation.BoundsTransform,kotlin.Boolean%29
🔗 LookaheadScope
https://developer.android.google.cn/reference/kotlin/androidx/compose/ui/layout/LookaheadScope?hl=en


LookaheadScope 能够让 Compose 在布局变化时执行中间测量过程,并告知可组合项这些变化之间的中间状态。近期,您可能也注意到了 LookaheadScope 还可用于新的共享元素 API。

🔗 共享元素 API
https://developer.android.google.cn/develop/ui/compose/animation/shared-elements


要使用 Modifier.animateBounds(),我们需要在顶层的 FlowRow 外包裹一个 LookaheadScope,然后将 animateBounds 修饰符应用于每个卡片。我们还可以通过指定 boundsTransform 参数到自定义的 spring 规范,从而定制动画的运行方式:
val boundsTransform = { _ : Rect, _: Rect -> spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium, visibilityThreshold = Rect.VisibilityThreshold )}

LookaheadScope { val animateBoundsModifier = Modifier.animateBounds( lookaheadScope = this@LookaheadScope, boundsTransform = boundsTransform) val timeSleepSummaryCards = remember { movableContentOf { AverageTimeInBedCard(animateBoundsModifier) AverageTimeAsleepCard(animateBoundsModifier) } } FlowRow( modifier = Modifier .fillMaxSize() .windowInsetsPadding(insets), horizontalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center, maxItemsInEachRow = 3 ) { JetLaggedSleepGraphCard(uiState.value.sleepGraphData, animateBoundsModifier.widthIn(max = 600.dp)) if (windowSizeClass == WindowWidthSizeClass.Compact) { timeSleepSummaryCards() } else { FlowColumn { timeSleepSummaryCards() } }

FlowColumn { WellnessCard( wellnessData = uiState.value.wellnessData, modifier = animateBoundsModifier .widthIn(max = 400.dp) .heightIn(min = 200.dp) ) HeartRateCard( modifier = animateBoundsModifier .widthIn(max = 400.dp, min = 200.dp), uiState.value.heartRateData ) } }}
将此逻辑应用到我们的布局中后,我们可以看到两个状态之间的转换更加流畅,不会出现不连贯的情况。

将此逻辑应用到整个仪表盘中,当调整布局大小时,您会感受到整个屏幕上的界面互动变得更加流畅自然。


总结



正如本文所述,通过使用 Compose,我们能够利用流式布局、WindowSizeClasses、可移动内容和 LookaheadScope 来构建一个响应式的仪表盘布局。这些概念同样可以应用于您自己的布局中,可能会有项目在布局中移动。


有关这些不同主题的更多信息,您可以查阅官方文档。有关 JetLagged 的详细更改,请查阅此拉取请求。也欢迎您持续关注 "Android 开发者" 微信公众号,及时了解更多开发技术和产品更新等资讯动态!

🔗 官方文档
https://developer.android.google.cn/jetpack/compose
🔗 此拉取请求
https://github.com/android/compose-samples/pull/1473



点击图片关注精彩活动





 点击屏末 | 阅读原文 | 即刻体验使用 Jetpack Compose 构建应用



继续滑动看下一个
Android 开发者
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存