头图

From May 18th to 20th, we held Google’s annual I/O Developer Conference in a completely online format, including 112 meetings, 151 Codelabs, 79 developer gatherings, and 29 seminars , And many exciting releases. Although this year's conference did not release a new version of the Google I/O application, we still updated the code base to show some of the latest features and trends in Android development.

Application experience on large screens (tablets, foldable devices and even Chrome OS and desktop PCs) is one of our concerns: In the past year, devices with large screens have become more and more popular. User usage is also getting higher and higher, and has now grown to 250 million active devices. Therefore, it is especially important for apps to make full use of the extra screen space. This article will show some of the techniques we used to make Google I/O applications better display on large screens.

Responsive navigation

On wide-screen devices such as tablets or horizontal phones, users usually hold both sides of the device, so the user’s thumb can more easily reach the area near the side. At the same time, due to the additional horizontal space, it is more natural for navigation elements to move from the bottom to the side. In order to achieve this ergonomic change, we added Navigation rail Material Components for the Android platform.

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

△ Left: The bottom navigation in portrait mode. Right: the navigation rail in landscape mode.

The Google I/O application uses two different layouts in the main Activity, which includes our ergonomic navigation. The layout in the res/layout directory contains BottomNavigationView , and the layout res/layout-w720dp NavigationRailView . During the running of the program, we can use Kotlin's safe call operator (?.) to determine which view is presented to the user according to the current device configuration.

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 { } // 避免导航到同一目的界面。
  }
  ...
}

Tips: Even if you don't need all the functions of data binding, you can still use view binding to generate binding classes for your layout, so that you can avoid calling findViewById .

Single pane or dual pane

In the schedule function, we use the list-detail model to display the level of information. On wide-screen devices, the display area is divided into the meeting list on the left and the selected meeting details on the right. A special challenge brought by this layout method is that the same device may have different best display methods under different configurations. For example, the vertical screen of a tablet computer may be different from the horizontal screen. Since the Google I/O application uses Jetpack Navigation to switch between different interfaces, how does this challenge affect the navigation chart, and how do we record the content on the current screen?

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

△ Left: The portrait mode (single pane) of the tablet. Right: The landscape mode (dual pane) of the tablet.

We used SlidingPaneLayout , which provides an intuitive solution to the above problems. The dual pane will always exist, but depending on the size of the screen, the second pane may not be displayed in the visible range. SlidingPaneLayout will display both at the same time only if there is still enough space under the given pane width. We have allocated 400dp and 600dp widths for the meeting list and details pane respectively. After some experiments, we found that even on a large-screen tablet, the dual-pane content displayed in portrait mode at the same time will make the display of information too dense, so these two width values can be guaranteed to be displayed at the same time only in landscape mode The contents of all panes.

As for the navigation map, the destination page of the schedule is now a dual-pane Fragment, and the destinations that can be displayed in each pane have been migrated to the new navigation map. We can use NavController a certain pane to manage the various purpose pages contained in the pane, such as meeting details and lecturer details. However, we cannot directly navigate to the meeting details from the meeting list, because the two have now been placed in different panes, that is, they exist in different navigation graphs.

Our alternative is to make the meeting list and the two-pane Fragment share the same ViewModel , which also contains a Kotlin data stream. Whenever the user selects a meeting from the list, we will send an event to the data stream, and then the two-pane Fragment can collect this event, and then forward it to NavController in the meeting details pane:

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() is called in the above code, on narrow-screen devices, sliding into the display details pane has become the user's visible part of the navigation process. We must also slide out the details pane to "return" to the meeting list in other ways. Since each destination page in the dual-pane Fragment is no longer part of the main navigation map of the application, we cannot automatically navigate backwards in the pane by pressing the back button on the device, that is, we need to implement this function.

All of the above situations can be OnBackPressedCallback onViewCreated() method of the dual pane Fragment is executed (you can learn more about adding custom navigation here). This callback will monitor the movement of the sliding pane and pay attention to the changes of the navigation destination page of each pane, so it can evaluate how to deal with the next time the return key is pressed.

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 has also recently been optimized and updated for foldable devices. For more information about using SlidingPaneLayout, please refer to: create a dual-pane layout .

resource qualifiers

The search application bar also displays different content under different screen content. When you are searching, you can select different tags to filter the search results that need to be displayed. We will also display the currently effective filter tags in one of the following two positions: narrow mode is located below the search text box, and wide mode is located Search behind the text box. It may be counter-intuitive that the tablet is in the narrow mode when it is horizontally screened, and in the wide mode when it is used in the vertical screen.

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

△ Search application bar when the tablet is horizontally screened (narrow mode)

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

