30

Background

Suppose you have an app you've created that has a similar UI as the one you can create via the wizard of "scrolling activity", yet you wish the scrolling flags to have snapping, as such:

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

The problem

As it turns out, on many cases it has issues of snapping. Sometimes the UI doesn't snap to top/bottom, making the CollapsingToolbarLayout stay in between.

Sometimes it also tries to snap to one direction, and then decides to snap to the other .

You can see both issues on the attached video here.

What I've tried

I thought it's one of the issues that I got for when I use setNestedScrollingEnabled(false) on a RecyclerView within, so I asked about it here, but then I noticed that even with the solution and without using this command at all and even when using a simple NestedScrollView (as is created by the wizard), I can still notice this behavior.

That's why I decided to report about this as an issue, here.

Sadly, I couldn't find any workaround for those weird bugs here on StackOverflow.

The question

Why does it occur, and more importantly: how can I avoid those issues while still using the behavior it's supposed to have?


EDIT: here's a nice improved Kotlin version of the accepted answer:

class RecyclerViewEx @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) : RecyclerView(context, attrs, defStyle) {
    private var mAppBarTracking: AppBarTracking? = null
    private var mView: View? = null
    private var mTopPos: Int = 0
    private var mLayoutManager: LinearLayoutManager? = null

    interface AppBarTracking {
        fun isAppBarIdle(): Boolean
        fun isAppBarExpanded(): Boolean
    }

    override fun dispatchNestedPreScroll(dx: Int, dy: Int, consumed: IntArray?, offsetInWindow: IntArray?, type: Int): Boolean {
        if (mAppBarTracking == null)
            return super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type)
        if (type == ViewCompat.TYPE_NON_TOUCH && mAppBarTracking!!.isAppBarIdle()
                && isNestedScrollingEnabled) {
            if (dy > 0) {
                if (mAppBarTracking!!.isAppBarExpanded()) {
                    consumed!![1] = dy
                    return true
                }
            } else {
                mTopPos = mLayoutManager!!.findFirstVisibleItemPosition()
                if (mTopPos == 0) {
                    mView = mLayoutManager!!.findViewByPosition(mTopPos)
                    if (-mView!!.top + dy <= 0) {
                        consumed!![1] = dy - mView!!.top
                        return true
                    }
                }
            }
        }
        if (dy < 0 && type == ViewCompat.TYPE_TOUCH && mAppBarTracking!!.isAppBarExpanded()) {
            consumed!![1] = dy
            return true
        }

        val returnValue = super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type)
        if (offsetInWindow != null && !isNestedScrollingEnabled && offsetInWindow[1] != 0)
            offsetInWindow[1] = 0
        return returnValue
    }

    override fun setLayoutManager(layout: RecyclerView.LayoutManager) {
        super.setLayoutManager(layout)
        mLayoutManager = layoutManager as LinearLayoutManager
    }

    fun setAppBarTracking(appBarTracking: AppBarTracking) {
        mAppBarTracking = appBarTracking
    }

    fun setAppBarTracking(appBarLayout: AppBarLayout) {
        val appBarIdle = AtomicBoolean(true)
        val appBarExpanded = AtomicBoolean()
        appBarLayout.addOnOffsetChangedListener(object : AppBarLayout.OnOffsetChangedListener {
            private var mAppBarOffset = Integer.MIN_VALUE

            override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
                if (mAppBarOffset == verticalOffset)
                    return
                mAppBarOffset = verticalOffset
                appBarExpanded.set(verticalOffset == 0)
                appBarIdle.set(mAppBarOffset >= 0 || mAppBarOffset <= -appBarLayout.totalScrollRange)
            }
        })
        setAppBarTracking(object : AppBarTracking {
            override fun isAppBarIdle(): Boolean = appBarIdle.get()
            override fun isAppBarExpanded(): Boolean = appBarExpanded.get()
        })
    }

    override fun fling(velocityX: Int, inputVelocityY: Int): Boolean {
        var velocityY = inputVelocityY
        if (mAppBarTracking != null && !mAppBarTracking!!.isAppBarIdle()) {
            val vc = ViewConfiguration.get(context)
            velocityY = if (velocityY < 0) -vc.scaledMinimumFlingVelocity
            else vc.scaledMinimumFlingVelocity
        }

        return super.fling(velocityX, velocityY)
    }
}
android developer
  • 114,585
  • 152
  • 739
  • 1,270

5 Answers5

10

