实践 | 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 应用在大尺寸屏幕上更好地显示而用到的一些技巧。
响应式导航
△ 左图: 竖屏模式下的底部导航。
右图: 横屏模式下的 navigation rail。
Navigation rail
https://material.io/components/navigation-rail
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 { } // 避免导航到同一目的界面。
}
...
}
单窗格还是双窗格
△ 左图: 平板电脑的竖屏模式 (单窗格)。
Jetpack Navigation
https://developer.android.google.cn/guide/navigation
我们采用了 SlidingPaneLayout,它为上述问题提供了一个直观的解决方案。双窗格会一直存在,但根据屏幕的尺寸,第二窗格可能不会显示在可视范围当中。只有在给定的窗格宽度下仍然有足够的空间时,SlidingPaneLayout 才会同时将两者显示出来。我们分别为会议列表和详情窗格分配了 400dp 和 600dp 的宽度。经过一些实验,我们发现即使是在大屏幕的平板上,竖屏模式同时显示出双窗格内容会使得信息的显示过于密集,所以这两个宽度值可以保证只在横屏模式下才同时展现全部窗格的内容。
SlidingPaneLayout
https://developer.android.google.cn/reference/kotlin/androidx/slidingpanelayout/widget/SlidingPaneLayout
至于导航图,日程的目的地页面现在是双窗格 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() 方法执行时会被注册 (您可以在这里了解更多关于添加自定义导航的内容)。这个回调会监听滑动窗格的移动以及关注各个窗格导航目的页面的变化,因此它能够评估下一次按下返回键时应该如何处理。
自定义导航
https://developer.android.google.cn/guide/navigation/navigation-custom-back
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)
}
}
创建双窗格布局
https://developer.android.google.cn/guide/topics/ui/layout/twopane
资源限定符的局限
搜索应用栏也在不同屏幕内容下显示不同内容。当您在搜索时,可以选择不同的标签来过滤需要显示的搜索结果,我们也会把当前生效的过滤标签显示在以下两个位置之一: 窄模式时位于搜索文本框下方,宽模式时位于搜索文本框的后面。可能有些反直觉的是,当平板电脑横屏时属于窄尺寸模式,而当其竖屏使用时属于宽尺寸模式。
布局
https://github.com/google/iosched/blob/main/mobile/src/main/res/layout/fragment_search.xml
<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>
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()
}
}
}
ViewStub
https://developer.android.google.cn/reference/android/view/ViewStubdoOnNextLayout
https://developer.android.google.cn/reference/kotlin/androidx/core/view/package-summary#doonnextlayout
转换空间
Android 一直都可以创建在多种屏幕尺寸上可用的布局,这都是由 match_parent 尺寸值、资源限定符和诸如 ConstraintLayout 的库来实现的。然而,这并不总是能在特定屏幕尺寸下为用户带来最佳的体验。当 UI 元素拉伸过度、相距过远或是过于密集时,往往难以传达信息,触控元素也变得难以辨识,并导致应用的可用性受到影响。
<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>
指南
https://developer.android.google.cn/training/multiscreen/screensizes#TaskUseSWQuali
转换内容
Codelabs 功能与设置功能有相似的结构。但我们想要充分利用额外的屏幕空间,而不是限制显示内容的宽度。在窄屏幕设备上,您会看到一列项目,它们会在点击时展开或折叠。在宽尺寸屏幕上,这些列表项会转换为一格一格的卡片,卡片上直接显示了详细的内容。
右图: 宽屏幕显示 Codelabs。
这些独立的网格卡片是定义在 res/layout-w840dp 下的备用布局,数据绑定处理信息如何与视图绑定,以及卡片如何响应点击,所以除了不同样式下的差异之外,不需要实现太多内容。另一方面,整个 Fragment 没有备用布局,所以让我们看看在不同的配置下实现所需的样式和交互都用到了哪些技巧吧。
备用布局
https://github.com/google/iosched/blob/main/mobile/src/main/res/layout-w840dp/item_codelab.xml
所有的一切都集中在这个 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 |
@BindingAdapter("itemSpacing")
fun itemSpacing(recyclerView: RecyclerView, dimen: Float) {
val space = dimen.toInt()
if (space > 0) {
recyclerView.addItemDecoration(SpaceDecoration(space, space, space, space))
}
}
Binding Adapter
https://developer.android.google.cn/topic/libraries/data-binding/binding-adapters
SpaceDecoration
https://github.com/google/iosched/blob/main/mobile/src/main/java/com/google/samples/apps/iosched/widget/SpaceDecoration.kt
屏幕越多样越好
可折叠模拟器
https://developer.android.google.cn/guide/topics/ui/foldables#emulators自由窗口模式
https://developer.android.google.cn/studio/releases/emulator#freeform_window_mode
Github
https://github.com/google/iosched
欢迎您通过下方二维码向我们提交反馈,或分享您喜欢的内容、发现的问题。您的反馈对我们非常重要,感谢您的支持!
推荐阅读