△ Search application bar when the tablet is in portrait mode (wide mode)

<include> tag in the application bar part of the view hierarchy of the search Fragment, and provided two different versions of the layout to achieve this function, one of which was limited to a specification such layout-w720dp Now this method does not work, because in that case, the layout or other resource files with these qualifiers will be parsed according to the entire screen width, but in fact we only care about the width of that particular pane.

To achieve this feature, please refer to search for the application bar code of layout Note the two ViewStub elements (lines 27 and 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>

The two ViewStub each point to a different layout, but both contain only one RecyclerView (although the attributes are slightly different). These stubs will not occupy visual space at runtime until the content is inflate. All that remains to do is when we know how wide the pane is, select the pile to inflate. So we only need to use doOnNextLayout extension function and wait for the first layout of onViewCreated() in 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()
    }
  }
}

conversion space

Android has always been able to create layouts that can be used on multiple screen sizes. This is all match_parent by 06149910bd4db5 size values, resource qualifiers, and libraries ConstraintLayout However, this is not always the best experience for users under a certain screen size. When UI elements are too stretched, too far apart, or too dense, it is often difficult to convey information, and touch elements become difficult to recognize, which affects the usability of the application.

For functions like "Settings", our short list items will be stretched severely on wide screens. Since these list items are unlikely to have a new layout, we can solve the problem by limiting the width of the list 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>

In line 10, @dimen/content_max_width_percent is a floating-point type size value, which may have different values according to different screen widths. These values are gradually reduced from 1.0 for small screens to 0.6 for wide screens, so when the screen becomes wider, UI elements will not experience a sense of fragmentation due to excessive stretching.

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

△ Setting interface on wide screen device

guide that supports different screen sizes to get reference information about common size cutoff points.

Converted content

The Codelabs function has a similar structure to the setting function. But we want to make full use of the extra screen space, not limit the width of the displayed content. On narrow screen devices, you will see a list of items that will expand or collapse when clicked. On a wide screen, these list items will be converted into a grid of cards, and the detailed content is directly displayed on the card.

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

△ Left: Codelabs displayed on a narrow screen. Right: Widescreen display of Codelabs.

These independent grids card is defined res/layout-w840dp under alternate layouts , how to process information and data binding view bindings, and click response cards, so in addition to the differences in different patterns, need not achieve much content . On the other hand, there is no alternate layout for the entire Fragment, so let's see what techniques are used to achieve the required styles and interactions in different configurations.

Everything is focused on this RecyclerView element:

<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"
  ……其他的布局属性……/>

Two resource files are provided here, each of which has a different value at the size cutoff point we selected for the alternate layout:

Resource file unqualified version (default) -w840dp
@string/codelabs_recyclerview_layoutmanagerLinearLayoutManagerStaggeredGridLayoutManager
@dimen/codelabs_list_item_spacing0dp8dp

app:layoutManager to the string resource just now in the XML file android:orientation and app:spanCount to configure the layout manager. Note that the orientation property (orientation) is the same for the two layout managers, but the horizontal span (span count) only applies to StaggeredGridLayoutManager . If the filled layout manager is LinearLayoutManager , then it will simply ignore the setting Horizontal span value.

The size resource used for android:paddingHorizontal is also used for another attribute app:itemSpacing . It is not a standard property of RecyclerView, so where does it come from? This is actually Binding Adapter , and Binding Adapter is the way we provide custom logic to the data binding library. When the application is running, data binding will call the following function and pass the value parsed from the resource file as a parameter.

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

SpaceDecoration is ItemDecoration , which reserves a certain amount of space around each element, which explains why we will always get the same @dimen/codelabs_list_item_spacing Element spacing. Setting RecyclerView itself to the same value will make RecyclerView boundary and the gap between the elements remain the same size, forming a uniform white space around the element. In order for the element to scroll to RecyclerView , you need to set android:clipToPadding="false" .

more diverse the screen, the better

Android has always been a diverse hardware ecosystem. As more tablets and foldable devices become popular among users, make sure to test your app in these different sizes and screen ratios so that some users will not feel "outsided". Android Studio also provides foldable emulators and free window mode to simplify these testing processes, so you can use them to check your application's response to the above scenarios.

We hope that these changes in Google I/O applications can inspire you to build beautiful, high-quality applications that fully adapt to devices of all shapes and sizes. You are welcome to Github and give it a try.

Welcome Click here to submit feedback to us, or share your favorite content or problems found. Your feedback is very important to us, thank you for your support!


Android开发者
404 声望2k 粉丝

Android 最新开发技术更新,包括 Kotlin、Android Studio、Jetpack 和 Android 最新系统技术特性分享。更多内容,请关注 官方 Android 开发者文档。