20

Background

We have quite a complex layout that has CollapsingToolbarLayout in it, together with a RecyclerView at the bottom.

In certain cases, we temporarily disable the expanding/collapsing of the CollapsingToolbarLayout, by calling setNestedScrollingEnabled(boolean) on the RecyclerView.

The problem

This usually works fine.

However, on some (bit rare) cases, slow scrolling on the RecyclerView gets semi-blocked, meaning it tries to scroll back when scrolling down. It's as if it has 2 scrolling that fight each other (scroll up and scroll down):

enter image description here

The code to trigger this is as such:

res/layout/activity_scrolling.xml

<android.support.design.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"
    android:fitsSystemWindows="true"
    tools:context="com.example.user.myapplication.ScrollingActivity">

    <android.support.design.widget.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="@dimen/app_bar_height"
        android:fitsSystemWindows="true"
        android:theme="@style/AppTheme.AppBarOverlay">

        <android.support.design.widget.CollapsingToolbarLayout
            android:id="@+id/toolbar_layout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fitsSystemWindows="true"
            app:contentScrim="?attr/colorPrimary"
            app:layout_scrollFlags="scroll|exitUntilCollapsed|snap">

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

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

    <android.support.v7.widget.RecyclerView
        android:id="@+id/nestedView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"/>

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_anchor="@id/app_bar"
        app:layout_anchorGravity="bottom|end">

        <Button
            android:id="@+id/disableNestedScrollingButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="disable"/>

        <Button
            android:id="@+id/enableNestedScrollingButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="enable"
            />
    </LinearLayout>

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

ScrollingActivity.java

public class ScrollingActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_scrolling);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        final RecyclerView nestedView = (RecyclerView) findViewById(R.id.nestedView);
        findViewById(R.id.disableNestedScrollingButton).setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(final View v) {
                nestedView.setNestedScrollingEnabled(false);
            }
        });
        findViewById(R.id.enableNestedScrollingButton).setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(final View v) {
                nestedView.setNestedScrollingEnabled(true);
            }
        });
        nestedView.setLayoutManager(new LinearLayoutManager(this));
        nestedView.setAdapter(new Adapter() {
            @Override
            public ViewHolder onCreateViewHolder(final ViewGroup parent, final int viewType) {
                return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(
                        android.R.layout.simple_list_item_1,
                        parent,
                        false)) {
                };
            }

            @Override
            public void onBindViewHolder(final ViewHolder holder, final int position) {
                ((TextView) holder.itemView.findViewById(android.R.id.text1)).setText("item " + position);
            }

            @Override
            public int getItemCount() {
                return 100;
            }
        });
    }

}

What I've tried

At first I thought it's because of something else (I thought it's a weird combination with DrawerLayout), but then I've found a minimal sample to show it, and it's just as I thought: it's all because of the setNestedScrollingEnabled.

I tried to report about this on Google's website (here), hoping it will get fixed if it's a real bug. If you wish to try it out, or watch the videos of the issue, go there, as I can't upload them all here (too large and too many files).

I've also tried to use special flags as instructed on other posts (examples: here, here, here, here and here) , but none helped. In fact each of them had an issue, whether it's staying in expanded mode, or scrolling in a different way than what I do.

The questions

  1. Is this a known issue? Why does it happen?

  2. Is there a way to overcome this?

  3. Is there perhaps an alternative to calling this function of setNestedScrollingEnabled ? One without any issues of scrolling or locking the state of the CollapsingToolbarLayout ?

Sagar V
  • 11,083
  • 7
  • 41
  • 62
android developer
  • 106,412
  • 122
  • 641
  • 1,128
  • There is a cyanogen head is there in the status bar. Is this cyanogen or stock android? – Sagar V Jun 14 '17 at 05:46
  • @SagarV It's not a custom rom. It's official, stock, Android O directly from Google. The issue occurs on other Android versions, including Android 6 and Android 7. – android developer Jun 14 '17 at 11:22
  • take the nested scroll view which wrap the recyclerview and it's below linear layout, and try this. – Moinkhan Jun 15 '17 at 04:17
  • @Moinkhan I don't understand. Have you found a solution that works ? Can you please show ? There is no additional nested scroll view. Only a single RecyclerView and that's it. – android developer Jun 15 '17 at 06:38