Update I have changed the code slightly to address remaining issues - at least the ones that I can reproduce. The key update was to dispose of dy only when the AppBar is expanded or collapsed. In the first iteration, dispatchNestedPreScroll() was disposing of scroll without checking the status of the AppBar for a collapsed state.

Other changes are minor and fall under the category of clean up. The code blocks are updated below.


This answer addresses the question's issue regarding RecyclerView. The other answer I have given still stands and applies here. RecyclerView has the same issues as NestedScrollView that were introduced in 26.0.0-beta2 of the support libraries.

The code below is base upon this answer to a related question but includes the fix for the erratic behavior of the AppBar. I have removed the code that fixed the odd scrolling because it no longer seems to be needed.

AppBarTracking.java

public interface AppBarTracking {
    boolean isAppBarIdle();
    boolean isAppBarExpanded();
}

MyRecyclerView.java

public class MyRecyclerView extends RecyclerView {

    public MyRecyclerView(Context context) {
        this(context, null);
    }

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

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

    private AppBarTracking mAppBarTracking;
    private View mView;
    private int mTopPos;
    private LinearLayoutManager mLayoutManager;

    @Override
    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow,
                                           int type) {

        // App bar latching trouble is only with this type of movement when app bar is expanded
        // or collapsed. In touch mode, everything is OK regardless of the open/closed status
        // of the app bar.
        if (type == ViewCompat.TYPE_NON_TOUCH && mAppBarTracking.isAppBarIdle()
                && isNestedScrollingEnabled()) {
            // Make sure the AppBar stays expanded when it should.
            if (dy > 0) { // swiped up
                if (mAppBarTracking.isAppBarExpanded()) {
                    // Appbar can only leave its expanded state under the power of touch...
                    consumed[1] = dy;
                    return true;
                }
            } else { // swiped down (or no change)
                // Make sure the AppBar stays collapsed when it should.
                // Only dy < 0 will open the AppBar. Stop it from opening by consuming dy if needed.
                mTopPos = mLayoutManager.findFirstVisibleItemPosition();
                if (mTopPos == 0) {
                    mView = mLayoutManager.findViewByPosition(mTopPos);
                    if (-mView.getTop() + dy <= 0) {
                        // Scroll until scroll position = 0 and AppBar is still collapsed.
                        consumed[1] = dy - mView.getTop();
                        return true;
                    }
                }
            }
        }

        boolean returnValue = super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
        // Fix the scrolling problems when scrolling is disabled. This issue existed prior
        // to 26.0.0-beta2.
        if (offsetInWindow != null && !isNestedScrollingEnabled() && offsetInWindow[1] != 0) {
            offsetInWindow[1] = 0;
        }
        return returnValue;
    }

    @Override
    public void setLayoutManager(RecyclerView.LayoutManager layout) {
        super.setLayoutManager(layout);
        mLayoutManager = (LinearLayoutManager) getLayoutManager();
    }

    public void setAppBarTracking(AppBarTracking appBarTracking) {
        mAppBarTracking = appBarTracking;
    }

    @SuppressWarnings("unused")
    private static final String TAG = "MyRecyclerView";
}

ScrollingActivity.java

public class ScrollingActivity extends AppCompatActivity
        implements AppBarTracking {

    private MyRecyclerView mNestedView;
    private int mAppBarOffset;
    private boolean mAppBarIdle = false;
    private int mAppBarMaxOffset;

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

        mNestedView = findViewById(R.id.nestedView);

        final AppBarLayout appBar = findViewById(R.id.app_bar);

        appBar.addOnOffsetChangedListener(new AppBarLayout.OnOffsetChangedListener() {
            @Override
            public final void onOffsetChanged(AppBarLayout appBarLayout, int verticalOffset) {
                mAppBarOffset = verticalOffset;
                // mAppBarOffset = 0 if app bar is expanded; If app bar is collapsed then
                // mAppBarOffset = mAppBarMaxOffset
                // mAppBarMaxOffset is always <=0 (-AppBarLayout.getTotalScrollRange())
                // mAppBarOffset should never be > zero or less than mAppBarMaxOffset
                mAppBarIdle = (mAppBarOffset >= 0) || (mAppBarOffset <= mAppBarMaxOffset);
            }
        });

        appBar.post(new Runnable() {
            @Override
            public void run() {
                mAppBarMaxOffset = -appBar.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.setAppBarTracking(this);
        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)) {
                };
            }

            @SuppressLint("SetTextI18n")
            @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);
    }

    @Override
    public boolean isAppBarExpanded() {
        return mAppBarOffset == 0;
    }

    @Override
    public boolean isAppBarIdle() {
        return mAppBarIdle;
    }

    @SuppressWarnings("unused")
    private static final String TAG = "ScrollingActivity";
}

