From May 18 to 20, we hosted Google’s annual I/O Developer Conference entirely online, with 112 sessions, 151 Codelabs, 79 developer meetings, 29 workshops, and many exciting announcements. While there were no new versions of Google I/O apps announced at this year’s conference, we did update our codebase to showcase some of the latest features and trends in Android development.

The experience of using apps on larger screens (tablets, foldable devices, even Chrome OS and desktop PCS) is one of our main concerns: over the past year, the popularity and adoption of larger screens has increased to 250 million active devices. So it’s important to make the most of the extra screen space. This article will show you some of the techniques we used to make Google I/O applications look good on large screens.

Responsive navigation

On wide-screen devices like tablets or landscape phones, users often hold the sides of the device, making it easier for their thumbs to reach near the sides. At the same time, navigation elements move from the bottom to the side more naturally due to the extra horizontal space. To achieve this ergonomic change, we added Navigation Rail to the Material Components for Android.

▽ Left: Bottom navigation in portrait mode. Right: Navigation Rail in landscape mode.

The Google I/O app uses two different layouts in the main Activity, which includes our ergonomic navigation. The layout under res/ Layout contains the BottomNavigationView, and the layout under RES/Layout -w720dp contains the NavigationRailView. While the program is running, we can use Kotlin’s security call operator (? .). To determine which view to present to the user based on the current device configuration.

private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) {super. OnCreate (savedInstanceState) binding = ActivityMainBinding. Inflate (layoutInflater) / / according to different configuration, there may be one of the following two navigation view. binding.bottomNavigation? . Apply {configureNavMenu (menu) setupWithNavController (navController) setOnItemReselectedListener {} / / avoid navigation interface to the same purpose. } binding.navigationRail? . Apply {configureNavMenu (menu) setupWithNavController (navController) setOnItemReselectedListener {} / / avoid navigation interface to the same purpose. }... }Copy the code

Tip: Even if you don’t need all the functionality of data binding, you can still use view binding to generate bound classes for your layout, thus avoiding the call to findViewById.

Single pane or double pane

In the calendar function, we use the list-detail pattern to show the level of information. On wide-screen devices, the display area is divided into a list of meetings on the left and details of selected meetings on the right. One particular challenge with this layout is that the same device may work best in different configurations, such as portrait versus landscape on a tablet. Since The Google I/O app uses Jetpack Navigation to switch between interfaces, how does this challenge affect Navigation diagrams and how do we keep track of what’s currently on the screen?

▽ Left picture: Portrait mode (single pane) on the tablet. Right: Landscape mode (double panes) on a tablet.

We used SlidingPaneLayout, which provides an intuitive solution to the above problem. The double panes will always exist, but depending on the size of the screen, the second pane may not be visible. SlidingPaneLayout will display both only if there is still enough space for a given pane width. We have assigned 400DP and 600DP widths to the meeting list and details panes, respectively. After some experimentation, we found that even on a large-screen tablet, displaying both panes in portrait mode makes the information too dense, so these two width values ensure that all panes are displayed at the same time only in landscape mode.

As for the navigation diagram, the destination page of the calendar is now a double-pane Fragment, and the destinations that can be displayed in each pane have been migrated to the new navigation diagram. We can use the NavController of a pane to manage the various destination pages contained in that pane, such as meeting details, lecturer details. However, we cannot navigate directly from the meeting list to the meeting details, because the two are now in different panes, that is, in different navigation diagrams.

Our alternative is to have the meeting list and the dual-pane Fragment share the same ViewModel, which in turn contains a Kotlin data stream. Whenever a user selects a meeting from the list, we send an event to the data stream, which is then collected by the dual Fragment pane and forwarded to the 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)
  )
  // On narrow-screen devices, if the meeting details pane is not already at the top, slide it in and mask it over the list.
  // If both panes are already visible, no execution effect will occur.
  binding.slidingPaneLayout.open()}Copy the code

As in the code above, slidingPanelayout.open () is called, on narrow-screen devices, sliding into the display details pane has become a user-visible part of the navigation process. We also have to slide out the details pane to “return” to the meeting list by other means. Since each destination page in a two-pane Fragment is no longer part of the app’s main navigation chart, we can’t automatically navigate backward within the pane by pressing the back button on the device, which means we need to implement this feature.

All of the above can be handled in the OnBackPressedCallback, which is registered when the onViewCreated() method of the two-pane Fragment is executed (you can learn more about adding custom navigation here). This callback listens for the movement of the sliding panes and watches for changes in the destination page for each pane’s navigation, so it can evaluate what to do the next time the back key is pressed.

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

  init {
    // Listen for the movement of the slider pane.
    slidingPaneLayout.addPanelSlideListener(this)
    // Listen for changes in the navigation destination page in both panes.
    listPaneNavController.addOnDestinationChangedListener(this)
    detailPaneNavController.addOnDestinationChangedListener(this)}override fun handleOnBackPressed(a) {
    // Pressing return has three possible effects, which we examine in order:
    // 1. Currently in the Details pane, return to meeting details from Instructor Details.
    vallistDestination = listPaneNavController.currentDestination? .idvaldetailDestination = detailPaneNavController.currentDestination? .idvar done = false
    if (detailDestination == R.id.navigation_speaker_detail) {
      done = detailPaneNavController.popBackStack()
    }
    // 2. On a narrow-screen device, if the detail page is on the top layer, try to slide it out.
    if(! done) { done = slidingPaneLayout.closePane() }// 3. Currently in the List pane, the meeting list is returned from the search results.
    if(! done && listDestination == R.id.navigation_schedule_search) { listPaneNavController.popBackStack() } syncEnabledState() }// For other necessary overrides, just call syncEnabledState().

  private fun syncEnabledState(a) {
    vallistDestination = listPaneNavController.currentDestination? .idvaldetailDestination = detailPaneNavController.currentDestination? .id isEnabled = listDestination == R.id.navigation_schedule_search || detailDestination == R.id.navigation_speaker_detail || (slidingPaneLayout.isSlideable && slidingPaneLayout.isOpen) } }Copy the code

