Toolbar in AppBarLayout is scrollable although RecyclerView has not enough content to scroll

23,491

Solution 1

So, proper credit, this answer almost solved it for me https://stackoverflow.com/a/32923226/5050087. But since it was not showing the toolbar when you actually had an scrollable recyclerview and its last item was visible (it would not show the toolbar on the first scroll up), I decided to modify it and adapt it for an easier implementation and for dynamic adapters.

First, you must create a custom layout behavior for you appbar:

public class ToolbarBehavior extends AppBarLayout.Behavior{

private boolean scrollableRecyclerView = false;
private int count;

public ToolbarBehavior() {
}

public ToolbarBehavior(Context context, AttributeSet attrs) {
    super(context, attrs);
}

@Override
public boolean onInterceptTouchEvent(CoordinatorLayout parent, AppBarLayout child, MotionEvent ev) {
    return scrollableRecyclerView && super.onInterceptTouchEvent(parent, child, ev);
}

@Override
public boolean onStartNestedScroll(CoordinatorLayout parent, AppBarLayout child, View directTargetChild, View target, int nestedScrollAxes, int type) {
    updatedScrollable(directTargetChild);
    return scrollableRecyclerView && super.onStartNestedScroll(parent, child, directTargetChild, target, nestedScrollAxes, type);
}

@Override
public boolean onNestedFling(CoordinatorLayout coordinatorLayout, AppBarLayout child, View target, float velocityX, float velocityY, boolean consumed) {
    return scrollableRecyclerView && super.onNestedFling(coordinatorLayout, child, target, velocityX, velocityY, consumed);
}

private void updatedScrollable(View directTargetChild) {
    if (directTargetChild instanceof RecyclerView) {
        RecyclerView recyclerView = (RecyclerView) directTargetChild;
        RecyclerView.Adapter adapter = recyclerView.getAdapter();
        if (adapter != null) {
            if (adapter.getItemCount()!= count) {
                scrollableRecyclerView = false;
                count = adapter.getItemCount();
                RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
                if (layoutManager != null) {
                    int lastVisibleItem = 0;
                    if (layoutManager instanceof LinearLayoutManager) {
                        LinearLayoutManager linearLayoutManager = (LinearLayoutManager) layoutManager;
                        lastVisibleItem = Math.abs(linearLayoutManager.findLastCompletelyVisibleItemPosition());
                    } else if (layoutManager instanceof StaggeredGridLayoutManager) {
                        StaggeredGridLayoutManager staggeredGridLayoutManager = (StaggeredGridLayoutManager) layoutManager;
                        int[] lastItems = staggeredGridLayoutManager.findLastCompletelyVisibleItemPositions(new int[staggeredGridLayoutManager.getSpanCount()]);
                        lastVisibleItem = Math.abs(lastItems[lastItems.length - 1]);
                    }
                    scrollableRecyclerView = lastVisibleItem < count - 1;
                }
            }
        }
    } else scrollableRecyclerView = true;
  }
}

Then, you only need to define this behavior for you appbar in your layout file:

<android.support.design.widget.AppBarLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:fitsSystemWindows="true"
    app:layout_behavior="com.yourappname.whateverdir.ToolbarBehavior"
    >

I haven't tested it for screen rotation so let me know if it works like this. I guess it should work as I don't think the count variable is saved when the rotation happens, but let me know if it doesn't.

This was the easiest and cleanest implementation for me, enjoy it.

Solution 2

Edit 2:

Turns out the only way to ensure Toolbar is not scrollable when RecyclerView is not scrollable is to set setScrollFlags programmatically which requires to check if RecyclerView's is scrollable. This check has to be done every time adapter is modified.

Interface to communicate with the Activity:

public interface LayoutController {
    void enableScroll();
    void disableScroll();
}

MainActivity:

public class MainActivity extends AppCompatActivity implements 
    LayoutController {

    private CollapsingToolbarLayout collapsingToolbarLayout;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);

        collapsingToolbarLayout = 
              (CollapsingToolbarLayout) findViewById(R.id.collapsing_toolbar);

        final FragmentManager manager = getSupportFragmentManager();
        final Fragment fragment = new CheeseListFragment();
        manager.beginTransaction()
                .replace(R.id.root_content, fragment)
                .commit();
    }

    @Override
    public void enableScroll() {
        final AppBarLayout.LayoutParams params = (AppBarLayout.LayoutParams)
                                  collapsingToolbarLayout.getLayoutParams();
        params.setScrollFlags(
                AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL 
                | AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS
        );
        collapsingToolbarLayout.setLayoutParams(params);
    }

    @Override
    public void disableScroll() {
        final AppBarLayout.LayoutParams params = (AppBarLayout.LayoutParams)
                                  collapsingToolbarLayout.getLayoutParams();
        params.setScrollFlags(0);
        collapsingToolbarLayout.setLayoutParams(params);
    }
}

activity_main.xml:

<android.support.v4.widget.DrawerLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/drawer_layout"
    android:layout_height="match_parent"
    android:layout_width="match_parent"
    android:fitsSystemWindows="true">

    <android.support.design.widget.CoordinatorLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:id="@+id/main_content"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <android.support.design.widget.AppBarLayout
            android:id="@+id/appbar"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">

            <android.support.design.widget.CollapsingToolbarLayout
                android:id="@+id/collapsing_toolbar"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:fitsSystemWindows="true"
                app:contentScrim="?attr/colorPrimary">

                <android.support.v7.widget.Toolbar
                    android:id="@+id/toolbar"
                    android:layout_width="match_parent"
                    android:layout_height="?attr/actionBarSize"
                    android:background="?attr/colorPrimary"
                    app:popupTheme="@style/ThemeOverlay.AppCompat.Light"/>

            </android.support.design.widget.CollapsingToolbarLayout>

        </android.support.design.widget.AppBarLayout>

        <FrameLayout
            android:id="@+id/root_content"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_gravity="fill_vertical"
            app:layout_behavior="@string/appbar_scrolling_view_behavior"/>

    </android.support.design.widget.CoordinatorLayout>

</android.support.v4.widget.DrawerLayout>

Test Fragment:

public class CheeseListFragment extends Fragment {

    private static final int DOWN = 1;
    private static final int UP = 0;

    private LayoutController controller;
    private RecyclerView rv;

    @Override
    public void onAttach(Context context) {
        super.onAttach(context);

        try {
            controller = (MainActivity) getActivity();
        } catch (ClassCastException e) {
            throw new RuntimeException(getActivity().getLocalClassName()
                    + "must implement controller.", e);
        }
    }

    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        rv = (RecyclerView) inflater.inflate(
                R.layout.fragment_cheese_list, container, false);
        setupRecyclerView(rv);

        // Find out if RecyclerView are scrollable, delay required
        final Handler handler = new Handler();
        handler.postDelayed(new Runnable() {
            @Override
            public void run() {
                if (rv.canScrollVertically(DOWN) || rv.canScrollVertically(UP)) {
                    controller.enableScroll();
                } else {
                    controller.disableScroll();
                }
            }
        }, 100);

        return rv;
    }

    private void setupRecyclerView(RecyclerView recyclerView) {
        final LinearLayoutManager layoutManager = new LinearLayoutManager(recyclerView.getContext());

        recyclerView.setLayoutManager(layoutManager);

        final SimpleStringRecyclerViewAdapter adapter =
                new SimpleStringRecyclerViewAdapter(
                        getActivity(),
                        // Test ToolBar scroll
                        getRandomList(/* with enough items to scroll */)
                        // Test ToolBar pin
                        getRandomList(/* with only 3 items*/)
                );

        recyclerView.setAdapter(adapter);
    }
}

Sources:

Edit:

You should CollapsingToolbarLayout to control the behaviour.

Adding a Toolbar directly to an AppBarLayout gives you access to the enterAlwaysCollapsed and exitUntilCollapsed scroll flags, but not the detailed control on how different elements react to collapsing. [...] setup uses CollapsingToolbarLayout’s app:layout_collapseMode="pin" to ensure that the Toolbar itself remains pinned to the top of the screen while the view collapses.http://android-developers.blogspot.com.tr/2015/05/android-design-support-library.html

<android.support.design.widget.CollapsingToolbarLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_scrollFlags="scroll|exitUntilCollapsed">

    <android.support.v7.widget.Toolbar
        android:id="@+id/drawer_toolbar"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        app:layout_collapseMode="pin"/>

</android.support.design.widget.CollapsingToolbarLayout>

Add

app:layout_collapseMode="pin"