What is happening here?

From the question, it was apparent that the layout was failing to snap the app bar closed or open as it should when the user's finger was not on the screen. When dragging, the app bar behaves as it should.

In version 26.0.0-beta2, some new methods were introduced - specifically dispatchNestedPreScroll() with a new type argument. The type argument specifies if the movement specified by dx and dy are due to the user touching the screen ViewCompat.TYPE_TOUCH or not ViewCompat.TYPE_NON_TOUCH.

Although the specific code that causes the problem was not identified, the tack of the fix is to kill vertical movement in dispatchNestedPreScroll() (dispose of dy) when needed by not letting vertical movement propagate. In effect, the app bar is to be latched into place when expanded and will not allowed to start to close until it is closing through a touch gesture. The app bar will also be latched when closed until the RecyclerView is positioned at its topmost extent and there is sufficient dy to open the app bar while performing a touch gesture.

So, this is not so much a fix as much as a discouragement of problematic conditions.

The last part of the MyRecyclerView code deals with an issue that was identified in this question dealing with improper scroll movements when nested scrolling is disabled. This is the part that comes after the call to the super of dispatchNestedPreScroll() that changes the value of offsetInWindow[1]. The thinking behind this code is the same as presented in the accepted answer for the question. The only difference is that since the underlying nested scrolling code has changed, the argument offsetInWindow is sometime null. Fortunately, it seems to be non-null when it matters, so the last part continues to work.

The caveat is that this "fix" is very specific to the question asked and is not a general solution. The fix will likely have a very short shelf life since I expect that such an obvious problem will be addressed shortly.

Cheticamp
  • 61,413
  • 10
  • 78
  • 131
  • Sadly this doesn't fix the snappy issue. It can still stay in between expanded and collapsed states. A bit hard (maybe even very hard), but still possible to reproduce it. I think it fixed the "wobbly" behavior, though the UI glitches (I called them "artifacts") still appear sometimes. Because it behaves much better than the original one, I will now upvote this. Thank you again for helping me so much. – android developer Jul 27 '17 at 06:54
  • I'm also not sure, but I think it now tries to snap to the original state when I perform a short gesture to switch to the other state (example: expanded, trying to collapsed by short gesture to scroll down, but it snaps to be expanded) – android developer Jul 27 '17 at 06:58
  • BTW, tip: I'd recommend not to force the Activity to implement the interface by putting the value of mAppBarTracking in the CTOR, because the view might be in a Fragment instead. – android developer Jul 27 '17 at 07:35
  • @androiddeveloper I can reproduce the "between" state by flinging up then immediately down during the scroll to bring the top item back into view. I have to get the timing just right, though and I am just occasionally successful . Is that what you are seeing? Also, I am curious about the "artifacts." What are those? – Cheticamp Jul 27 '17 at 11:47
  • I don't know the exact way I did it, but it still exists. Because it's better than what I had before, I've upvoted your answer. About the artifacts, I'm talking about glitches at the top, usually shown as if the status bar gets larger for a short time. You can see them on the video, for example. They are also a bit rare, and usually quite small. – android developer Jul 27 '17 at 12:31
  • This almost work perfectly, I think. Sometimes, when pressing the "disable" button and the AppBar is expanded, the scrolling barely works, if at all. Seems to work fine when it's collapsed, though – android developer Jul 30 '17 at 07:00
  • @androiddeveloper I hadn't added any code to accommodate switching off nested scrolling. `MyRecyclerView` now handles having nested scrolling turn off and on. – Cheticamp Jul 30 '17 at 13:28
  • You mean you've updated the code? If so, I don't see this issue anymore. I think you got to handle all of those annoying issues. Are there more insights of how you've fixed it all? I will check it out further soon. For now I accept the answer. – android developer Jul 30 '17 at 13:47
  • 1
    @androiddeveloper Yes, the code for MyRecyclerView is updated to take into account that nested scrolling could be turned off. Before, it was assuming that it was always on. If you think all the issues are handled, I can update the answer with some additional explanatory text later today. – Cheticamp Jul 30 '17 at 13:52
  • @androiddeveloper See explanatory text at bottom. – Cheticamp Jul 30 '17 at 17:20
  • Is it ok to use getTotalScrollRange() inside the onOffsetChanged, instead of getting it in the runnable, into a field ? And also to save isAppBarExpanded as a boolean field there, instead of checking the value of mAppBarOffset inside isAppBarExpanded() ? – android developer Jul 31 '17 at 06:36
  • Meaning this: mAppBarExpanded = verticalOffset == 0; mAppBarIdle = (verticalOffset >= 0) || (verticalOffset <= -appBarLayout.getTotalScrollRange()); – android developer Jul 31 '17 at 06:43
  • `getTotalScrollRange()` inside the `onOffsetChanged`? I think that would be OK. `save isAppBarExpanded as a boolean field?` Also OK. The meaning of `mAppBarExpanded = verticalOffset ==...` is that I wrote a statement that is probably always true so can be removed. I did notice this at one point, but didn't make the change. I will repost a correction. – Cheticamp Jul 31 '17 at 11:19
  • @androiddeveloper That statement in `onOffsetChanged()` isn't always true. I have commented the code to explain it. It could also be written as `mAppBarIdle = (mAppBarOffset == 0) || (mAppBarOffset == mAppBarMaxOffset)`. Also, see comment above. I forgot to tag you. – Cheticamp Jul 31 '17 at 11:38
  • I don't understand. So what I wrote as an alternative is also ok or not? What have you changed? What isn't true? – android developer Jul 31 '17 at 11:44
  • @androiddeveloper What you wrote as an alternative will work. What I said in a comment above about the statement ` mAppBarExpanded = verticalOffset == 0; mAppBarIdle = (verticalOffset >= 0) || (verticalOffset <= -appBarLayout.getTotalScrollRange());` being always true is incorrect. The statement does work to identify when the app bar is fully expanded or fully collapsed (what I am calling "idle"). I have included some comments about this in the `ScrollingActivity` code. I have not changed any code other than to make these comments. – Cheticamp Jul 31 '17 at 11:54