SlidingPaneLayout has also recently been optimized and updated for foldable devices. For more information about using SlidingPaneLayout, see creating a Two-pane Layout.

Limitations of resource qualifiers

The search app bar also displays different content under different screen content. When you are searching, you can select different labels to filter the search results that you want to display. We will also display the current filter labels in one of the following two locations: narrow mode is located below the search text box, wide mode is located behind the search text box. Perhaps counterintuitively, tablets are in narrow mode when they’re landscape, and wide mode when they’re portrait.

△ Search application bar in landscape mode (narrow mode)

▽ Search application bar in portrait screen (wide mode)

Previously, we did this by using the

tag in the application bar section of the search Fragment view hierarchy and providing two different versions of the layout, one of which was limited to a specification like Layout-W720DP. This doesn’t work today, because in that case layouts or other resource files with these qualifiers would be parsed across the full screen width, but in fact we only care about the width of that particular pane.

To implement this feature, see the application bar section of the search layout code. Notice 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">

    <! The Toolbar does not support layout_weight, so we introduce a middle 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 for filtering labels in wide size. -->
      <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 for filter labels in narrow size. -->
  <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>
Copy the code

The two ViewStubs each point to a different layout, but both contain only a RecyclerView (albeit with slightly different properties). These stubs do not take up visual space at runtime until the content-inflate. All that remains to be done is to select the pile that you want to inflate after we know how wide the pane is. So we just need to use the doOnNextLayout extension function and wait for the first layout of AppBarLayout in onViewCreated().

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() } } }Copy the code

Transformation space

Android has always been able to create layouts that are available on a variety of screen sizes, with match_parent size values, resource qualifiers, and libraries such as ConstraintLayout. However, this does not always provide the best user experience for a given screen size. When UI elements are too stretched, too far apart, or too dense, they often fail to convey information, touch elements become unrecognizable, and the usability of the application suffers.

For features like “Settings”, our short list items are stretched too much on a wide screen. Since these list items themselves are unlikely to have a new layout, we can solve this problem by ConstraintLayout limiting the list width.

<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">

    <! -- Settings...... -->

  </androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
Copy the code

In line 10, @dimen/content_max_width_percent is a size value of type floating point, which may vary depending on the screen width. These values gradually decrease from 1.0 on the small screen to 0.6 on the wide screen, so UI elements don’t get too stretched and fragmented as the screen gets wider.

△ Setting interface on wide-screen devices

Please read this guide on supporting different screen sizes for common sizing cutoff points.

Convert the content

The Codelabs function has a similar structure to the Settings function. But we wanted to take advantage of the extra screen real estate, not limit the width of the content. On a narrow-screen device, you see a list of items that expand or collapse when clicked. On a wide screen, these list items are converted into cards that display the details directly.

▽ Left picture: Codelabs on narrow screen. Right: Wide screen showing Codelabs.

These separate grid cards are an alternate layout defined under RES/Layout – W840DP. Data binding deals with how information is bound to the view and how the card responds to clicks, so there’s not much to implement other than the differences in different styles. On the other hand, the entire Fragment has no alternate layout, so let’s take a look at the techniques used to achieve the desired 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"... Other layout properties... />Copy the code

Two resource files are provided, each with a different value at the size cutoff point we chose for the alternate layout:

Resource file Unqualified version (default) -w840dp
@string/codelabs_recyclerview_layoutmanager LinearLayoutManager StaggeredGridLayoutManager
@dimen/codelabs_list_item_spacing 0dp 8dp

We configure the layoutManager by setting the app:layoutManager value to the string resource in the XML file, and then setting both android:orientation and app:spanCount. Note that toward the attributes (orientation) for two kinds of layout manager is the same, but the transverse span (span count) is only applicable to StaggeredGridLayoutManager, If the layout manager being populated is the LinearLayoutManager, it will simply ignore the set horizontal span value.

Used for android: the size of the paddingHorizontal resources at the same time is also used in another attribute app: itemSpacing. It’s not a standard property of RecyclerView, so where does it come from? This is actually a property defined by the Binding Adapter, which is our way of providing custom logic to the data Binding library. When the application runs, the data binding calls the following functions and passes in the values parsed from the resource file as parameters.

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

SpaceDecoration is a simple implementation of an ItemDecoration that reserves space around each element, This also explains why we get the same spacing of elements all the time on screens 840DP or wider (need to give a positive value for @dimen/codelabs_list_item_spacing). The internal margin of RecyclerView itself is also set to the same value, which will make the distance between elements and RecyclerView boundary and the gap between elements keep the same size, forming a unified white space around the elements. To scroll elements all the way to the edge of RecyclerView, set Android :clipToPadding=”false”.

The more varied the screen, the better

Android has always been a diverse hardware ecosystem. As more tablets and foldable devices become available to users, be sure to test your app with these different sizes and screen ratios so that some users don’t feel left out. Android Studio provides both collapsible emulators and free window modes to simplify the testing process, so you can use them to check how your application responds to the above scenarios.

We hope these changes to Google I/O applications inspire you to build beautiful, high-quality applications that work well with devices of all shapes and sizes. Download the code from Github and give it a try.

Please click here to submit your feedback to us, or share your favorite content or questions. Your feedback is very important to us, thank you for your support!