8 Answers8

4

Actually, you might be looking at the problem in the wrong way.

The only thing you need is to set the Toolbar flags accordingly. You don't really anything else so I would say that your layout should be simplified to:

<android.support.design.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"
    android:fitsSystemWindows="true"
    tools:context="com.example.user.myapplication.ScrollingActivity">

    <android.support.design.widget.AppBarLayout
         android:id="@+id/app_bar"
         android:layout_width="match_parent"
         android:layout_height="@dimen/app_bar_height"
         android:fitsSystemWindows="true"
         android:theme="@style/AppTheme.AppBarOverlay">

        <android.support.v7.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            app:layout_scrollFlags="scroll|enterAlways"
            app:popupTheme="@style/AppTheme.PopupOverlay"
            app:title="Title" />

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

    <android.support.v7.widget.RecyclerView
        android:id="@+id/nestedView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"            
        app:layout_behavior="@string/appbar_scrolling_view_behavior"/>

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_anchor="@id/app_bar"
        app:layout_anchorGravity="bottom|end">

        <Button
            android:id="@+id/disableNestedScrollingButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="disable"/>

        <Button
            android:id="@+id/enableNestedScrollingButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="enable"
            />
    </LinearLayout>
</android.support.design.widget.CoordinatorLayout>

Then when you wish to disable the collapsing just set your toolbar flags:

// To disable collapsing
AppBarLayout.LayoutParams params = (AppBarLayout.LayoutParams) toolbar.getLayoutParams();
params.setScrollFlags(AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP);
toolbar.setLayoutParams(params);

And to enable

// To enable collapsing
AppBarLayout.LayoutParams params = (AppBarLayout.LayoutParams) toolbar.getLayoutParams();
params.setScrollFlags(AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL|AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS);
toolbar.setLayoutParams(params);

Hold a reference to the layout params if you are changing instead of getting it all the time.

If you need to have the CollapsingToolbarLayout get from and set the LayoutParams to that View instead, update the flags the same way but now adding the appBarLayout.setExpanded(true/false)

Note: Using the setScrollFlags clears all previous flags, so be careful and set all required flags when using this method.

