How to support full screen navigation UI for RecyclerView?

1

Background

I wanted to support full screen navigation UI as shown here:

https://medium.com/androiddevelopers/windowinsets-listeners-to-layouts-8f9ccc8fa4d1 https://developer.android.com/guide/navigation/gesturenav

The problem

While all Activities of my app worked fine, I suddently reached a problematic one that has a RecyclerView with thumbs.

Here, I got 2 weird issues:

  1. When scrolling to the bottom, the last item/s don't get fully shown.

enter image description here

  1. When in landscape mode, the thumbs go outside of anywhere that I can reach, so they are also not touchable. Not only that, but I can also see the normal scrollbar, and both get to disappear when scrolling to the bottom:

enter image description here

What I've tried

I tried to apply the insets to various views, including both padding and margins, but nothing helped.

Also, in some websites I saw that I should use View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION and in some that I need to also add View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN. Didn't help.

Here's my current code (project available here, as I think this is a bug) :

MainActivity.kt

class MainActivity : AppCompatActivity(R.layout.activity_main) {
    inline fun View.updateMargins(@Px left: Int = marginLeft, @Px top: Int = marginTop, @Px right: Int = marginRight, @Px bottom: Int = marginBottom) {
        updateLayoutParams<ViewGroup.MarginLayoutParams> {
            this.bottomMargin = bottom
            this.topMargin = top
            this.leftMargin = left
            this.rightMargin = right
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setSupportActionBar(toolbar)
        findViewById<View>(android.R.id.content).systemUiVisibility =
            View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
        ViewCompat.setOnApplyWindowInsetsListener(appBarLayout) { _, insets ->
            val systemWindowInsets = insets.systemWindowInsets
            appBarLayout.updateMargins(
                left = systemWindowInsets.left,
                top = systemWindowInsets.top,
                right = systemWindowInsets.right
            )
            insets
        }

        ViewCompat.setOnApplyWindowInsetsListener(recyclerView) { _, insets ->
            val systemWindowInsets = insets.systemWindowInsets
            recyclerView.updatePadding(
                left = systemWindowInsets.left,
                bottom = systemWindowInsets.bottom,
                right = systemWindowInsets.right
            )
            insets
        }

        recyclerView.adapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
            init {
                setHasStableIds(true)
            }

            override fun onCreateViewHolder(
                parent: ViewGroup,
                viewType: Int
            ): RecyclerView.ViewHolder {
                return object : RecyclerView.ViewHolder(
                    LayoutInflater.from(this@MainActivity).inflate(R.layout.list_item, parent, false)
                ) {}
            }

            override fun getItemId(position: Int): Long = position.toLong()

            override fun getItemCount(): Int = 100

            override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
                holder.itemView.imageView.setColorFilter(if (position % 2 == 0) 0xffff0000.toInt() else 0xff00ff00.toInt())
                holder.itemView.textView.text = "item $position"
            }

        }
    }

    override fun onCreateOptionsMenu(menu: Menu): Boolean {
        menu.add("test").setIcon(android.R.drawable.ic_dialog_email).setOnMenuItemClickListener {
            true
        }.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
        return super.onCreateOptionsMenu(menu)
    }
}

styles.xml (I use AppTheme in the manifest as theme)

<resources xmlns:tools="http://schemas.android.com/tools">

    <style name="AppTheme" parent="Theme.MaterialComponents.NoActionBar">
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
        <item name="android:navigationBarColor" tools:targetApi="lollipop">
            @android:color/transparent
        </item>
        <item name="android:statusBarColor" tools:targetApi="lollipop">@color/colorPrimaryDark
        </item>
    </style>

    <style name="AppTheme.AppBarOverlay" parent="ThemeOverlay.MaterialComponents.Dark.ActionBar" />
</resources>

activity_main.xml

