Jetpack Compose 中适配不同的屏幕尺寸

Posted 川峰

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Jetpack Compose 中适配不同的屏幕尺寸相关的知识,希望对你有一定的参考价值。

窗口大小分类

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.calculateWindowSizeClass

class 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.density
        val widthWindowSizeClass = when 
            widthDp < 600f -> WindowSize.COMPACT
            widthDp < 840f -> WindowSize.MEDIUM
            else -> WindowSize.EXPANDED
        

        val heightDp = metrics.bounds.height() / Resources.getSystem().displayMetrics.density
        val heightWindowSizeClass = when 
            heightDp < 480f -> WindowSize.COMPACT
            heightDp < 900f -> WindowSize.MEDIUM
            else -> 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 1
        items(10) 
            SimpleText( "Item $it",Color.Cyan)
        
        // List 2
        items(10) 
            SimpleText( "Item $it",Color.Magenta)
        
    


@Composable
fun TwoListScreen() 
    Row 
        LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f)) 
            // List 1
            items(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().value
            PlayerScreen(devicePosture)
         
    
    
    private fun getDevicePosture(): StateFlow<DevicePosture> 
        return WindowInfoTracker.getOrCreate(this)
            .windowLayoutInfo(this)
            .flowWithLifecycle(this.lifecycle)
            .map  windowLayoutInfo ->
                val foldingFeature = windowLayoutInfo.displayFeatures
                    .filterIsInstance<FoldingFeature>().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:<version>"

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 

以上是关于Jetpack Compose 中适配不同的屏幕尺寸的主要内容,如果未能解决你的问题,请参考以下文章

Jetpack Compose简单的屏幕适配方案

如何在jetpack compose中定义不同的屏幕

Android Jetpack Compose Grid 外观视图

如何在Jetpack Compose中更改topBar对应的屏幕

Jetpack Compose 手机屏幕布局预览

屏幕滚动到顶部(Jetpack Compose 分页)