Joaquim Ley
  • 3,562
  • 2
  • 22
  • 41
  • Where's the CollapsingToolbarLayout in your layout file? And, where is "app_bar" to anchor to? You've changed the layout a lot. We need to have content in the CollapsingToolbarLayout that will get collapsed and will be able to lock on collapsed mode. – android developer Jun 19 '17 at 22:17
  • Well for the use-case shown in the `.gif` you do not need the Collapsing layout (and that's the only thing I change I would say). I'll check and fix if there are any styling changes. – Joaquim Ley Jun 20 '17 at 13:44
  • The gif could not be longer because it got too large, so I've shown only the issue. That's also why I've put the gif file in the "problem" section. The use case is shown on the sample project and on the video file I've posted on Google's page. I also tried to describe it on the thread here. – android developer Jun 20 '17 at 13:54
  • In short, what we need is CollapsingToolbarLayout (that has content) that collapses and expands by scrolling as usual, yet we could disable it from expanding/collapsing (yet scrolling will work fine) in any time we wish. Try the sample project. Usually it works fine. – android developer Jun 20 '17 at 13:57
  • Have you even tried to implement like this? Or do you need the collapsing toolbar? Are you expanding more than what is shown on the gif? If so you gotta be more explicit. – Joaquim Ley Jun 20 '17 at 14:16
  • I've added the explanation if you do require the CollapsingToolbarLayout. – Joaquim Ley Jun 20 '17 at 14:25
  • I was very explicit. Please re-read what i wrote, and also visit the link that includes both a video and a project.I tried your solution by ignoring the XML part and setting the flags on the CollapsingToolbarLayout instead of the Toolbar. It still doesn't work. When it's collapsed, and I set it to "disable collapsing", it directly expands. I didn't want it to expand. I wanted it to avoid collapsing/expanding when scrolling. To lock on its current state – android developer Jun 20 '17 at 17:00
  • I really don't understand how could this not work. I've made it work on my side unless there is something out of the scope that you provided that can be interfering. Also besides flags you must expand and collapse your appbarlayout – Joaquim Ley Jun 21 '17 at 12:21
  • Please share entire project then, since this code doesn't work when I add it to the sample project I've put. Also please fill any missing information on this post, in case it's more than what you wrote. – android developer Jun 21 '17 at 13:07
  • Downvoted? without explanation? really cool! I haven't done anything special on the spike (I've already deleted the project ofc). Are you expanding and collapsing with the flags? – Joaquim Ley Jun 21 '17 at 14:09
  • Downvoted all answers because all fail to work according to what I wrote and I didn't want them to get the bounty for not working. Your solution still doesn't work, even with calling mAppBarLayout.setExpanded(false,false) , because it didn't collapse. It still got expanded. I collapse it, call the function to set the flags to have only "SCROLL_FLAG_SNAP" (the part you wrote "// To disable collapsing") , and also call mAppBarLayout.setExpanded(false,false) , and it got expanded instead of staying on collapsed mode. Sadly I can't take back the bounty and set a higher bounty for true answers. – android developer Jun 21 '17 at 18:09
  • So, the only thing I can do now, is to accept an answer and upvote it. – android developer Jun 21 '17 at 18:11
  • That was quite strange/rude, I tried to help you just a reminder that you're not paying me. Anyway It was working on my end. good luck- – Joaquim Ley Jun 21 '17 at 18:17
  • No it's not. You claimed it worked but it didn't and you didn't provide needed info, and the website wrote that it will auto-grant the bounty. I handled all answers the same. All failed, so all should be downvoted and not get a bounty. I also tried to contact moderators to avoid this, but it didn't help. In the end it did auto-grant the bounty to a random answer, sadly, so it didn't help. In any case, if it does work for you, please publish a working progress so I could mark this answer as correct one. I could also change the downvote to upvote. Sadly, the bounty cannot be granted anymore. – android developer Jun 21 '17 at 18:30
  • In short, this was a desperate action to avoid auto-granting bounty to failed answers. If you had answered the question with a working solution, I wouldn't have done it. I have no reason to be unfair to you. – android developer Jun 21 '17 at 18:32
3

As @Moinkhan points out, you could try wrapping the RecyclerView and next elements in a NestedScrollView like this, this should resolve your problem of scrolling alongside with your collapsing toolbar layout:

<android.support.design.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"
    android:fitsSystemWindows="true"
    tools:context="com.example.user.myapplication.ScrollingActivity">

    <android.support.design.widget.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="@dimen/app_bar_height"
        android:fitsSystemWindows="true"
        android:theme="@style/AppTheme.AppBarOverlay">

        <android.support.design.widget.CollapsingToolbarLayout
            android:id="@+id/toolbar_layout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fitsSystemWindows="true"
            app:contentScrim="?attr/colorPrimary"
            app:layout_scrollFlags="scroll|exitUntilCollapsed|snap">

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

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

    <android.support.v4.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="fill_vertical"
        android:fillViewport="true"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

        <RelativeLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent">

            <android.support.v7.widget.RecyclerView
                android:id="@+id/nestedView"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                app:layout_behavior="@string/appbar_scrolling_view_behavior"/>

        </RelativeLayout>

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

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_anchor="@id/app_bar"
        app:layout_anchorGravity="bottom|end">

        <Button
            android:id="@+id/disableNestedScrollingButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="disable"/>

        <Button
            android:id="@+id/enableNestedScrollingButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="enable"
            />
    </LinearLayout>

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

In case the contents of the recyclerview are not displayed you can follow this thread to solve that issue How to use RecyclerView inside NestedScrollView?.

Hope it helps.

fmaccaroni
  • 3,581
  • 1
  • 16
  • 34
  • 1
    The code you've provided doesn't lock the scrolling as I've written that I want to do. Pressing the "disable" button (which triggers setNestedScrollingEnabled) doesn't do anything. The CollapsingToolbarLayout is freely getting collapsed/expanded according to scroll events. It's as if I don't call setNestedScrollingEnabled . Please try your solution on the project I've put, here: https://issuetracker.google.com/issues/62513149 – android developer Jun 19 '17 at 08:16
