Jetpack Compose 中适配不同的屏幕尺寸
创始人
2024-05-29 21:44:28
0

窗口大小分类

Compose 将 Android 设备的屏幕尺寸分为三类:

  • Compact: 小屏幕,一般就是手机设备,屏幕宽度 < 600dp
  • Medium:中等屏幕,大号的板砖手机如折叠屏或平板的竖屏,600dp < 屏幕宽度 < 840dp
  • Expanded:展开屏幕,平板或平板电脑等,屏幕宽度 > 840dp

在这里插入图片描述

它是以某个维度来划分的,如上图是以宽度作为划分点,当然也可以按照高度来作为划分点:

在这里插入图片描述

但由于垂直滚动的普遍存在,可用宽度通常比可用高度更重要;因此,宽度窗口大小类别很可能与应用的界面更相关。所以大部分开发者只需要根据宽度调整应用即可。

窗口大小类别不适用于“isTablet-type”逻辑,而是由应用可用的窗口大小决定(无论运行应用的设备是什么类型);这有两个重大影响:

  • 实体设备不能保证特定的窗口大小类别。应用可用的屏幕空间可能会与设备的屏幕尺寸不同,这有很多原因。在移动设备上,分屏模式可以在多个应用之间拆分屏幕。在 Chrome 操作系统中,Android 应用可以呈现在可任意调整大小的自由式窗口中。可折叠设备可以有两个大小不同的屏幕,分别可通过折叠或展开设备使用。

  • 窗口大小类别在应用的整个生命周期内可能会发生变化。当应用处于运行状态时,设备更改屏幕方向、进行多任务处理和折叠/展开可能会改变可用的屏幕空间量。因此,窗口大小类别是动态的,应用的界面应相应地调整。

基于 Compose 的应用可以通过 calculateWindowSizeClass() 函数来当前窗口的分类,它使用 material3-window-size-class 库计算 WindowSizeClass,需要添加依赖:

implementation "androidx.compose.material3:material3-window-size-class:1.0.0"

调用示例代码:

import androidx.activity.compose.setContent
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClassclass MyActivity : ComponentActivity() {override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContent {// 计算Activity当前窗口的窗口大小类别// 如果窗口大小改变了,例如当设备旋转时,calculateSizeClass返回的值也会改变。val windowSizeClass = calculateWindowSizeClass(this)MyApp(windowSizeClass)}}
}@Composable
fun MyApp(windowSizeClass: WindowSizeClass) {// 根据不同的窗口大小类别,进行不同的视图展示逻辑when(windowSizeClass.widthSizeClass) {WindowWidthSizeClass.Compact -> Text("当前是 Compact 屏幕")WindowWidthSizeClass.Medium -> Text("当前是 Medium 屏幕")WindowWidthSizeClass.Expanded -> Text("当前是 Expanded 屏幕")}
}

在非 Compose 的应用中,也可以判断窗口大小类别,但是要麻烦一点:

enum class WindowSizeClass { COMPACT, MEDIUM, EXPANDED }class MainActivity : Activity() {override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)// ...// Replace with a known container that you can safely add a// view to where it won't affect the layout and the view// won't be replaced.val container: ViewGroup = binding.container// Add a utility view to the container to hook into// View.onConfigurationChanged. This is required for all// activities, even those that don't handle configuration// changes. We also can't use Activity.onConfigurationChanged,// since there are situations where that won't be called when// the configuration changes. View.onConfigurationChanged is// called in those scenarios.container.addView(object : View(this) {override fun onConfigurationChanged(newConfig: Configuration?) {super.onConfigurationChanged(newConfig)computeWindowSizeClasses()}})computeWindowSizeClasses()}// 非 Compose 应用中的使用方法enum class WindowSize { COMPACT, MEDIUM, EXPANDED }private fun computeWindowSizeClasses() {val metrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(this)val widthDp = metrics.bounds.width() / Resources.getSystem().displayMetrics.densityval widthWindowSizeClass = when {widthDp < 600f -> WindowSize.COMPACTwidthDp < 840f -> WindowSize.MEDIUMelse -> WindowSize.EXPANDED}val heightDp = metrics.bounds.height() / Resources.getSystem().displayMetrics.densityval heightWindowSizeClass = when {heightDp < 480f -> WindowSize.COMPACTheightDp < 900f -> WindowSize.MEDIUMelse -> WindowSize.EXPANDED}// Use widthWindowSizeClass and heightWindowSizeClass.}
}

这是通过 Jetpack WindowManager 库提供的能力,需要单独的添加依赖:

implementation "androidx.window:window:1.0.0"

在应用中观察窗口大小类别之后,就可以开始根据当前的窗口大小类别来改变布局了。

下面是一个简单的例子,它在屏幕的窗口类型是 Compact 的时候展示一个单独的列表,而在其他情况下展示两个并排的列表:

@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
class WindowSizeActivity: ComponentActivity() {override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContent {val windowSizeClass = calculateWindowSizeClass(this)WindowSizeExample(windowSizeClass)} }
}
@Composable
fun WindowSizeExample(windowSizeClass: WindowSizeClass) {if (windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact) {ListScreen()} else {TwoListScreen()}
}@Composable
fun ListScreen() {LazyColumn(modifier = Modifier.fillMaxSize()) {// List 1items(10) {SimpleText( "Item $it",Color.Cyan)}// List 2items(10) {SimpleText( "Item $it",Color.Magenta)}}
}@Composable
fun TwoListScreen() {Row {LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f)) {// List 1items(10) {SimpleText( "Item $it",Color.Cyan)}}LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f)) {items(10) {SimpleText( "Item $it",Color.Magenta)}}}
}@Composable
fun SimpleText(text: String, bgColor: Color = Color.White) {Text(text = text,fontSize = 25.sp,modifier = Modifier.fillMaxWidth().background(bgColor).padding(16.dp))
}

运行效果:

在这里插入图片描述

以显式方式对屏幕级可组合项布局进行大幅调整

使用 Compose 布置整个应用时,应用级和屏幕级可组合项会占用分配给应用进行渲染的所有空间。在应用设计的这个层面上,可能有必要更改屏幕的整体布局以充分利用屏幕空间。

关键术语

  • 应用级可组合项:单个根可组合项,它会占用分配给应用的所有空间,并包含所有其他可组合项。
  • 屏幕级可组合项:应用级可组合项中包含的一种可组合项,会占用分配给应用的所有空间。在应用中导航时,每个屏幕级可组合项通常代表一个特定目的地。
  • 个别可组合项:所有其他可组合项。可以是各个元素、可重复使用的内容组,或者是在屏幕级可组合项中托管的可组合项。

避免根据物理硬件值来确定布局。您可能会想根据固定的值来确定布局(如设备是平板电脑吗?物理屏幕是否有特定的宽高比?)不过,这些问题的答案对于确定界面可使用的空间可能没什么价值。

在这里插入图片描述

在平板电脑上,应用可能会在多窗口模式下运行,这意味着,该应用可能会与另一个应用分屏显示。在 Chrome 操作系统中,应用可能会位于可调整大小的窗口中。甚至可能会有多个物理屏幕,例如可折叠设备。在所有这些情况下,物理屏幕尺寸都与决定如何显示内容无关。

相反,您应该根据分配给应用的实际屏幕区域来决定如何显示,例如 Jetpack WindowManager 库提供的当前窗口指标或使用 material3-window-size-class 库提供的 WindowSizeClass。您可以将这些 Size 类作为状态进行传递,也可以执行其他逻辑来创建派生状态以传递给嵌套可组合项。

在这里插入图片描述

遵循此方法可提高应用的灵活性,因为它将在以上所有场景中都能正常运行。让布局能够自动适应可用的屏幕空间,也可以减少为支持 Chrome 操作系统等平台以及平板电脑和可折叠设备等设备类型而需要进行的特殊处理。

应该尽量避免下面这样的方式,这些都是在传统 View 体系开发当中非常糟糕的解决方案:

在这里插入图片描述

而应根据窗口大小,将相同的数据相同的基本组件以不同的方式重新展示:

在这里插入图片描述

例如下面的卡片在 Compact 类别的屏幕中显示正常,但是在 MediumExpanded 的类别的屏幕中显示异常,底部文字被截断:

在这里插入图片描述

它的代码可能长下面这样:

在这里插入图片描述

如果我们要对其进行修复的话,首先可以尝试使用的是 Modifier.vertialScroll() 修饰符,它可以使组件自身变得可以纵向滚动,这在横屏模式下表现良好:

在这里插入图片描述

当切换到竖屏时,我们可以为Image组件添加一个weight修饰符,这样当其余内容都显示完毕后,Image会利用剩余的空间完整的显示:

在这里插入图片描述

我们可以有更好的方案。当窗口大小是 Expended 类别的情况下,屏幕有足够的空间来展示所有内容,因此可以将组件并排排列,而无需放在一个 Column 组件中:

在这里插入图片描述

根据不同的具体需求决定采用何种适配方案

在 Compose 中关于“屏幕适配”这件事,有多种方案可以来实现,例如,你可以使用 BoxWithConstraint 组件,也可以使用自定义布局,还可以跟据前面介绍的 calculateWindowSizeClass() 函数来计算 WindowSizeClass 作为可组合屏幕的选择依据。

但是到底应该使用哪一种,可能需要考虑从不同的需求出发点来做出选择:

  • WindowSizeClass:窗口级别的布局改变,倾向于在更大的窗口空间展示更多的内容
  • BoxWithConstraint:展示的内容类型根据组件尺寸而变化,侧重点在内容类型跟随尺寸变化
  • CustomLayout:自定义不同的排列方式,可以是根据宽高尺寸来做决定,但也可以是根据其它条件,在任何情况下都可自主决定。

在这里插入图片描述

折叠屏

可折叠屏幕的设备对于应用开发者来说,主要是能够判断屏幕发生折叠的状态,当折叠状态发生改变的时候去调整布局展示。

在这里插入图片描述

要判断屏幕折叠状态,依然是利用 Jetpack WindowManager 库 提供的能力,可以通过 WindowInfoTracker 的相关 API 来获取。

以下是判断折叠状态的示例代码:

@OptIn(ExperimentalLifecycleComposeApi::class)
class WindowSizeActivity: ComponentActivity() {override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContent {val devicePosture = getDevicePosture().collectAsStateWithLifecycle().valuePlayerScreen(devicePosture)} }private fun getDevicePosture(): StateFlow {return WindowInfoTracker.getOrCreate(this).windowLayoutInfo(this).flowWithLifecycle(this.lifecycle).map { windowLayoutInfo ->val foldingFeature = windowLayoutInfo.displayFeatures.filterIsInstance().firstOrNull()if(foldingFeature != null && isTableTopPosture(foldingFeature)) {DevicePosture.TableTopPosture} else {DevicePosture.NormalPosture}}.stateIn(scope = lifecycleScope,started = SharingStarted.Eagerly,initialValue = DevicePosture.NormalPosture)}private fun isTableTopPosture(foldingFeature: FoldingFeature): Boolean {return foldingFeature.state == FoldingFeature.State.HALF_OPENED &&foldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL}
}
enum class DevicePosture { NormalPosture, TableTopPosture}
@Composable
fun PlayerScreen(devicePosture: DevicePosture) {if (devicePosture == DevicePosture.TableTopPosture) {PlayerContentTableTop()} else {PlayerContentRegular()}
}

