June 12, 2019 / Reading time: 8m

Android GestureNavigation & RecyclerView

android

Quick tips for making your RecyclerView & ItemDecorations play along with Window Insets.

Quick Intro

With the new Android Q gesture navigation and the ever growing screen real estate, it is important to have our app displayed in full screen. Besides being awesome UX to our users, it’s also important to support the framework changes/best practices. After reading about this subject I decided to implement it on the Stinto Android App.

TL;DR Want to skip to the implementation? Check The Solution section below.

The Problem

The first screen consists of an Activity with a MaterialToolbar a FloatingActionButton and a BottomNavigationView with 3 tabs (each for a different Fragment) and a NavHostFragmnet (I’m using the jetpack navigation component).

Let’s focus on the first screen, the “My Cards”. In a nutshell, displays a list of items coming from a ViewModel (with 0 Rx shh 🤫). All standard Android things, here’s how it looks with no data:

With just two different Pixel models (1/Original and 3XL) we have three different insets and two navigation types. Remember the Pixel 3XL has a notch, which the original (Left) Pixel doesn’t. You can compare how much space there is between the top/bottom on the three screenshots.

As mentioned above, we have three views that aren’t part of the Fragment layout (MaterialToolbar, FloatingActionButton, BottomNavigationView). Now depending on the effect, you want to achieve, the easiest way to deal with these elements would be to set padding/margins for your fragment container on your activity layout — or in case you are using the Navigation component, the NavHostFragmnet.


This would work but would mean your RecyclerView wouldn’t display items below the Toolbar and BottomNavigationView as the user scrolls. Also, there’s an issue where FloatingActionButton would stay above the last element. For that, we use the RecyclerView.ItemDecorations.

For reusability I created this open/extendable ItemDecoration class:


open class RecyclerViewMarginItemDecoration(
    private val sizeInDp: Int = 16,
    private val isTop: Boolean = false
) : RecyclerView.ItemDecoration() {

    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        super.getItemOffsets(outRect, view, parent, state)
        if (isTop) {
            if (parent.getChildAdapterPosition(view) == 0) {
                outRect.top = sizeInDp.toPx()
            }
        } else {
            if (parent.getChildAdapterPosition(view) == state.itemCount - 1) {
                outRect.bottom = sizeInDp.toPx()
            }
        }
    }
}

Then I can simply extend it for common cases such as:

private const val TOOLBAR_MARGIN_SIZE_DP = 8

class RecyclerViewToolbarItemDecoration :
 RecyclerViewMarginItemDecoration(toolbarSize + TOOLBAR_MARGIN_SIZE_DP, isTop = true)

private const val FAB_MARGIN_SIZE_DP = 8

class RecyclerViewFabItemDecoration : RecyclerViewMarginItemDecoration(fabSize + FAB_MARGIN_SIZE_DP, isTop = false)

Now just add the decoration to the RecyclerView as needed:

recyclerView.addItemDecoration(RecyclerViewToolbarItemDecoration(toolbar.height))

Applying the item decoration(s) enables the RecyclerView to draw below the Toolbar and account for the FAB margin, great! Now if I stopped writing here, this would be an article about RecyclerView.ItemDecorations and not full screen/gesture navigation ready, so let’s make the app fullscreen:

Looking at the documentation I started by applying the flags to my activity’s root view:

requireView()?.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION

And that’s when the UI got messed up, now my RecyclerView.ItemDecoration aren’t accounting for both the Navigation bar as well the Status/System bar (and the notch on the Pixel 3XL), the first item of the RecyclerView is half behind the Toolbar, the last item’s FloatingActionButton margin is not working…


The Solution

First, I want to give the well-deserved credit to all these great resources. Not only I recommend you to read these, but also assume you have. I won’t be repeating any of the same information.


First thing I did was applying the necessary attributes to the Theme:

<style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
              
        ...

  <item name="android:windowLightStatusBar">true</item>
  <item name="android:statusBarColor">@color/colorStatusBar</item>
  <item name="android:windowBackground">@color/colorWindowBackground</item>
  <item name="android:navigationBarColor">@color/colorNavigationBar</item>
  <item name="android:windowLightNavigationBar">true</item>
  <item name="android:navigationBarDividerColor">@color/colorNavigationBarDivider</item>
</style>

  
    // Colors 
    <color name="colorWindowBackground">@color/white</color>
    <color name="colorStatusBar">@color/android_transparent</color>
    <color name="colorNavigationBarDivider">@color/grey_light4</color>
    <color name="colorNavigationBar">@color/android_transparent</color>
    <color name="colorBottomSheetNavigationBar">@color/white</color>

    <color name="colorToolbarBackground">@color/white20</color>
    <color name="colorBottomNavigationViewTint">@color/white20</color>

Important to note the android_transparent and white20 colors:

<color name="android_transparent">@android:color/transparent</color>
<color name="white20">#F2FFFFFF</color>

This means both the navigation bar and the status bar are fully transparent and both the MaterialToolbar and BottomNavigationView have white with 80% opacity background.

80% opacity white on the Toolbar

Now it’s time to make your toolbar play nice with the status bar by adding the necessary attribute. In my case, I’ve also set the height to wrap_content . This is mostly due to the elevation animation feel free to use ?actionBarSize.

<com.google.android.material.appbar.MaterialToolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/colorToolbarBackground" 
        android:fitsSystemWindows="true" 
        android:stateListAnimator="@animator/toolbar_elevation"/>

I’m using a MaterialToolbar for some other utility, this should not make a difference for this use case. If it does, try to the good-old AppBarLayout+Toolbar combo, as Mark Allison’s article shows.

Apply window insets to the activity views:

// Called on the Activity onCreate()

private fun applyFullScreenInsets() {
    root_view?.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION

    // Activity views
    toolbar?.applySystemBarPaddingInsets()
    fab?.applyNavigationAndBottomNavigationViewMarginInsets()
    bottom_navigation_view?.applyNavigationBarPaddingInsets()
}

As for the View extension functions, I’ll just provide the source code (sorry mobile readers but it had to be a gist) hopefully method names are explicit enough:

/************************************************
 *                 Insets Section               *
 ************************************************/

/*******************
 * Utility methods *
 *******************/

fun View.applySystemBarPaddingInsets() {
    this.doOnApplyWindowInsets { view, insets, padding, _ ->
        view.updatePadding(top = padding.top + insets.systemWindowInsetTop)
    }
}

fun View.applyNavigationBarPaddingInsets() {
    this.doOnApplyWindowInsets { view, insets, padding, _ ->
        view.updatePadding(bottom = padding.bottom + insets.systemWindowInsetBottom)
    }
}

fun View.applyNavigationAndBottomNavigationViewMarginInsets() {
    this.doOnApplyWindowInsets { view, insets, _, margin ->
        view.updateLayoutParams<ViewGroup.MarginLayoutParams> {
            setMargins(margin.left, margin.top, margin.right, margin.bottom + insets.systemWindowInsetBottom)
        }
    }
}

fun View.applyVerticalInsets() {
    this.doOnApplyWindowInsets { view, windowInsets, initialPadding, _ ->
        view.updatePadding(
            top = initialPadding.top + windowInsets.systemWindowInsetTop,
            bottom = initialPadding.bottom + windowInsets.systemWindowInsetBottom
        )
    }
}

/*********************
 * Implementation(s) *
 *********************/

fun View.doOnApplyWindowInsets(f: (View, WindowInsets, InitialPadding, InitialMargin) -> Unit) {
    // Create a snapshot of the view's padding/margin state
    val initialPadding = recordInitialPaddingForView(this)
    val initialMargin = recordInitialMarginForView(this)
    // Set an actual OnApplyWindowInsetsListener which proxies to the given
    // lambda, also passing in the original padding state
    setOnApplyWindowInsetsListener { v, insets ->
        insets.consumeSystemWindowInsets()
        f(v, insets, initialPadding, initialMargin)
        // Always return the insets, so that children can also use them
        insets
    }
    // request some insets
    requestApplyInsetsWhenAttached()
}