3

inside the recycler view, to scrolling smooth

android:nestedScrollingEnabled="false" 

to overlap the cardView in the toolbar

 app:behavior_overlapTop = "24dp" 

Try this code for CollapsingToolbar:

  <android.support.design.widget.CoordinatorLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/background"
    android:fitsSystemWindows="true">

    <android.support.design.widget.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="@dimen/app_bar_height"
        android:fitsSystemWindows="true"
        android:theme="@style/AppTheme.AppBarOverlay">

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

            <android.support.v7.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_collapseMode="pin"
                app:popupTheme="@style/AppTheme.PopupOverlay"
                app:title="Title" />

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


    <android.support.v4.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_marginLeft="10dp"
        android:layout_marginRight="10dp"
        android:background="@android:color/transparent"
        app:behavior_overlapTop="@dimen/behavior_overlap_top"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

        <LinearLayout
            android:id="@+id/linearLayout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

            <android.support.v7.widget.RecyclerView
                android:id="@+id/recycler_view
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_margin="@dimen/text_min_padding"
                android:nestedScrollingEnabled="false"
                android:scrollbarSize="2dp"
                android:scrollbarStyle="outsideInset"
                android:scrollbarThumbVertical="@color/colorAccent"
                android:scrollbars="vertical" />

        </LinearLayout>

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

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

Screenshot