10

Looks like onStartNestedScroll and onStopNestedScroll calls can be reordered and it lead to "wobbly" snap. I made a small hack inside AppBarLayout.Behavior. Don't really want to mess up with all that stuff in activity as proposed by other answers.

@SuppressWarnings("unused")
public class ExtAppBarLayoutBehavior extends AppBarLayout.Behavior {

    private int mStartedScrollType = -1;
    private boolean mSkipNextStop;

    public ExtAppBarLayoutBehavior() {
        super();
    }

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

    @Override
    public boolean onStartNestedScroll(CoordinatorLayout parent, AppBarLayout child, View directTargetChild, View target, int nestedScrollAxes, int type) {
        if (mStartedScrollType != -1) {
            onStopNestedScroll(parent, child, target, mStartedScrollType);
            mSkipNextStop = true;
        }
        mStartedScrollType = type;
        return super.onStartNestedScroll(parent, child, directTargetChild, target, nestedScrollAxes, type);
    }

    @Override
    public void onStopNestedScroll(CoordinatorLayout coordinatorLayout, AppBarLayout abl, View target, int type) {
        if (mSkipNextStop) {
            mSkipNextStop = false;
            return;
        }
        if (mStartedScrollType == -1) {
            return;
        }
        mStartedScrollType = -1;
        // Always pass TYPE_TOUCH, because want to snap even after fling
        super.onStopNestedScroll(coordinatorLayout, abl, target, ViewCompat.TYPE_TOUCH);
    }
}

Usage in XML layout:

<android.support.design.widget.CoordinatorLayout>

    <android.support.design.widget.AppBarLayout
        app:layout_behavior="com.example.ExtAppBarLayoutBehavior">

        <!-- Put here everything you usually add to AppBarLayout: CollapsingToolbarLayout, etc... -->

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

    <!-- Content: recycler for example -->
    <android.support.v7.widget.RecyclerView
        app:layout_behavior="@string/appbar_scrolling_view_behavior" />

    ...

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

It is very likely that the root cause of the problem in the RecyclerView. Do not have an opportunity to dig deeper now.

