实践 | Google I/O 应用是如何适配大尺寸屏幕 UI 的?

5 月 18 日至 20 日,我们以完全线上的形式举办了 Google 每年一度的 I/O 开发者大会,其中包括 112 场会议、151 个 Codelab、79 场开发者聚会、29 场研讨会,以及众多令人兴奋的发布。尽管今年的大会没有发布新版的 Google I/O 应用,我们仍然更新了代码库来展示时下 Android 开发最新的一些特性和趋势。

应用在大尺寸屏幕 (平板、可折叠设备甚至是 Chrome OS 和台式个人电脑) 上的使用体验是我们的关注点之一: 在过去的一年中,大尺寸屏幕的设备越来越受欢迎,用户使用率也越来越高,如今已增长到 2.5 亿台活跃设备了。因此,让应用能充分利用额外的屏幕空间显得尤其重要。本文将展示我们为了让 Google I/O 应用在大尺寸屏幕上更好地显示而用到的一些技巧。

响应式导航

在平板电脑这类宽屏幕设备或者横屏手机上,用户们通常握持着设备的两侧,于是用户的拇指更容易触及侧边附近的区域。同时,由于有了额外的横向空间,导航元素从底部移至侧边也显得更加自然。为了实现这种符合人体工程学的改变,我们在用于 Android 平台的 Material Components 中新增了 Navigation rail

△ 左图: 竖屏模式下的底部导航。右图: 横屏模式下的 navigation rail。

△ 左图: 竖屏模式下的底部导航。右图: 横屏模式下的 navigation rail。

Google I/O 应用在主 Activity 中使用了两个不同的布局,其中包含了我们的人体工程学导航。其中在 res/layout 目录下的布局中包含了 BottomNavigationView,而在 res/layout-w720dp 目录下的布局中则包含了 NavigationRailView。在程序运行过程中,我们可以通过 Kotlin 的安全调用操作符 (?.) 来根据当前的设备配置确定呈现给用户哪一个视图。

private lateinit var binding: ActivityMainBinding

override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  binding = ActivityMainBinding.inflate(layoutInflater)

  // 根据配置不同,可能存在下面两种导航视图之一。
  binding.bottomNavigation?.apply {
    configureNavMenu(menu)
    setupWithNavController(navController)
    setOnItemReselectedListener { } // 避免导航到同一目的界面。
  }
  binding.navigationRail?.apply {
    configureNavMenu(menu)
    setupWithNavController(navController)
    setOnItemReselectedListener { } // 避免导航到同一目的界面。
  }
  ...
}

小贴士: 即使您不需要数据绑定的所有功能,您仍然可以使用 视图绑定 来为您的布局生成绑定类,这样就能避免调用 findViewById 了。

单窗格还是双窗格

在日程功能中,我们用列表-详情的模式来展示信息的层次。在宽屏幕设备上,显示区域被划分为左侧的会议列表和右侧的所选会议详细信息。这种布局方式带来的一个特别的挑战是,同一台设备在不同的配置下可能有不同的最佳显示方式,比如平板电脑竖屏对比横屏显示就有差异。由于 Google I/O 应用使用了 Jetpack Navigation 实现不同界面之间的切换,这个挑战对导航图有怎样的影响,我们又该如何记录当前屏幕上的内容呢?

△ 左图: 平板电脑的竖屏模式 (单窗格)。右图: 平板电脑的横屏模式 (双窗格)。

△ 左图: 平板电脑的竖屏模式 (单窗格)。右图: 平板电脑的横屏模式 (双窗格)。

我们采用了 SlidingPaneLayout,它为上述问题提供了一个直观的解决方案。双窗格会一直存在,但根据屏幕的尺寸,第二窗格可能不会显示在可视范围当中。只有在给定的窗格宽度下仍然有足够的空间时,SlidingPaneLayout 才会同时将两者显示出来。我们分别为会议列表和详情窗格分配了 400dp 和 600dp 的宽度。经过一些实验,我们发现即使是在大屏幕的平板上,竖屏模式同时显示出双窗格内容会使得信息的显示过于密集,所以这两个宽度值可以保证只在横屏模式下才同时展现全部窗格的内容。

至于导航图,日程的目的地页面现在是双窗格 Fragment,而每个窗格中可以展示的目的地都已经被迁移到新的导航图中了。我们可以用某窗格的 NavController 来管理该窗格内包含的各个目的页面,比如会议详情、讲师详情。不过,我们不能直接从会议列表导航到会议详情,因为两者如今已经被放到了不同的窗格中,也就是存在于不同的导航图里。