to your Toolbar in xml.

    <android.support.v7.widget.Toolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        android:background="?attr/colorPrimary"
        app:layout_scrollFlags="scroll|enterAlways"
        app:layout_collapseMode="pin"
        app:theme="@style/ToolbarStyle" />

Solution 3

Something like this in a LayoutManager subclass seems to result in the desired behavior:

@Override
public boolean canScrollVertically() {
    int firstCompletelyVisibleItemPosition = findFirstCompletelyVisibleItemPosition();
    if (firstCompletelyVisibleItemPosition == RecyclerView.NO_POSITION) return false;

    int lastCompletelyVisibleItemPosition = findLastCompletelyVisibleItemPosition();
    if (lastCompletelyVisibleItemPosition == RecyclerView.NO_POSITION) return false;

    if (firstCompletelyVisibleItemPosition == 0 &&
            lastCompletelyVisibleItemPosition == getItemCount() - 1)
        return false;

    return super.canScrollVertically();
}

The documentation for canScrollVertically() says:

/**
 * Query if vertical scrolling is currently supported. The default implementation
 * returns false.
 *
 * @return True if this LayoutManager can scroll the current contents vertically
 */

Notice the wording of "can scroll the current contents vertically", which I believe implies that the current state should be reflected by the return value.

However, that is not done by any of the LayoutManager subclasses provided through the v7 recyclerview library (23.1.1), which makes me somewhat hesitant whether it is a correct solution; it might cause undesired effects in other situations than the one discussed in this question.

Solution 4

It is not a bug, all the events in a viewGroup are handled this way. Because your recyclerview is a child of coordinatorLayout so whenever the event is generated, it is first checked for parent and if parent is not interested only then it is passed down to child. See google documentation

Solution 5

I've implemented it using my own Behavior class which might be attached to AppBarLayout:

public class CustomAppBarLayoutBehavior extends AppBarLayout.Behavior {

private RecyclerView recyclerView;
private int additionalHeight;

public CustomAppBarLayoutBehavior(RecyclerView recyclerView, int additionalHeight) {
    this.recyclerView = recyclerView;
    this.additionalHeight = additionalHeight;
}

public boolean isRecyclerViewScrollable(RecyclerView recyclerView) {
    return recyclerView.computeHorizontalScrollRange() > recyclerView.getWidth() || recyclerView.computeVerticalScrollRange() > (recyclerView.getHeight() - additionalHeight);
}

@Override
public boolean onStartNestedScroll(CoordinatorLayout parent, AppBarLayout child, View directTargetChild, View target, int nestedScrollAxes) {
    if (isRecyclerViewScrollable(mRecyclerView)) {
        return super.onStartNestedScroll(parent, child, directTargetChild, target, nestedScrollAxes);
    }
    return false;
}

}

And below is the code how to set this behavior:

final View appBarLayout = ((DrawerActivity) getActivity()).getAppBarLayoutView();
CoordinatorLayout.LayoutParams layoutParams = (CoordinatorLayout.LayoutParams) appBarLayout.getLayoutParams();
layoutParams.setBehavior(new AppBarLayoutNoEmptyScrollBehavior(recyclerView, getResources().getDimensionPixelSize(R.dimen.control_bar_height)));
Share:
23,491
eickeee
Author by

eickeee

Updated on October 07, 2020