vyndor
  • 368
  • 2
  • 13
  • Is this the entire workaround, or does it include extra code? Does it require the changes from other answers ? Please explain. – android developer Feb 19 '18 at 12:30
  • Yes, it is entire workaround. For me it works fine. You even can use only ExtBehavior from xml. Just add `app:layout_behavior="com.example.ExtAppBarLayout$ExtBehavior` to your AppBarLayout xml declaration. It is unnecessary to subclass AppBarLayout, but I made it for convinience. – vyndor Feb 19 '18 at 13:09
  • Interesting. So you say you can't reproduce now (with your new code) any of the issues I've shown on the video? Nice. Hope to check it out soon. – android developer Feb 19 '18 at 13:43
  • I am not really sure if it helps with CollapsingToolbarLayout, in my case I use plain AppBarLayout. But I think this problems have a single cause. Let me know when you try it! – vyndor Feb 19 '18 at 13:49
  • Why actually you created the class of ExtAppBarLayout, if all code is in the behavior ? Anyway, I've now tested the code. It crashed for me with `ClassCastException: android.support.v4.widget.NestedScrollView cannot be cast to android.support.design.widget.AppBarLayout` . I've tried it on a new project, and the one from the issue tracker. Please show how to use your code. Maybe even put a sample project by uploading a zipped file somewhere. – android developer Feb 19 '18 at 14:24
  • Looks like you attached Behaviour to something different from AppBarLayout. I edited my answer to be more straightforward and added an example of xml. Hope it helps. If not, I'll make a sample project. – vyndor Feb 19 '18 at 15:42
  • The way the new app is created (via the wizard of a new app), the behavior is set to be on the scrolling view, meaning NestedScrollView or RecyclerView. That's where I've put it. Why did you change it to be on the AppBarLayout ? – android developer Feb 19 '18 at 18:17
  • I override default behavior of AppBarLayout (it's set via annotation in java). You should leave other views as is. Everything you need is to add behavior to AppBarLayout. I updated XML layout in my answer. – vyndor Feb 20 '18 at 09:22
  • Seems to work well, but it still has artifacts at the top. – android developer Feb 21 '18 at 07:40
5

Edit The code has been updated to bring it more in line with the code for the accepted answer. This answer concerns NestedScrollView while the accepted answer is about RecyclerView.


This is an issue what was introduced in the API 26.0.0-beta2 release. It does not happen on the beta 1 release or with API 25. As you noted, it also happens with API 26.0.0. Generally, the problem seems to be related to how flings and nested scrolling are handled in beta2. There was a major rewrite of nested scrolling (see "Carry on Scrolling"), so it is not surprising that this type of issue has cropped up.

My thinking is that excess scroll is not being disposed of properly somewhere in NestedScrollView. The work-around is to quietly consume certain scrolls that are "non-touch" scrolls (type == ViewCompat.TYPE_NON_TOUCH) when the AppBar is expanded or collapsed. This stops the bouncing, allows snaps and, generally, makes the AppBar better behaved.

ScrollingActivity has been modified to track the status of the AppBar to report whether it is expanded or not. A new class call "MyNestedScrollView" overrides dispatchNestedPreScroll() (the new one, see here) to manipulate the consumption of the excess scroll.

The following code should suffice to stop AppBarLayout from wobbling and refusing to snap. (XML will also have to change to accommodate MyNestedSrollView. The following only applies to support lib 26.0.0-beta2 and above.)

AppBarTracking.java

public interface AppBarTracking {
    boolean isAppBarIdle();
    boolean isAppBarExpanded();
}

ScrollingActivity.java

public class ScrollingActivity extends AppCompatActivity implements AppBarTracking {

    private int mAppBarOffset;
    private int mAppBarMaxOffset;
    private MyNestedScrollView mNestedView;
    private boolean mAppBarIdle = true;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        AppBarLayout appBar;

        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_scrolling);
        final Toolbar toolbar = findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        appBar = findViewById(R.id.app_bar);
        mNestedView = findViewById(R.id.nestedScrollView);
        mNestedView.setAppBarTracking(this);
        appBar.addOnOffsetChangedListener(new AppBarLayout.OnOffsetChangedListener() {
            @Override
            public void onOffsetChanged(AppBarLayout appBarLayout, int verticalOffset) {
                mAppBarOffset = verticalOffset;
            }
        });

        appBar.addOnOffsetChangedListener(new AppBarLayout.OnOffsetChangedListener() {
            @Override
            public final void onOffsetChanged(AppBarLayout appBarLayout, int verticalOffset) {
                mAppBarOffset = verticalOffset;
                // mAppBarOffset = 0 if app bar is expanded; If app bar is collapsed then
                // mAppBarOffset = mAppBarMaxOffset
                // mAppBarMaxOffset is always <=0 (-AppBarLayout.getTotalScrollRange())
                // mAppBarOffset should never be > zero or less than mAppBarMaxOffset
                mAppBarIdle = (mAppBarOffset >= 0) || (mAppBarOffset <= mAppBarMaxOffset);
            }
        });

        mNestedView.post(new Runnable() {
            @Override
            public void run() {
                mAppBarMaxOffset = mNestedView.getMaxScrollAmount();
            }
        });
    }

    @Override
    public boolean isAppBarIdle() {
        return mAppBarIdle;
    }

    @Override
    public boolean isAppBarExpanded() {
        return mAppBarOffset == 0;
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        // Inflate the menu; this adds items to the action bar if it is present.
        getMenuInflater().inflate(R.menu.menu_scrolling, menu);
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        // Handle action bar item clicks here. The action bar will
        // automatically handle clicks on the Home/Up button, so long
        // as you specify a parent activity in AndroidManifest.xml.
        int id = item.getItemId();

        //noinspection SimplifiableIfStatement
        if (id == R.id.action_settings) {
            return true;
        }
        return super.onOptionsItemSelected(item);
    }

    @SuppressWarnings("unused")
    private static final String TAG = "ScrollingActivity";
}