我们的替代方案是让会议列表和双窗格 Fragment 共享同一个 ViewModel,其中又包含了一个 Kotlin 数据流。每当用户从列表选中一个会议,我们会向数据流发送一个事件,随后双窗格 Fragment 就可以收集此事件,进而转发到会议详情窗格的 NavController:

val detailPaneNavController = 
  (childFragmentManager.findFragmentById(R.id.detail_pane) as NavHostFragment)
  .navController
scheduleTwoPaneViewModel.selectSessionEvents.collect { sessionId ->
  detailPaneNavController.navigate(
    ScheduleDetailNavGraphDirections.toSessionDetail(sessionId)
  )
  // 在窄屏幕设备上,如果会议详情窗格尚未处于最顶端时,将其滑入并遮挡在列表上方。
  // 如果两个窗格都已经可见,则不会产生执行效果。
  binding.slidingPaneLayout.open()
}

正如上面的代码中调用 slidingPaneLayout.open() 那样,在窄屏幕设备上,滑入显示详情窗格已经成为了导航过程中的用户可见部分。我们也必须要将详情窗格滑出,从而通过其他方式 “返回” 会议列表。由于双窗格 Fragment 中的各个目的页面已经不属于应用主导航图的一部分了,因此我们无法通过按设备上的后退按钮在窗格内自动向后导航,也就是说,我们需要实现这个功能。

上面这些情况都可以在 OnBackPressedCallback 中处理,这个回调在双窗格 Fragment 的 onViewCreated() 方法执行时会被注册 (您可以在这里了解更多关于添加 自定义导航 的内容)。这个回调会监听滑动窗格的移动以及关注各个窗格导航目的页面的变化,因此它能够评估下一次按下返回键时应该如何处理。

class ScheduleBackPressCallback(
  private val slidingPaneLayout: SlidingPaneLayout,
  private val listPaneNavController: NavController,
  private val detailPaneNavController: NavController
) : OnBackPressedCallback(false),
  SlidingPaneLayout.PanelSlideListener,
  NavController.OnDestinationChangedListener {

  init {
    // 监听滑动窗格的移动。
    slidingPaneLayout.addPanelSlideListener(this)
    // 监听两个窗格内导航目的页面的变化。
    listPaneNavController.addOnDestinationChangedListener(this)
    detailPaneNavController.addOnDestinationChangedListener(this)
  }

  override fun handleOnBackPressed() {
    // 按下返回有三种可能的效果,我们按顺序检查:
    // 1. 当前正在详情窗格,从讲师详情返回会议详情。
    val listDestination = listPaneNavController.currentDestination?.id
    val detailDestination = detailPaneNavController.currentDestination?.id
    var done = false
    if (detailDestination == R.id.navigation_speaker_detail) {
      done = detailPaneNavController.popBackStack()
    }
    // 2. 当前在窄屏幕设备上,如果详情页正在顶层,尝试将其滑出。
    if (!done) {
      done = slidingPaneLayout.closePane()
    }
    // 3. 当前在列表窗格,从搜索结果返回会议列表。
    if (!done && listDestination == R.id.navigation_schedule_search) {
      listPaneNavController.popBackStack()
    }

    syncEnabledState()
  }

  // 对于其他必要的覆写,只需要调用 syncEnabledState()。

  private fun syncEnabledState() {
    val listDestination = listPaneNavController.currentDestination?.id
    val detailDestination = detailPaneNavController.currentDestination?.id
    isEnabled = listDestination == R.id.navigation_schedule_search ||
      detailDestination == R.id.navigation_speaker_detail ||
      (slidingPaneLayout.isSlideable && slidingPaneLayout.isOpen)
  }
}

SlidingPaneLayout 最近也针对可折叠设备进行了优化更新。更多关于使用 SlidingPaneLayout 的信息,请参阅: 创建双窗格布局

资源限定符的局限

搜索应用栏也在不同屏幕内容下显示不同内容。当您在搜索时,可以选择不同的标签来过滤需要显示的搜索结果,我们也会把当前生效的过滤标签显示在以下两个位置之一: 窄模式时位于搜索文本框下方,宽模式时位于搜索文本框的后面。可能有些反直觉的是,当平板电脑横屏时属于窄尺寸模式,而当其竖屏使用时属于宽尺寸模式。

△ 平板横屏时的搜索应用栏 (窄模式)