<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity">


    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appBarLayout" android:layout_width="match_parent" android:layout_height="wrap_content"
        android:theme="@style/AppTheme.AppBarOverlay">
        <!--app:popupTheme="@style/AppTheme.PopupOverlay"-->
        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary" />
    </com.google.android.material.appbar.AppBarLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView" android:layout_width="match_parent" android:layout_height="match_parent"
        android:orientation="vertical" android:scrollbars="vertical" app:fastScrollEnabled="true"
        app:fastScrollHorizontalThumbDrawable="@drawable/thumb_drawable"
        app:fastScrollHorizontalTrackDrawable="@drawable/line_drawable"
        app:fastScrollVerticalThumbDrawable="@drawable/thumb_drawable"
        app:fastScrollVerticalTrackDrawable="@drawable/line_drawable"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        app:layout_behavior="@string/appbar_scrolling_view_behavior" tools:itemCount="100"
        tools:listitem="@layout/list_item" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

line.xml

<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">

    <solid android:color="@android:color/darker_gray" />

    <padding
        android:bottom="10dp" android:left="10dp" android:right="10dp" android:top="10dp" />
</shape>

line_drawable.xml

<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@drawable/line" android:state_pressed="true" />

    <item android:drawable="@drawable/line" />
</selector>

thumb.xml

<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
    <corners
        android:bottomLeftRadius="44dp" android:topLeftRadius="44dp" android:topRightRadius="44dp" />
    <padding
        android:paddingLeft="22dp" android:paddingRight="22dp" />
    <solid android:color="@color/colorPrimaryDark" />
</shape>

thumb_drawable.xml

<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@drawable/thumb" android:state_pressed="true" />

    <item android:drawable="@drawable/thumb" />
</selector>

The questions

  1. Why does it occur? It worked fine for various other places...
  2. How can I make the RecyclerView avoid both these cases, yet allow the navigation bar at the bottom show content of the RecyclerView as it is transparent?
  3. In which cases should I add the flag View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN? What does it do to help in these cases?

EDIT: a possible workaround is to avoid using CoordinatorLayout. It works well, but I wanted to do things "the official way". Here's this workaround:

Instead of CoordinatorLayout I used :

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical"
    xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity">

...

And in code, I've set both margins and padding to the RecyclerView:

    ViewCompat.setOnApplyWindowInsetsListener(recyclerView) { _, insets ->
        val systemWindowInsets = insets.systemWindowInsets
        recyclerView.updatePadding(
            bottom = systemWindowInsets.bottom
        )
        recyclerView.updateMargins(
            left = systemWindowInsets.left,
            right = systemWindowInsets.right
        )
        insets
    }
android
android-recyclerview
asked on Stack Overflow Oct 30, 2019 by android developer • edited Oct 30, 2019 by android developer

2 Answers

2

As there is a comment discussion on my previous answer, I will not edit that one (it might also benefit someone).

The simplest thing to do, is to let the AppBarLayout handle the top and bottom insets. We can do this by using the android:fitsSystemWindows="true" on the AppBarLayout. For the items to be seen through the navigation bar, use android:clipToPadding="false" on the RecyclerView. Use android:scrollbars="none" on the RecyclerView to disable the normal scrollbar. The layout XML is then:

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appBarLayout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:fitsSystemWindows="true"
        android:theme="@style/AppTheme.AppBarOverlay">

        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary" />

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:clipToPadding="false"
        android:orientation="vertical"
        android:scrollbars="none"
        app:fastScrollEnabled="true"
        app:fastScrollHorizontalThumbDrawable="@drawable/thumb_drawable"
        app:fastScrollHorizontalTrackDrawable="@drawable/line_drawable"
        app:fastScrollVerticalThumbDrawable="@drawable/thumb_drawable"
        app:fastScrollVerticalTrackDrawable="@drawable/line_drawable"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        app:layout_behavior="@string/appbar_scrolling_view_behavior" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