RamaKrishnan
  • 159
  • 9
  • Please explain more. How to programmatically disable the expanding/collapsing? – android developer Jun 20 '17 at 07:51
  • While this code may answer the question, providing additional [context](https://meta.stackexchange.com/q/114762) regarding how and/or why it solves the problem would improve the answer's long-term value. Remember that you are answering the question for readers in the future, not just the person asking now! Please [edit](http://stackoverflow.com/posts/44646437/edit) your answer to add an explanation, and give an indication of what limitations and assumptions apply. It also doesn't hurt to mention why this answer is more appropriate than others. – Dev-iL Jun 22 '17 at 06:25
  • There is no function here to call. Only XML and attributes . Please explain what is it that you've changed, and what I should do with it. – android developer Jun 22 '17 at 08:10
3

This is an alternate approach to achieving the same goal as this answer. While that answer used Reflection, this answer does not, but the reasoning remains the same.

Why is this happening?

The problem is that RecyclerView sometimes uses a stale value for the member variable mScrollOffset. mScrollOffset is set in only two places in RecyclerView: dispatchNestedPreScroll and dispatchNestedScroll. We are only concerned with dispatchNestedPreScroll. This method is invoked by RecyclerView#onTouchEvent when it handles MotionEvent.ACTION_MOVE events.

The following is from the documentation for dispatchNestedPreScroll.

dispatchNestedPreScroll

boolean dispatchNestedPreScroll (int dx, int dy, int[] consumed, int[] offsetInWindow)

Dispatch one step of a nested scroll in progress before this view consumes any portion of it.

Nested pre-scroll events are to nested scroll events what touch intercept is to touch. dispatchNestedPreScroll offers an opportunity for the parent view in a nested scrolling operation to consume some or all of the scroll operation before the child view consumes it.

...

offsetInWindow int: Optional. If not null, on return this will contain the offset in local view coordinates of this view from before this operation to after it completes. View implementations may use this to adjust expected input coordinate tracking.

offsetInWindow is actually an int[2] with the second index representing the y shift to be applied to the RecyclerView due to nested scrolling.

RecyclerView#DispatchNestedPrescroll resolves to a method with the same name in NestedScrollingChildHelper.

When RecyclerView calls dispatchNestedPreScroll, mScrollOffset is used as the offsetInWindow argument. So any changes made to offsetInWindow directly updates mScrollOffset. dispatchNestedPreScroll updates mScrollOffset as long as nested scrolling is in effect. If nested scrolling is not in effect, then mScrollOffset is not updated and proceeds with the value that was last set by dispatchNestedPreScroll. Thus, when nested scrolling is turned off, the value of mScrollOffset becomes immediately stale but RecyclerView continues to use it.

The correct value of mScrollOffset[1] upon return from dispatchNestedPreScroll is the amount to adjust for input coordinate tracking (see above). In RecyclerView the following lines adjusts the y touch coordinate:

mLastTouchY = y - mScrollOffset[1];

If mScrollOffset[1] is, let's say, -30 (because it is stale and should be zero) then mLastTouchY will be off by +30 pixels (--30=+30). The effect of this miscalculation is that it will appear that the touch occurred further down the screen than it really did. So, a slow downward scroll will actually scrolls up and an upward scroll will scroll faster. (If a downward scroll is fast enough to overcome this 30px barrier, then downward scrolling will occur but more slowly than it should.) Upward scrolling will be overly quick since the app thinks more space has been covered.

mScrollOffset will continue as a stale variable until nested scrolling is turned on and dispatchNestedPreScroll once again reports the correct value in mScrollOffset.

Approach

Since mScrollOffset[1] has a stale value under certain circumstances, the goal is to set it to the correct value under those circumstances. This value should be zero when nested scrolling is not taking place, i.e., When the AppBar is expanded or collapsed. Unfortunately, mScrollOffset is local to RecyclerView and there is no setter for it. To gain access to mScrollOffset without resorting to Reflection, a custom RecyclerView is created that overrides dispatchNestedPreScroll. The fourth agument is offsetInWindow which is the variable we need to change.

A stale mScrollOffset occurs whenever nested scrolling is disabled for the RecyclerView. An additional condition we will impose is that the AppBar must be idle so we can safely say that mScrollOffset[1] should be zero. This is not an issue since the CollapsingToolbarLayout specifies snap in the scroll flags.

In the sample app, ScrollingActivity has been modified to record when the AppBar is expanded and closed. A callback has also been created (clampPrescrollOffsetListener) that will return true when our two conditions are met. Our overridden dispatchNestedPreScroll will invoke this callback and clamp mScrollOffset[1] to zero on a true response.

The updated source file for ScrollingActivity is presented below as is the custom RecyclerView - MyRecyclerView. The XML layout file must be changed to reflect the custom MyRecyclerView.

ScrollingActivity

public class ScrollingActivity extends AppCompatActivity
        implements MyRecyclerView.OnClampPrescrollOffsetListener {

    private CollapsingToolbarLayout mCollapsingToolbarLayout;
    private AppBarLayout mAppBarLayout;
    private MyRecyclerView mNestedView;
    // This variable will be true when the app bar is completely open or completely collapsed.
    private boolean mAppBarIdle = true;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_scrolling);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);

        mNestedView = (MyRecyclerView) findViewById(R.id.nestedView);
        mAppBarLayout = (AppBarLayout) findViewById(R.id.app_bar);
        mCollapsingToolbarLayout = (CollapsingToolbarLayout) findViewById(R.id.toolbar_layout);

        // Set the listener for the patch code.
        mNestedView.setOnClampPrescrollOffsetListener(this);

        // Listener to determine when the app bar is collapsed or fully open (idle).
        mAppBarLayout.addOnOffsetChangedListener(new AppBarLayout.OnOffsetChangedListener() {
            @Override
            public final void onOffsetChanged(AppBarLayout appBarLayout, int verticalOffset) {
                mAppBarIdle = verticalOffset == 0
                        || verticalOffset <= appBarLayout.getTotalScrollRange();
            }
        });
        findViewById(R.id.disableNestedScrollingButton).setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(final View v) {
                // If the AppBar is fully expanded or fully collapsed (idle), then disable
                // expansion and apply the patch; otherwise, set a flag to disable the expansion
                // and apply the patch when the AppBar is idle.
                setExpandEnabled(false);

            }
        });
        findViewById(R.id.enableNestedScrollingButton).setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(final View v) {
                setExpandEnabled(true);
            }
        });
        mNestedView.setLayoutManager(new LinearLayoutManager(this));
        mNestedView.setAdapter(new Adapter() {
            @Override
            public ViewHolder onCreateViewHolder(final ViewGroup parent, final int viewType) {
                return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(
                        android.R.layout.simple_list_item_1,
                        parent,
                        false)) {
                };
            }

            @Override
            public void onBindViewHolder(final ViewHolder holder, final int position) {
                ((TextView) holder.itemView.findViewById(android.R.id.text1)).setText("item " + position);
            }

            @Override
            public int getItemCount() {
                return 100;
            }
        });
    }

    private void setExpandEnabled(boolean enabled) {
        mNestedView.setNestedScrollingEnabled(enabled);
    }

    // Return "true" when the app bar is idle and nested scrolling is disabled. This is a signal
    // to the custom RecyclerView to clamp the y prescroll offset to zero.
    @Override
    public boolean clampPrescrollOffsetListener() {
        return mAppBarIdle && !mNestedView.isNestedScrollingEnabled();
    }

    private static final String TAG = "ScrollingActivity";
}