△ 平板横屏时的搜索应用栏 (窄模式)

△ 平板竖屏时的搜索应用栏 (宽模式)

△ 平板竖屏时的搜索应用栏 (宽模式)

此前,我们通过在搜索 Fragment 的视图层次中的应用栏部分使用 <include> 标签,并提供两种不同版本的布局来实现此功能,其中一个被限定为 layout-w720dp 这样的规格。如今此方法行不通了,因为在那种情况下,带有这些限定符的布局或是其他资源文件都会被按照整屏幕宽度解析,但事实上我们只关心那个特定窗格的宽度。

要实现这一特性,请参阅搜索 布局 的应用栏部分代码。请注意两个 ViewStub 元素 (第 27 和 28 行)。

<com.google.android.material.appbar.AppBarLayout
  android:id="@+id/appbar"
  android:layout_width="match_parent"
  android:layout_height="wrap_content"
  ... >

  <androidx.appcompat.widget.Toolbar
    android:layout_width="match_parent"
    android:layout_height="?actionBarSize">

    <!-- Toolbar 不支持 layout_weight,所以我们引入一个中间布局 LinearLayout。-->
    <LinearLayout
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:orientation="horizontal"
      android:showDividers="middle"
      ... >

      <SearchView
        android:id="@+id/searchView"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="2"
        ... />

      <!-- 宽尺寸时过滤标签的 ViewStub。-->
      <ViewStub
        android:id="@+id/active_filters_wide_stub"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="3"
        android:layout="@layout/search_active_filters_wide"
        ... />
    </LinearLayout>
  </androidx.appcompat.widget.Toolbar>

  <!-- 窄尺寸时过滤标签的 ViewStub。-->
  <ViewStub
    android:id="@+id/active_filters_narrow_stub"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout="@layout/search_active_filters_narrow"
    ... />
</com.google.android.material.appbar.AppBarLayout>

两个 ViewStub 各自指向不同的布局,但都只包含了一个 RecyclerView (虽然属性略有不同)。这些桩 (stub) 在运行时直到内容 inflate 之前都不会占据可视空间。剩下要做的就是当我们知道窗格有多宽之后,选择要 inflate 的桩。所以我们只需要使用 doOnNextLayout 扩展函数,等待 onViewCreated() 中对 AppBarLayout 进行首次布局即可。

binding.appbar.doOnNextLayout { appbar ->
  if (appbar.width >= WIDE_TOOLBAR_THRESHOLD) {
    binding.activeFiltersWideStub.viewStub?.apply {
      setOnInflateListener { _, inflated ->
        SearchActiveFiltersWideBinding.bind(inflated).apply {
          viewModel = searchViewModel
          lifecycleOwner = viewLifecycleOwner
        }
      }
      inflate()
    }
  } else {
    binding.activeFiltersNarrowStub.viewStub?.apply {
      setOnInflateListener { _, inflated ->
        SearchActiveFiltersNarrowBinding.bind(inflated).apply {
          viewModel = searchViewModel
          lifecycleOwner = viewLifecycleOwner
        }
      }
      inflate()
    }
  }
}

转换空间

Android 一直都可以创建在多种屏幕尺寸上可用的布局,这都是由 match_parent 尺寸值、资源限定符和诸如 ConstraintLayout 的库来实现的。然而,这并不总是能在特定屏幕尺寸下为用户带来最佳的体验。当 UI 元素拉伸过度、相距过远或是过于密集时,往往难以传达信息,触控元素也变得难以辨识,并导致应用的可用性受到影响。

对于类似 “Settings” (设置) 这样的功能,我们的短列表项在宽屏幕上会被拉伸地很严重。由于这些列表项本身不太可能有新的布局方式,我们可以通过 ConstraintLayout 限制列表宽度来解决。

<androidx.constraintlayout.widget.ConstraintLayout
  android:layout_width="match_parent"
  android:layout_height="match_parent">
  <androidx.core.widget.NestedScrollView
    android:id="@+id/scroll_view"
    android:layout_width="0dp"
    android:layout_height="match_parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintWidth_percent="@dimen/content_max_width_percent">

    <!-- 设置项……-->

  </androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

在第 10 行,@dimen/content_max_width_percent 是一个浮点数类型的尺寸值,根据不同的屏幕宽度可能有不同的值。这些值从小屏幕的 1.0 开始渐渐减少到宽屏幕的 0.6,所以当屏幕变宽,UI 元素也不会因为拉伸过度而产生割裂感。

