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

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

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

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

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

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

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

△ 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 的详细更改,请查阅 此拉取请求。也欢迎您持续关注我们,及时了解更多开发技术和产品更新等资讯动态!

版权声明

禁止一切形式的转载-禁止商用-禁止衍生 申请授权

脉脉不得语
脉脉不得语
Zhengzhou Website
Android Developer | https://androiddevtools.cn and https://androidweekly.io Funder | GDG Zhengzhou Funder & Ex Organizer | http://Toast.show(∞) Podcast Host

你已经成功订阅到 Android 开发技术周报
太棒了!接下来,完成检验以获得全部访问权限 Android 开发技术周报
欢迎回来!你已经成功登录了。
Unable to sign you in. Please try again.
成功!您的帐户已完全激活,您现在可以访问所有内容。
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.
🍗