Comments

  • eickeee
    eickeee over 3 years

    Is it really intended that the Toolbar in a AppBarLayout is scrollable although the main container with the "appbar_scrolling_view_behavior" has not enough content to really scroll?

    What I have tested so far:
    When I use a NestedScrollView (with "wrap_content" attribute) as main container and a TextView as child, the AppBarLayout works properly and does not scroll.

    However, when I use a RecyclerView with only a few entries and the "wrap_content" attribute (so that there is no need to scroll), the Toolbar in the AppBarLayout is scrollable even though the RecyclerView never receives a scroll event (tested with a OnScrollChangeListener).

    Here's my layout code:

    <android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:id="@+id/coordinatorLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
    
        <android.support.design.widget.AppBarLayout
            android:id="@+id/appBarLayout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">
    
            <android.support.v7.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                android:background="?attr/colorPrimary"
                app:layout_scrollFlags="scroll|enterAlways"
                app:theme="@style/ToolbarStyle" />
        </android.support.design.widget.AppBarLayout>
    
        <android.support.v7.widget.RecyclerView
            android:id="@+id/recycler"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_behavior="@string/appbar_scrolling_view_behavior" />
    </android.support.design.widget.CoordinatorLayout>
    

    With the following effect that the toolbar is scrollable although it's not necessary:

    I've also found a way to deal with this by checking if all RecyclerView items are visible and using the setNestedScrollingEnabled() method of the RecyclerView.
    Nevertheless, it does seem more like a bug as intended to me. Any opinions? :D

    EDIT #1:

    For people who are might be interested in my current solution, I had to put the setNestedScrollingEnabled() logic in the postDelayed() method of a Handler with 5 ms delay due to the LayoutManager which always returned -1 when calling the methods to find out whether the first and the last item is visible.
    I use this code in the onStart() method (after my RecyclerView has been initialized) and every time after a content change of the RecyclerView occurs.

    final LinearLayoutManager layoutManager = (LinearLayoutManager) mRecyclerView.getLayoutManager();
    new Handler().postDelayed(new Runnable() {
        @Override
        public void run() {
            //no items in the RecyclerView
            if (mRecyclerView.getAdapter().getItemCount() == 0)
                mRecyclerView.setNestedScrollingEnabled(false);
            //if the first and the last item is visible
            else if (layoutManager.findFirstCompletelyVisibleItemPosition() == 0
                    && layoutManager.findLastCompletelyVisibleItemPosition() == mRecyclerView.getAdapter().getItemCount() - 1)
                mRecyclerView.setNestedScrollingEnabled(false);
            else
                mRecyclerView.setNestedScrollingEnabled(true);
        }
    }, 5);
    

    EDIT #2:

    I just played around with a new app and it seems that this (unintended) behavior has been fixed in support library version 23.3.0 (or even earlier). Thus, there is no need for workarounds anymore!

  • eickeee
    eickeee over 8 years
    I want that the Toolbar scrolls and goes off screen if the RecyclerView has enough content but not if there is not enough content. If I remove the scroll flag, the Toolbar does not scroll at all.
  • Mohammad Hossein Gerami
    Mohammad Hossein Gerami over 8 years
    do you launch the sample?
  • eickeee
    eickeee over 8 years
    I tested it and if you decrease the items in the RecyclerView, you'll see that this example has the same problem. So the question whether it's a bug or intended remains.
  • Kenneth
    Kenneth over 8 years
    See my answer below about intended behavior. I also have the same problem with the suggested project: dropbox.com/s/16fep4r7linjtnp/sameproblem.mov?dl=0
  • joelpet
    joelpet over 8 years
    One issue here is that if items – that previously filled the screen – are removed after the app bar has been scrolled away, then the app bar will get stuck off the screen since AppBarLayout.Behavior will return false in onStartNestedScroll and thus not receive any calls to the onNested[Pre]Scrolll methods that handle app bar scrolling. The reason is that RecyclerView adds the SCROLL_AXIS_VERTICAL flag only if its LayoutManager says it canScrollVertically.
  • eickeee
    eickeee over 8 years
    It doesn't work for me. Have you tested it? I suppose no.
  • user3623735
    user3623735 over 8 years
    Yes, I use it on my own app. But I just noticed the difference I think. See my edit. you should wrap Toolbar with CollapsingToolbarLayout and set the scrollFlags on that view, and make sure Toolbar is set to pin.
  • user3623735
    user3623735 over 8 years
    Hey, firstly, sorry for completely misunderstanding what you're trying to do and wasting your time by giving a wrong answer. Secondly, pls check my edit2, it does exactly what you're trying to do. Let me know if you have any questions.
  • OneEyeQuestion
    OneEyeQuestion about 8 years
    I think given the layout in the question you should call the method as updatedScrollable(target) since the RecyclerView isn't a direct child of the AppBarLayout.
  • emirua
    emirua about 8 years
    It is actually a reference to the CoordinatorLayout which is the parent ;).
  • droid256
    droid256 over 5 years
    The overall idea makes sense, but there's an issue. If the recyclerview would not scroll when taking the entire CoordinatorLayout vertical space but would when taking all space that's left by the AppBarLayout + CollapsingToolbarLayout, this breaks: the RecyclerView would scroll fine within the space left, but the AppBarLayout won't collapse. To fix this, we should somehow check "would scroll vertically within the vertical space that's left when CollapsingToolbarLayout is fully expanded".