△ 宽屏幕设备上的设置界面

△ 宽屏幕设备上的设置界面

请您阅读这则关于支持不同屏幕尺寸的 指南,获得常见尺寸分界点的参考信息。

转换内容

Codelabs 功能与设置功能有相似的结构。但我们想要充分利用额外的屏幕空间,而不是限制显示内容的宽度。在窄屏幕设备上,您会看到一列项目,它们会在点击时展开或折叠。在宽尺寸屏幕上,这些列表项会转换为一格一格的卡片,卡片上直接显示了详细的内容。

△ 左图: 窄屏幕显示 Codelabs。右图: 宽屏幕显示 Codelabs。

△ 左图: 窄屏幕显示 Codelabs。右图: 宽屏幕显示 Codelabs。

这些独立的网格卡片是定义在 res/layout-w840dp 下的 备用布局,数据绑定处理信息如何与视图绑定,以及卡片如何响应点击,所以除了不同样式下的差异之外,不需要实现太多内容。另一方面,整个 Fragment 没有备用布局,所以让我们看看在不同的配置下实现所需的样式和交互都用到了哪些技巧吧。

所有的一切都集中在这个 RecyclerView 元素上:

<androidx.recyclerview.widget.RecyclerView
  android:id="@+id/codelabs_list"
  android:clipToPadding="false"
  android:orientation="vertical"
  android:paddingHorizontal="@dimen/codelabs_list_item_spacing"
  android:paddingVertical="8dp"
  app:itemSpacing="@{@dimen/codelabs_list_item_spacing}"
  app:layoutManager="@string/codelabs_recyclerview_layoutmanager"
  app:spanCount="2"
  ……其他的布局属性……/>

这里提供了两个资源文件,每一个在我们为备用布局选择的尺寸分界点上都有不同的值:

资源文件 无限定符版本 (默认) -w840dp
@string/codelabs_recyclerview_layoutmanager LinearLayoutManager StaggeredGridLayoutManager
@dimen/codelabs_list_item_spacing 0dp 8dp

我们通过在 XML 文件中把 app:layoutManager 的值设置为刚才的字符串资源,然后同时设置 android:orientationapp:spanCount 实现布局管理器的配置。注意,朝向属性 (orientation) 对两种布局管理器而言是相同的,但是横向跨度 (span count) 只适用于 StaggeredGridLayoutManager,如果被填充的布局管理器是 LinearLayoutManager,那么它会简单地忽略设定的横向跨度值。

用于 android:paddingHorizontal 的尺寸资源同时也被用于另一个属性 app:itemSpacing。它不是 RecyclerView 的标准属性,那它从何而来?这其实是由 Binding Adapter 定义的一个属性,而 Binding Adapter 是我们向数据绑定库提供自定义逻辑的方法。在应用运行时,数据绑定会调用下面的函数,并将解析自资源文件的值作为参数传进去。

@BindingAdapter("itemSpacing")
fun itemSpacing(recyclerView: RecyclerView, dimen: Float) {
  val space = dimen.toInt()
  if (space > 0) {
    recyclerView.addItemDecoration(SpaceDecoration(space, space, space, space))
  }
}

SpaceDecorationItemDecoration 的一种简单实现,它在每个元素周围保留一定空间,这也解释了为什么我们会在 840dp 或更宽的屏幕上 (需要为 @dimen/codelabs_list_item_spacing 给定一个正值) 得到始终相同的元素间隔。将 RecyclerView 自身的内边距也设置为相同的值,会使得元素同 RecyclerView 边界的距离与元素间的空隙保持相同的大小,在元素周围形成统一的留白。为了让元素能够一直滚动显示到 RecyclerView 的边缘,需要设置 android:clipToPadding="false"

屏幕越多样越好

Android 一直是个多样化的硬件生态系统。随着更多的平板和可折叠设备在用户中普及,请确保在这些不同尺寸和屏幕比例中测试您的应用,这样一些用户就不会觉得自己被 “冷落” 了。Android Studio 同时提供了 可折叠模拟器自由窗口模式 以简化这些测试过程,因此您可以通过它们来检查您的应用对于上述场景的响应情况。

我们希望这些 Google I/O 应用上的变动能启发您构建充分适配各种形状和尺寸设备的美观、高质量的应用。欢迎您从 Github 下载代码,动手试一试。

欢迎您 点击这里 向我们提交反馈,或分享您喜欢的内容、发现的问题。您的反馈对我们非常重要,感谢您的支持!