其中 foldingFeature.state == FoldingFeature.State.HALF_OPENED && foldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL 表示屏幕是半开且屏幕方向是水平方向,即折叠状态。

TwoPane

WindowSizeClass 不只在应用级可组合项可用,在屏幕级可组合项 (某个全屏的Composable)中也可以使用:

在这里插入图片描述

如果是视频播放这样的屏幕级可组合项 ,一般都是固定的模式,屏幕的上半部分显示播放器,屏幕的下半部分显示评论列表等。对于这种场景 Jetpack Compose 提供了一个专门的组件来处理:TwoPane。不过它是在 Accompanist 库中提供的,使用它需要单独添加依赖:

dependencies {implementation "com.google.accompanist:accompanist-adaptive:"
}

TwoPane的使用跟 Scaffold 类似,它提供了两个固定的槽位可供填充:

在这里插入图片描述
这两个槽位的默认位置由TwoPaneStrategy驱动,它可以决定将两个槽位水平或垂直排列,并可配置它们之间的间隔。

其中 strategy 参数就是你需要根据 WindowSizeClass 来决定如何显示槽位策略的地方,该库内置了两种策略可供选择:HorizontalTwoPaneStrategyVerticalTwoPaneStrategy,它们允许配置一些偏移量或空间的占位百分比。当没有折叠时,TwoPane 将使用提供的默认策略。它还需要一个displayFeatures 参数,该参数可以通过该库提供的 calculateDisplayFeatures(activity) 来获取。

FlowLayout

下图展示了一种可能出现的场景,在不同尺寸的屏幕中,底部的标签出现了显示不完整的情况。

在这里插入图片描述

它的代码可能长下面这样:

在这里插入图片描述
在这里插入图片描述

使用 FlowLayout 中的 FlowRow 组件可以轻松解决这种场景:

在这里插入图片描述
在这里插入图片描述