class InitialPadding(
    val left: Int, val top: Int,
    val right: Int, val bottom: Int
)

class InitialMargin(
    val left: Int, val top: Int,
    val right: Int, val bottom: Int
)

private fun recordInitialPaddingForView(view: View) =
    InitialPadding(view.paddingLeft, view.paddingTop, view.paddingRight, view.paddingBottom)

private fun recordInitialMarginForView(view: View) =
    InitialMargin(
        (view.layoutParams as? ViewGroup.MarginLayoutParams)?.leftMargin ?: 0,
        (view.layoutParams as? ViewGroup.MarginLayoutParams)?.topMargin ?: 0,
        (view.layoutParams as? ViewGroup.MarginLayoutParams)?.rightMargin ?: 0,
        (view.layoutParams as? ViewGroup.MarginLayoutParams)?.bottomMargin ?: 0
    )

private fun View.requestApplyInsetsWhenAttached() {
    if (isAttachedToWindow) {
        // We're already attached, just request as normal
        requestApplyInsets()
    } else {
        // We're not attached to the hierarchy, add a listener to
        // request when we are
        addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
            override fun onViewAttachedToWindow(v: View) {
                v.removeOnAttachStateChangeListener(this)
                v.requestApplyInsets()
            }

            override fun onViewDetachedFromWindow(v: View) = Unit
        })
    }
}

This is where I spent the majority of my time, expanding on Chris Banes’s original extensions as they didn’t suit all the cases I needed to address, I also wanted to make the code more concise (didn’t want to see any callback-based stuff on the UI controllers).

One important thing to note: When using the correct callback setOnApplyWindowInsetsListener() the views will get the correct size to any status/notch and navigation type.

Finally…

Now that our Activity views adapt to the available space, it’s time to make the RecyclerView be aware and take full advantage of the extra real-estate.

I created another extension function, this time for the RecyclerView. Now it also accounts for the window insets (initial extra paddings) as well the MaterialToolbar, FloatingActionButton, BottomNavigationView item padding/decorations:


EDIT

I didn’t know this but Chris Banes gave me nice tip, with the clipToPadding = false you can simply apply the normal padding to your RecyclerView no need for item decorations! If you still want to use’em use the same extension function below.

// On the list fragment simply apply:
recycler_view?.applyToolbarNavigationViewWithFabInsets()

And the corresponding RecyclerView extension function:

fun RecyclerView.applyToolbarNavigationViewWithFabInsets() {
    applyVerticalInsets()
    clipToPadding = false
    addItemDecoration(RecyclerViewToolbarItemDecoration())
	addItemDecoration(RecyclerViewBottomNavigationViewFabItemDecoration())
}

In all honesty, the winner here is the clipToPadding = false.

I was super frustrated by this, I was trying a lot of different approaches, clipToPadding was missing, what I was seeing, even though I had applied the status and navigation bar insets, RecyclerView elements/views would still cut-off, sometimes items would also animate in a strange way.

Since this is such an important aspect of this ‘issue’, and I don’t trust myself to always remember to add this property all the time on XML, I decided to make it part of the extension function instead, this small caveat is explained on the IO session DarkTheme with navigation, sadly I only came across it after I had spent all this time figuring it out.


As with many things in software, we rarely get out of the box solutions, and there are endless reasons for that, let me give you three:

  1. Hardware is ever changing (hello foldable screens 👋).
  2. Your code-base and use-case(s) are, most likely unique, with its own set of edge-cases particularities and biz rules, therefore, as most of the answers to your software questions “It depends”, making it difficult to provide that magical one size fits all solution.
  3. Documentation and/or examples can’t account for all possible edge-cases (although we have some great resources already, as mentioned above).

I hope this article helps you out, I wrote it so you won’t have to spend as much time as I did. Enjoy the final results.

P.S: Don’t worry, the app is buttery smooth, recording the Android emulator screen with Android Studio doesn't output the best quality.

With Two Button Navigation

With Gesture Navigation

Share