MyRecyclerView

public class MyRecyclerView extends RecyclerView {
    private OnClampPrescrollOffsetListener mPatchListener;

    public MyRecyclerView(Context context) {
        super(context);
    }

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

    public MyRecyclerView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    // Just a call to super plus code to force offsetInWindow[1] to zero if the patchlistener
    // instructs it.
    @Override
    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
        boolean returnValue;
        int currentOffset;
        returnValue = super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow);
        currentOffset = offsetInWindow[1];
        Log.d(TAG, "<<<<dispatchNestedPreScroll: " + currentOffset);
        if (mPatchListener.clampPrescrollOffsetListener() && offsetInWindow[1] != 0) {
            Log.d(TAG, "<<<<dispatchNestedPreScroll: " + currentOffset + " -> 0");
            offsetInWindow[1] = 0;
        }
        return returnValue;
    }

    public void setOnClampPrescrollOffsetListener(OnClampPrescrollOffsetListener patchListener) {
        mPatchListener = patchListener;
    }

    public interface OnClampPrescrollOffsetListener {
        boolean clampPrescrollOffsetListener();
    }

    private static final String TAG = "MyRecyclerView";
}
Cheticamp
  • 50,205
  • 8
  • 64
  • 109
  • Seems to work well, but why use Gist (write here instead...), and what's this comment about: "If the AppBar is fully expanded or fully collapsed (idle), then disable expansion and apply the patch; otherwise, set a flag to disable the expansion and apply the patch when the AppBar is idle." ? It doesn't have code related to it. Just the same code as the original code... – android developer Jul 17 '17 at 08:10
  • Also, what is exactly the y-offset? I'm trying to find a better naming for patchListener function and class names, as it's not clear to me how it works – android developer Jul 17 '17 at 08:27
  • OK I've tested this further, and it works very well. Now I'd like to just understand how it works and if you can publish the code here and I will try to have a better name for the patchListener . For now, I will mark this answer as the correct one. – android developer Jul 17 '17 at 10:48
  • @androiddeveloper Good points. Later today I'll go back and review what I have presented and address them. – Cheticamp Jul 17 '17 at 10:54
  • Thank you . You've earned it. – android developer Jul 18 '17 at 00:07
3

I had to solve a similar issue and did it using a custom behaviour on the AppBarLayout. Everything works great. By overriding onStartNestedScroll in the custom behaviour it is possible to block to collapsing toolbar layout from expanding or collapsing while keeping the scroll view (NestedScrollView) in my case, working as expected. I explained the details here, hope it helps.

private class AppBarLayoutBehavior : AppBarLayout.Behavior() {
    var canDrag = true
    var acceptsNestedScroll = true

    init {
        setDragCallback(object : AppBarLayout.Behavior.DragCallback() {
            override fun canDrag(appBarLayout: AppBarLayout): Boolean {
                // Allow/Do not allow dragging down/up to expand/collapse the layout
                return canDrag
            }
        })
    }

    override fun onStartNestedScroll(parent: CoordinatorLayout,
                                     child: AppBarLayout,
                                     directTargetChild: View,
                                     target: View,
                                     nestedScrollAxes: Int,
                                     type: Int): Boolean {
        // Refuse/Accept any nested scroll event
        return acceptsNestedScroll
    }}
