作者 / 开发者关系工程师 Rebecca Franks
这篇文章是我们 "Adaptive Spotlight Week" 系列的内容之一。在该系列中,我们会提供文章、视频、示例代码等资源,以帮助您将应用适配到手机、可折叠设备、平板电脑、ChromeOS 甚至是车载系统中。您可以 查阅更多关于 Adaptive Spotlight Week 的内容。
我们了解到,在 Jetpack Compose 中 创建自适应布局 比以往任何时候都更加简便。作为一款声明式界面工具包,Jetpack Compose 非常适合设计和实现能够根据不同屏幕尺寸调整显示内容的布局。通过结合使用 窗口大小类别 (Window Size Classes)、流式布局 (Flow layouts)、movableContentOf 和 LookaheadScope,我们可以确保在 Jetpack Compose 中实现流畅的响应式布局。
在 2023 年 Google I/O 大会上发布了 JetLagged 示例之后,我们决定添加更多示例。具体来说,我们希望展示如何使用 Compose 创建一个美观的仪表盘式布局。本文将介绍我们如何实现这一目标。
△ Jetlagged 中的响应式设计,各个项目的位置会自动调整
借助 FlowRow 和 FlowColumn 构建能够响应不同屏幕尺寸的布局
使用 流式布局 (FlowRow 和 FlowColumn) 可以更轻松地实现响应式、可重排布局,这些布局可以响应屏幕尺寸,并在行或列中的可用空间已满时,自动对内容进行换行处理。
在 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
}
我们还可以利用权重修饰符来分配行或列的剩余区域。您可以查阅 项目权重 的文档了解更多信息。
使用 WindowSizeClasses 区分不同设备
WindowSizeClasses 对于在界面中建立断点非常有用,它可以确定元素何时应该以不同的方式显示。在 JetLagged 中,我们使用该类来确定应该将卡片包含在 Column 中,还是让它们连续流动排列。
例如,如果 WindowWidthSizeClass 为 COMPACT,我们将项目保留在相同的 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 无限循环的动画。
@Composable
fun 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,并且不希望动画值停止运行,该怎么办呢?我们可能会想到以下方法:
@Composable
fun 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()
}
}
现在,我们不是分别在 Column 和 Row 中调用可组合项,而是改为调用 tiles()。
@Composable
fun 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 中,以保持卡片上的动画气泡状态:
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 使用。
LookaheadScope 能够让 Compose 在布局变化时执行中间测量过程,并告知可组合项这些变化之间的中间状态。近期,您可能也注意到了 LookaheadScope 还可用于新的 共享元素 API。
要使用 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 的详细更改,请查阅 此拉取请求。也欢迎您持续关注我们,及时了解更多开发技术和产品更新等资讯动态!
版权声明
禁止一切形式的转载-禁止商用-禁止衍生 申请授权