MyNestedScrollView.java

public class MyNestedScrollView extends NestedScrollView {

    public MyNestedScrollView(Context context) {
        this(context, null);
    }

    public MyNestedScrollView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

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

        setOnScrollChangeListener(new View.OnScrollChangeListener() {
            @Override
            public void onScrollChange(View view, int x, int y, int oldx, int oldy) {
                mScrollPosition = y;
            }
        });
    }

    private AppBarTracking mAppBarTracking;
    private int mScrollPosition;

    @Override
    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow,
                                           int type) {

        // App bar latching trouble is only with this type of movement when app bar is expanded
        // or collapsed. In touch mode, everything is OK regardless of the open/closed status
        // of the app bar.
        if (type == ViewCompat.TYPE_NON_TOUCH && mAppBarTracking.isAppBarIdle()
                && isNestedScrollingEnabled()) {
            // Make sure the AppBar stays expanded when it should.
            if (dy > 0) { // swiped up
                if (mAppBarTracking.isAppBarExpanded()) {
                    // Appbar can only leave its expanded state under the power of touch...
                    consumed[1] = dy;
                    return true;
                }
            } else { // swiped down (or no change)
                // Make sure the AppBar stays collapsed when it should.
                if (mScrollPosition + dy < 0) {
                    // Scroll until scroll position = 0 and AppBar is still collapsed.
                    consumed[1] = dy + mScrollPosition;
                    return true;
                }
            }
        }

        boolean returnValue = super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
        // Fix the scrolling problems when scrolling is disabled. This issue existed prior
        // to 26.0.0-beta2. (Not sure that this is a problem for 26.0.0-beta2 and later.)
        if (offsetInWindow != null && !isNestedScrollingEnabled() && offsetInWindow[1] != 0) {
            Log.d(TAG, "<<<<offsetInWindow[1] forced to zero");
            offsetInWindow[1] = 0;
        }
        return returnValue;
    }

    public void setAppBarTracking(AppBarTracking appBarTracking) {
        mAppBarTracking = appBarTracking;
    }

    @SuppressWarnings("unused")
    private static final String TAG = "MyNestedScrollView";
}
Cheticamp
  • 61,413
  • 10
  • 78
  • 131
  • It exists even on latest, "final" version that was out in recent few days (26.0.0). – android developer Jul 25 '17 at 14:25
  • I would still report it via the preview channel. It could still make the final-final release if they notice your report and decide to fix it. – Cheticamp Jul 25 '17 at 14:29
  • It exists on non-Android-O versions too. It's related to support library, so this is where I've reported about it. – android developer Jul 25 '17 at 14:43
  • Hope to check this code soon. But in the meantime, I'd like to ask if the same exact solution would work for RecyclerView as I've written about, and if not, what should be changed? – android developer Jul 26 '17 at 14:24
  • @androiddeveloper As I recall, the RecyclerView question involved turning off nested scrolling (setNestedScrollingEnabled(false)) that resulted in the use of a stale variable. Although this problem has some similarities to that particular issue, I believe that it is separate and distinct. This problem was definitely introduced in 26.0.0-beta2. The RecyclerView issue has probably been there since RecyclerVIew was introduced. – Cheticamp Jul 26 '17 at 15:16
  • And yet, the problem I've been trying to handle here is with RecyclerView, and not NestedScrollView. I've shown that even NestedScrollView has this issue, because I consider it to be simpler than RecyclerView, to emphasize the issue. – android developer Jul 26 '17 at 18:41
  • Is this solution just for the issues fixing, without the part of enable/disable the nested scrolling? Does it work as well as the other answer? – android developer Jul 31 '17 at 07:12
  • @androiddeveloper This solution addresses app bar wobbling and failure to latch open or closed. The scrolling issue that occurs when nested scrolling is turned off/on is not included in this solution. It is not clear to me that `NestedScrollView` has that issue but, if it does, the same approach as taken with `RecyclerView` may work. – Cheticamp Jul 31 '17 at 11:47
  • Thank you. Will it be about the same code for RecyclerView ? – android developer Jul 31 '17 at 17:36
  • 1
    @androiddeveloper The code for `RecyclerView` and `NestedScrollView` are very similar in the part where nested scrolling is handled. I don't know what will or won't work when nested scrolling is turned off for `NestedScrollView`. I don't think the `offsetInWindow` code from `MyRecyclerView` would be harmful if added to `MyNestedScrollView` and it may help. – Cheticamp Jul 31 '17 at 17:51
  • @androiddeveloper Just did a quick test and the scrolling issue does exist on API 26.0.0 for `NestedScrollView` as it does for `RecyclerView`. A port of `dispatchNestedPreScroll()` from `MyRecyclerView` to `MyNestedScrollView` should work. You will have to change out how the top of the scroll range is detected to what is currently in `MyNestedScrollView`. That is the part that start with `if (mScrollPosition + dy < 0)`. – Cheticamp Jul 31 '17 at 22:17
  • I've already been working with it. But, can you please update the answers? Does the accepted answer also need updating? – android developer Jul 31 '17 at 22:59
  • @androiddeveloper The accepted answer is OK as it is. I will update the `NestedScrollingView` answer. – Cheticamp Jul 31 '17 at 23:18
  • Thank you very much – android developer Aug 01 '17 at 12:31
  • @Cheticamp `setOnScrollChangeListener` inside **MyNestedScrollVIew** gives me an error "OnScrollChangeListener requires API level 23" is there any way to use it at least from API 21? – CBeTJlu4ok Feb 15 '18 at 09:55
  • @CBeTJlu4ok Those come from the support library, so it shouldn't be an issue. Check to make sure that your project is set up correctly. If you still have a problem, post a question to Stack Overflow. – Cheticamp Feb 15 '18 at 12:25
  • This code fixed the issue but also prevented the SwipeRefreshLayout from working. Can't believe this is still a bug with version 27.1.0 of the support library. – alexbchr Mar 21 '18 at 19:46
  • @alexbchr This patch gobbles up excess scroll, so that makes sense. It is surprising that this issue has been allowed to linger. – Cheticamp Mar 21 '18 at 21:39
  • @Cheticamp The `setOnScrollChangeListener` is actually different there. The listener is `NestedScrollView.OnScrollChangeListener` , and its method signature is different: `void onScrollChange(NestedScrollView v, int scrollX, int scrollY, int oldScrollX, int oldScrollY)` . I don't think you shouldn't use the one of `View`, as I think it will crash. Use this one instead. – android developer Apr 12 '18 at 13:57