Francesco Rigoni
  • 823
  • 6
  • 19
2

Use following code, it works fine for me:

lockAppBarClosed();
ViewCompat.setNestedScrollingEnabled(recyclerView, false);   // to lock the CollapsingToolbarLayout

and implement the following methods:

private void setAppBarDragging(final boolean isEnabled) {
        CoordinatorLayout.LayoutParams params =
                (CoordinatorLayout.LayoutParams) appBarLayout.getLayoutParams();
        AppBarLayout.Behavior behavior = new AppBarLayout.Behavior();
        behavior.setDragCallback(new AppBarLayout.Behavior.DragCallback() {
            @Override
            public boolean canDrag(AppBarLayout appBarLayout) {
                return isEnabled;
            }
        });
        params.setBehavior(behavior);
    }

    public void unlockAppBarOpen() {
        appBarLayout.setExpanded(true, false);
        appBarLayout.setActivated(true);
        setAppBarDragging(false);
    }

    public void lockAppBarClosed() {
        appBarLayout.setExpanded(false, false);
        appBarLayout.setActivated(false);
        setAppBarDragging(false);

    }
Usman Rana
  • 1,429
  • 1
  • 13
  • 30
  • I don't understand what I'm supposed to use here. setAppBarDragging ? or unlockAppBarOpen, lockAppBarClosed ? Can you please check it on the project I've put ? – android developer Jun 20 '17 at 11:10
  • put the top mentioned method in oncreate method after intitializing your views,and implement AppbarRequestListener in your activity/fragment and place the given methods in the same activity. – Usman Rana Jun 20 '17 at 14:05
  • Still don't understand. Why the need for the "AppbarRequestListener" interface, if nothing uses it this way? Also, what should I call ? Should I call lockAppBarClosed and then ViewCompat.setNestedScrollingEnabled(recyclerView, false) ? And to re-enable: unlockAppBarOpen and then ViewCompat.setNestedScrollingEnabled(recyclerView, true) ? If so, this also doesn't work. It just expands the CollapsingToolbarLayout (even though I need it stay collapsed, when it got to be collapsed), and it still doesn't disabled collapsing/expanding. – android developer Jun 20 '17 at 18:28
1

I believe that this problem is related to the collapsing toolbar snapping into place (either closed or open) and leaving a vertical offset variable (mScrollOffset[1] in RecyclerView) with a non-zero value that subsequently biases the scroll - slowing or reversing the scroll in one direction and speeding it up in the other. This variable only seems to be set in NestedScrollingChildHelper if nested scrolling is enabled. So, whatever value mScrollOffset[1] has goes unchanged once nest scrolling is disabled.

To reliably reproduce this issue, you can cause the toolbar to snap into place then immediately click disable. See this video for a demonstration. I believe, that the magnitude of the issue varies by how much "snapping" occurs.

If I drag the toolbar to the fully open or closed position and don't let it "snap", then I have not been able to reproduce this problem and mScrollOffset[1] is set to zero which I think is the right value. I have also reproduced the problem by removing snap from the layout_scrollFlags of the collapsing toolbar in the layout and placing the toolbar in a partially open state.

If you want to play around with this, you can put your demo app into debug mode and observe the value of mScrollOffset[1] in RecyclerView#onTouchEvent. Also take a look at NestedScrollingChildHelper's dispatchNestedScroll and dispatchNestedPreScroll methods to see how the offset is set only when nested scrolling is enabled.

So, how to fix this? mScrollOffset is private toRecyclerView and it is not immediately obvious how to subclass anything to change the value of mScrollOffset[1]. That would leave Reflection, but that may not be desirable to you. Maybe another reader has an idea about how to approach this or knows of some secret sauce. I will repost if anything occurs to me.

Edit: I have provided a new ScrollingActivity.java class that overcomes this issue. It does use reflection and applies a patch to set mScrollOffset[1] of RecyclerView to zero when the disable scroll button has been pressed and the AppBar is idle. I have done some preliminary testing and it is working. Here is the gist. (See updated gist below.)

