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

2021年09月15日 阅读数:2
这篇文章主要向大家介绍实践 | Google I/O 应用是如何适配大尺寸屏幕 UI 的?,主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。

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

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

响应式导航

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

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

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

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

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 了。segmentfault

单窗格仍是双窗格

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

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

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

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

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

咱们的替代方案是让会议列表和双窗格 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 下载代码,动手试一试。

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