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。
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。
这些独立的网格卡片是定义在 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:orientation
和 app: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))
}
}
SpaceDecoration 是 ItemDecoration
的一种简略实现,它在每个元素四周保留肯定空间,这也解释了为什么咱们会在 840dp 或更宽的屏幕上 (须要为 @dimen/codelabs_list_item_spacing
给定一个正值 ) 失去始终雷同的元素距离。将 RecyclerView
本身的内边距也设置为雷同的值,会使得元素同 RecyclerView
边界的间隔与元素间的空隙放弃雷同的大小,在元素四周造成对立的留白。为了让元素可能始终滚动显示到 RecyclerView
的边缘,须要设置 android:clipToPadding="false"
。
屏幕越多样越好
Android 始终是个多样化的硬件生态系统。随着更多的平板和可折叠设施在用户中遍及,请确保在这些不同尺寸和屏幕比例中测试您的利用,这样一些用户就不会感觉本人被 “ 冷清 ” 了。Android Studio 同时提供了 可折叠模拟器 和 自在窗口模式 以简化这些测试过程,因而您能够通过它们来查看您的利用对于上述场景的响应状况。
咱们心愿这些 Google I/O 利用上的变动能启发您构建充沛适配各种形态和尺寸设施的好看、高质量的利用。欢迎您从 Github 下载代码,入手试一试。
欢迎您 点击这里 向咱们提交反馈,或分享您喜爱的内容、发现的问题。您的反馈对咱们十分重要,感谢您的反对!