专栏名称: 郭霖
Android技术分享平台,每天都有优质技术文章推送。你还可以向公众号投稿,将自己总结的技术心得分享给大家。
目录
相关文章推荐
郭霖  ·  使用 Jetpack Compose ... ·  2 天前  
stormzhang  ·  钟睒睒的喊话,能改变什么? ·  昨天  
鸿洋  ·  为 TheRouter 的 AGP8 编译加个速 ·  2 天前  
郭霖  ·  iPhone 到 Android ... ·  1 周前  
51好读  ›  专栏  ›  郭霖

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

郭霖  · 公众号  · android  · 2024-11-26 08:00

正文



/   今日科技快讯   /


近日,苹果公司CEO库克现身北京,参加第二届中国国际供应链促进博览会。这是库克年内第三次来华。在回答如何评价苹果在中国的合作伙伴时,库克表示:“非常重视苹果在中国的合作伙伴们,没有他们苹果无法取得今天的成就。”苹果公司在现场的展览牌显示,公司的200家主要供货商中,有超过80%在中国生产。


/   正文   /


我们了解到,在 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 来构建一个响应式的仪表盘布局。这些概念同样可以应用于您自己的布局中,可能会有项目在布局中移动。


推荐阅读:

我的新书,《第一行代码 第3版》已出版!

Android外接设备开发使用一网打尽

一个适用于触控笔应用的全新 Jetpack 库


欢迎关注我的公众号

学习技术或投稿



长按上图,识别图中二维码即可关注