Second edit: I was able to get the toolbar to snap in funny ways and get stuck in the middle without the patch, so it doesn't look like the patch is causing that particular issue. I can get the toolbar to bounce from fully open to collapsed by scrolling down fast enough in the unpatched app.

I also took another look at what the patch is doing and I think that it will behave itself: The variable is private and referred to only in one place after scrolling is turned off. With scrolling enabled, the variable is always reset before use. The real answer is for Google to fix this problem. Until they do, I think this may be the closest you can get to an acceptable work-around with this particular design. (I have posted an updated gist that addresses potential issues with a quick click-around leaving switches in a potential unsuitable state.)

Regardless, the underlying issue has been identified and you have a reliable way to reproduce the problem, so you can more easily verify other proposed solutions.

I hope this helps.

Cheticamp
  • 50,205
  • 8
  • 64
  • 109
  • I mainly asked how to have this behavior, and less on why this occurs. You can use anything else instead of what I did. You can avoid using setNestedScrollingEnabled altogether. – android developer Jul 15 '17 at 20:35
  • 1
    @androiddeveloper Do you mean how to have this behavior or how to avoid it? The behavior I am referring to is the backward/slow scrolling I will shortly post something on how to avoid it (if that is what you are looking for) and it uses your sample code. – Cheticamp Jul 15 '17 at 20:40
  • This... almost works well. It sometimes doesn't snap, so the toolbar can be in the middle of scrolling without me touching anything. I think it's sometimes jumpy in snapping, too, meaning it tries to snap to top, and then to bottom (and vice versa). Also, sadly, as you've mentioned, this is a reflection solution, so it might have consequences... – android developer Jul 15 '17 at 23:28
  • You are correct about the bad snapping. I've decided to report about this too here : https://issuetracker.google.com/issues/63745189 . I see you've posted a new answer, without reflection. Both answers seem to work well. I will try it out further soon. For now, I will upvote the new answer. – android developer Jul 17 '17 at 08:17
0

I want to present a nice alternative, mainly based on the one here :

AppBarLayoutEx.kt

class AppBarLayoutEx : AppBarLayout {
    private var isAppBarExpanded = true
    private val behavior = AppBarLayoutBehavior()
    private var onStateChangedListener: (Boolean) -> Unit = {}
    var enableExpandAndCollapseByDraggingToolbar: Boolean
        get() = behavior.canDrag
        set(value) {
            behavior.canDrag = value
        }

    var enableExpandAndCollapseByDraggingContent: Boolean
        get() = behavior.acceptsNestedScroll
        set(value) {
            behavior.acceptsNestedScroll = value
        }

    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)

    init {
        addOnOffsetChangedListener(
                AppBarLayout.OnOffsetChangedListener { _, verticalOffset ->
                    isAppBarExpanded = verticalOffset == 0
                    onStateChangedListener(isAppBarExpanded)
                })
    }

    override fun setLayoutParams(params: ViewGroup.LayoutParams?) {
        super.setLayoutParams(params)
        (params as CoordinatorLayout.LayoutParams).behavior = behavior
    }

    fun toggleExpandedState() {
        setExpanded(!isAppBarExpanded, true)
    }

    fun setOnExpandAndCollapseListener(onStateChangedListener: (Boolean) -> Unit) {
        this.onStateChangedListener = onStateChangedListener
    }

    private class AppBarLayoutBehavior : AppBarLayout.Behavior() {
        var canDrag = true
        var acceptsNestedScroll = true

        init {
            setDragCallback(object : AppBarLayout.Behavior.DragCallback() {
                override fun canDrag(appBarLayout: AppBarLayout) = canDrag
            })
        }

        override fun onStartNestedScroll(parent: CoordinatorLayout, child: AppBarLayout, directTargetChild: View,
                                         target: View, nestedScrollAxes: Int, type: Int) = acceptsNestedScroll
    }
}

Usage: besides using it in the layout XML file, you can disable/enable the expanding of it using:

appBarLayout.enableExpandAndCollapseByDraggingToolbar = true/false

appBarLayout.enableExpandAndCollapseByDraggingContent = true/false
android developer
  • 106,412
  • 122
  • 641
  • 1,128