2

Since the issue is still not fixed as of February 2020 (latest material library version is 1.2.0-alpha5) I want to share my solution to the buggy AppBar animation.

The idea is to implmenet custom snapping logic by extending AppBarLayout.Behavior (Kotlin version):

package com.example

import android.content.Context
import android.os.Handler
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.AppBarLayout.LayoutParams

@Suppress("unused")
class AppBarBehaviorFixed(context: Context?, attrs: AttributeSet?) :
    AppBarLayout.Behavior(context, attrs) {

    private var view: AppBarLayout? = null
    private var snapEnabled = false

    private var isUpdating = false
    private var isScrolling = false
    private var isTouching = false

    private var lastOffset = 0

    private val handler = Handler()

    private val snapAction = Runnable {
        val view = view ?: return@Runnable
        val offset = -lastOffset
        val height = view.run { height - paddingTop - paddingBottom - getChildAt(0).minimumHeight }

        if (offset > 1 && offset < height - 1) view.setExpanded(offset < height / 2)
    }

    private val updateFinishDetector = Runnable {
        isUpdating = false
        scheduleSnapping()
    }

    private fun initView(view: AppBarLayout) {
        if (this.view != null) return

        this.view = view

        // Checking "snap" flag existence (applied through child view) and removing it
        val child = view.getChildAt(0)
        val params = child.layoutParams as LayoutParams
        snapEnabled = params.scrollFlags hasFlag LayoutParams.SCROLL_FLAG_SNAP
        params.scrollFlags = params.scrollFlags removeFlag LayoutParams.SCROLL_FLAG_SNAP
        child.layoutParams = params

        // Listening for offset changes
        view.addOnOffsetChangedListener(AppBarLayout.OnOffsetChangedListener { _, offset ->
            lastOffset = offset

            isUpdating = true
            scheduleSnapping()

            handler.removeCallbacks(updateFinishDetector)
            handler.postDelayed(updateFinishDetector, 50L)
        })
    }

    private fun scheduleSnapping() {
        handler.removeCallbacks(snapAction)
        if (snapEnabled && !isUpdating && !isScrolling && !isTouching) {
            handler.postDelayed(snapAction, 50L)
        }
    }

    override fun onLayoutChild(
        parent: CoordinatorLayout,
        abl: AppBarLayout,
        layoutDirection: Int
    ): Boolean {
        initView(abl)
        return super.onLayoutChild(parent, abl, layoutDirection)
    }

    override fun onTouchEvent(
        parent: CoordinatorLayout,
        child: AppBarLayout,
        ev: MotionEvent
    ): Boolean {
        isTouching =
            ev.actionMasked != MotionEvent.ACTION_UP && ev.actionMasked != MotionEvent.ACTION_CANCEL
        scheduleSnapping()
        return super.onTouchEvent(parent, child, ev)
    }

    override fun onStartNestedScroll(
        parent: CoordinatorLayout,
        child: AppBarLayout,
        directTargetChild: View,
        target: View,
        nestedScrollAxes: Int,
        type: Int
    ): Boolean {
        val started = super.onStartNestedScroll(
            parent, child, directTargetChild, target, nestedScrollAxes, type
        )

        if (started) {
            isScrolling = true
            scheduleSnapping()
        }

        return started
    }

    override fun onStopNestedScroll(
        coordinatorLayout: CoordinatorLayout,
        abl: AppBarLayout,
        target: View,
        type: Int
    ) {
        isScrolling = false
        scheduleSnapping()

        super.onStopNestedScroll(coordinatorLayout, abl, target, type)
    }


    private infix fun Int.hasFlag(flag: Int) = flag and this == flag

    private infix fun Int.removeFlag(flag: Int) = this and flag.inv()

}