由于 FlowLayout 是流式的布局,当一行显示不完整时,它会自动换行,因此它可以完美兼容所有各种尺寸的屏幕,我们甚至都不需要根据WindowSizeClass做什么判断。但是请注意它能解决的问题只是局部的个别可组合项级别,也就是对应的具体的某个元素单元,如果是应用级屏幕级的最好还是需要使用WindowSizeClass来判断。

FlowLayout 也是 Accompanist 库提供的能力之一,该组件的使用示例在之前的文章 Jetpack Compose中的Accompanist 中有提到过,感兴趣的话可以查看,或者可以直接参考官方文档。

BottomNavigation 和 NavigationRail 在不同屏幕尺寸导航

BottomNavigationNavigationRail 这两个都是在 NavHost 使用之外的场景,其中 BottomNavigation 主要用于 Scaffold 脚手架,之前在 Jetpack Compose中的导航路由 中有提到过。

从窗口分类的角度来考虑, BottomNavigation 主要用于 Compact 类型的屏幕:

在这里插入图片描述

NavigationRail 主要用于 MediumExpanded 两种类型的屏幕:

在这里插入图片描述

这两种导航类型的使用方式类似,最终都需要用 NavHost 来包装导航配置:

在这里插入图片描述

为了避免重复的写 NavHost ,可以考虑使用 movableContentOf, 它可以在不同导航之间移动时保持相同的实例状态:

在这里插入图片描述

movableContent lambda 在每次执行时都会保留内部的状态,它能在组合之间移动状态。

在这里插入图片描述

在这里插入图片描述

然后可以根据 WindowSizeClass 的结果选择在 Compat 类型的屏幕展示 BottomBarLayout,而在 MediumExpanded 类型的屏幕展示 NavigationRailLayout

在这里插入图片描述

列表在不同屏幕尺寸导航

对于列表,在 Compact 类型的屏幕,也就是正常手机模式下,一般我们就只有一个列表,但是对于 Expanded 类型的屏幕场景下,情况有所不同:

在这里插入图片描述

Expanded 类型的屏幕下,由于屏幕有足够的空间,所以完全可以同时展示包含列表和详情的 Composable 页面。而在其他情况下只展示列表或者详情二者 Composable 之一即可。

在这里插入图片描述

因此对于列表以及详情视图,在考虑适配不同屏幕尺寸的情况下,总共需要提供三个可组合项,例如:

/* Displays a list of items. */
@Composable
fun ListOfItems(onItemSelected: (String) -> Unit,
) { /*...*/ }/* Displays the detail for an item. */
@Composable
fun ItemDetail(selectedItemId: String? = null,
) { /*...*/ }/* Displays a list and the detail for an item side by side. */
@Composable
fun ListAndDetail(selectedItemId: String? = null,onItemSelected: (String) -> Unit,
) {Row {ListOfItems(onItemSelected = onItemSelected)ItemDetail(selectedItemId = selectedItemId)}
}

ListDetailRoute(导航目的地)决定了要发出三个可组合项中的哪一个:ListAndDetail 适用于较大窗口;ListOfItemsItemDetail 适用于较小窗口,具体取决于是否已选择列表项。

最后 ListDetailRoute需要在包含在 NavHost 中进行配置,例如:

NavHost(navController = navController, startDestination = "listDetailRoute") {composable("listDetailRoute") {ListDetailRoute(isExpandedWindowSize = isExpandedWindowSize,selectedItemId = selectedItemId)}/*...*/
}

其中 isExpandedWindowSize 参数可以通过前面提到的计算 WindowSizeClass 相关的API来得到。

selectedItemId 参数可由在所有窗口大小下保留状态的 ViewModel 提供。当用户从列表中选择一项时,selectedItemId 状态变量会更新:

class ListDetailViewModel : ViewModel() {data class ListDetailUiState(val selectedItemId: String? = null,)private val viewModelState = MutableStateFlow(ListDetailUiState())fun onItemSelected(itemId: String) {viewModelState.update {it.copy(selectedItemId = itemId)}}
}val listDetailViewModel = ListDetailViewModel()@Composable
fun ListDetailRoute(isExpandedWindowSize: Boolean = false,selectedItemId: String?,onItemSelected: (String) -> Unit = { listDetailViewModel.onItemSelected(it) },
) {if (isExpandedWindowSize) {ListAndDetail(selectedItemId = selectedItemId,onItemSelected = onItemSelected,/*...*/)} else {if (selectedItemId != null) {ItemDetail(selectedItemId = selectedItemId,/*...*/)} else {ListOfItems(onItemSelected = onItemSelected,/*...*/)}}
}

当列表详情可组合项占据整个应用窗口时,ListDetailRoute 还包含自定义 BackHandler 以便在返回列表时更新 selectedItemId 的状态:

class ListDetailViewModel : ViewModel() {data class ListDetailUiState(val selectedItemId: String? = null,)private val viewModelState = MutableStateFlow(ListDetailUiState())fun onItemSelected(itemId: String) {viewModelState.update {it.copy(selectedItemId = itemId)}}fun onItemBackPress() {viewModelState.update {it.copy(selectedItemId = null)}}
}val listDetailViewModel = ListDetailViewModel()@Composable
fun ListDetailRoute(isExpandedWindowSize: Boolean = false,selectedItemId: String?,onItemSelected: (String) -> Unit = { listDetailViewModel.onItemSelected(it) },onItemBackPress: () -> Unit = { listDetailViewModel.onItemBackPress() },
) {if (isExpandedWindowSize) {ListAndDetail(selectedItemId = selectedItemId,onItemSelected = onItemSelected,/*...*/)} else {if (selectedItemId != null) {ItemDetail(selectedItemId = selectedItemId,/*...*/)BackHandler {onItemBackPress()}} else {ListOfItems(onItemSelected = onItemSelected,/*...*/)}}
}

对于全屏显示的列表详情页面,它可以拥有自己独立的 NavHost 配置:

在这里插入图片描述

这些里面配置的子详情导航页应该只在详情页面中自己使用,如果外部想访问某个子详情页,请使用深度链接(可参考Jetpack Compose中的导航路由)。


相关内容

热门资讯

常用商务英语口语   商务英语是以适应职场生活的语言要求为目的,内容涉及到商务活动的方方面面。下面是小编收集的常用商务...
六年级上册英语第一单元练习题   一、根据要求写单词。  1.dry(反义词)__________________  2.writ...
复活节英文怎么说 复活节英文怎么说?复活节的英语翻译是什么?复活节:Easter;"Easter,anniversar...
2008年北京奥运会主题曲 2008年北京奥运会(第29届夏季奥林匹克运动会),2008年8月8日到2008年8月24日在中华人...
英语道歉信 英语道歉信15篇  在日常生活中,道歉信的使用频率越来越高,通过道歉信,我们可以更好地解释事情发生的...
六年级英语专题训练(连词成句... 六年级英语专题训练(连词成句30题)  1. have,playhouse,many,I,toy,i...
上班迟到情况说明英语   每个人都或多或少的迟到过那么几次,因为各种原因,可能生病,可能因为交通堵车,可能是因为天气冷,有...
小学英语教学论文 小学英语教学论文范文  引导语:英语教育一直都是每个家长所器重的,那么有关小学英语教学论文要怎么写呢...
英语口语学习必看的方法技巧 英语口语学习必看的方法技巧如何才能说流利的英语? 说外语时,我们主要应做到四件事:理解、回答、提问、...
四级英语作文选:Birth ... 四级英语作文范文选:Birth controlSince the Chinese Governmen...
金融专业英语面试自我介绍 金融专业英语面试自我介绍3篇  金融专业的学生面试时,面试官要求用英语做自我介绍该怎么说。下面是小编...
我的李老师走了四年级英语日记... 我的李老师走了四年级英语日记带翻译  我上了五个学期的小学却换了六任老师,李老师是带我们班最长的语文...
小学三年级英语日记带翻译捡玉... 小学三年级英语日记带翻译捡玉米  今天,我和妈妈去外婆家,外婆家有刚剥的`玉米棒上带有玉米籽,好大的...
七年级英语优秀教学设计 七年级英语优秀教学设计  作为一位兢兢业业的人民教师,常常要写一份优秀的教学设计,教学设计是把教学原...
我的英语老师作文 我的英语老师作文(通用21篇)  在日常生活或是工作学习中,大家都有写作文的经历,对作文很是熟悉吧,...
英语老师教学经验总结 英语老师教学经验总结(通用19篇)  总结是指社会团体、企业单位和个人对某一阶段的学习、工作或其完成...
初一英语暑假作业答案 初一英语暑假作业答案  英语练习一(基础训练)第一题1.D2.H3.E4.F5.I6.A7.J8.C...
大学生的英语演讲稿 大学生的英语演讲稿范文(精选10篇)  使用正确的写作思路书写演讲稿会更加事半功倍。在现实社会中,越...
VOA美国之音英语学习网址 VOA美国之音英语学习推荐网址 美国之音网站已经成为语言学习最重要的资源站点,在互联网上还有若干网站...
商务英语期末试卷 Part I Term Translation (20%)Section A: Translate ...