In the activity/fragment, we still have to handle bottom system inset for the RecyclerView and left and right system inset for both the RecyclerView and AppBarLayout. We use margins for theRecyclerView` to get the fast scroller into the content area.

ViewCompat.setOnApplyWindowInsetsListener(recyclerView) { _, insets ->
            val systemWindowInsets = insets.systemWindowInsets
            appBarLayout.updatePadding(left = systemWindowInsets.left, right = systemWindowInsets.right)
            recyclerView.updatePadding(bottom = systemWindowInsets.bottom)
            recyclerView.updateMargins(left = systemWindowInsets.left, right = systemWindowInsets.right)
            insets
        }

Video result here, comment #27.

answered on Stack Overflow Nov 2, 2019 by Adrijan • edited Nov 24, 2019 by android developer
1

One thing to note, you likely have set android:clipToPadding=false" on the RecyclerView although it is not visible above (otherwise the items would not be fully visible behind the navigation bar).

First part

The first part (bottom padding in portrait mode) is very easy to solve. It seems that the CoordinatorLayout somehow moves the RecyclerView down by the top inset (very likely caused by ScrollingViewBehavior, did not research much further). So the solution is to add both the bottom and the top inset to the bottom padding of the recycler:

ViewCompat.setOnApplyWindowInsetsListener(recyclerView) { _, insets -> 
    val systemWindowInsets = insets.systemWindowInsets
    recyclerView.updatePadding(
        left = systemWindowInsets.left,
        // Fix CoordinatorLayout behavior
        bottom = systemWindowInsets.bottom + systemWindowInsets.top
        right = systemWindowInsets.right
    )
    insets
}

Second part explanation

The second part is a bit more tricky. The easier part is the double scrollbars, but then comes the fast scroll behind the navigation bar. The padding is part of the view (check the layout inspector in Android Studio, you will see the RecyclerView extending all the way into the navigation bar). When the RecyclerView fast scroll implementation (which is based on ItemDecoration) displays the line and thumb, it does not account for padding.

The RecyclerView checks the app:fastScrollEnabled="true" attribute and calls initFastScroller(...) method, which creates a FastScroller object (see here). However, you cannot extend this class, as it has a @VisibleForTesting annotation (annotation info here, source code for FastScroller here).

Solution for the second part

Double scrollbars fix

The easy thing to solve is the double scrollbars. You have to disable the normal scrollbars and show only the fast scroll ones. To do that, use android:scrollbars="none" on the RecyclerView instead of android:scrollbars="vertical".

Fast scroll fix

For the fast scroller, what you can do, is copy the code from the FastScroller and change some things to account for padding. Note, that this will also change the portrait mode - the fast scrollbar will extend only to the navigation bar. The final code for the so called FastScroller2 is here on pastebin (it's a bit long, so I won't paste it here). Just create a new Java class called FastScroller2 and paste the code.

You can then use the FastScroller2 like this (called from onCreate)

private fun setupRecyclerView() {
    recyclerView.adapter = ... // Your adapter here
    val thumbDrawable = ContextCompat.getDrawable(this, R.drawable.thumb_drawable) as StateListDrawable?
    val lineDrawable = ContextCompat.getDrawable(this, R.drawable.line_drawable)
    val thickness = resources.getDimensionPixelSize(R.dimen.fastScrollThickness)
    val minRange = resources.getDimensionPixelSize(R.dimen.fastScrollMinimumRange)
    val margin = resources.getDimensionPixelSize(R.dimen.fastScrollMargin)
    if (thumbDrawable != null && lineDrawable != null) {
        // No need to do anything else, the fast scroller will take care of 
        // "connecting" itself to the recycler view
        FastScroller2(recyclerView, thumbDrawable, lineDrawable,
            thumbDrawable, lineDrawable, thickness, minRange, margin, true)
    }
}

The thickness, minRange and margin dimensions were copied from the recycler view library, see here, added to dimens.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <dimen name="fastScrollThickness">8dp</dimen>
    <dimen name="fastScrollMargin">0dp</dimen>
    <dimen name="fastScrollMinimumRange">50dp</dimen>
</resources>

This also means you don't need any fast scroller code in the layout xml:

<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/recyclerView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:clipToPadding="false"
    android:orientation="vertical"
    android:scrollbars="none"
    app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
    app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
    tools:itemCount="100"
    tools:listitem="@layout/list_item" />

I hope that this helps you.

answered on Stack Overflow Oct 31, 2019 by Adrijan

User contributions licensed under CC BY-SA 3.0