And now apply this behavior to the AppBarLayout in xml:

<android.support.design.widget.CoordinatorLayout>

    <android.support.design.widget.AppBarLayout
        app:layout_behavior="com.example.AppBarBehaviorFixed">

        <com.google.android.material.appbar.CollapsingToolbarLayout
            app:layout_scrollFlags="scroll|exitUntilCollapsed|snap">

            <!-- Toolbar declaration -->

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

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

    <!-- Scrolling view (RecyclerView, NestedScrollView) -->

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

That is still a hack but it seems to work quite well, and it does not require to put dirty code into your activity or extend RecyclerView and NestedScrollView widgets (thanks to @vyndor for this idea).

Alex Vasilkov
  • 6,215
  • 4
  • 23
  • 16
0

I also face problem with CollapsingToolbar not fully snap to bottom after displaying AppBar under system status bar.

After testing then I found

  • With material version 1.3.0, function AppBarLayout#snapToChildIfNeeded don't exclude the system inset, so it wont fully snap, it will have some space to bottom which equal status bar height

  • From material version 1.6.0, function AppBarLayout#snapToChildIfNeeded exclude the system inset, so CollapsingToolbar will fully snap

If you still want to use 1.3.0, then you can do a workaround by set the marginTop for first child of AppBar layout + minHeight for CollapsingToolbar (equal statusbar height), instead of using paddingTop on AppBarLayout

Linh
  • 57,942
  • 23
  • 262
  • 279
  • Can you please show me a full sample (Github, perhaps) so that I could test it out and see it working better than in the past? – android developer Mar 28 '23 at 07:35
  • @androiddeveloper can try this one https://github.com/PhanVanLinh/AndroidCollapsingToolbarLayoutMotion – Linh Mar 28 '23 at 08:44
  • It seems to use many files and when I run it, it moves very weird when I stop touching. Are you sure this is using the official API and has no workarounds? It doesn't work well... Please try to show the most minimal working example. Perhaps something here has ruined it – android developer Mar 28 '23 at 09:23
  • @androiddeveloper in above demo, I use workaround because it's material version 1.3.0 let me update a new version with latest material and remove other field animations – Linh Mar 28 '23 at 10:40
  • OK let me know when I can check it out. I hope they finally made something without weird issues – android developer Mar 29 '23 at 14:14