Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to avoid CollapsingToolbarLayout not being snapped or being "wobbly" when scrolling?

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)     } } 
like image 591
android developer Avatar asked Jul 19 '17 13:07

android developer


1 Answers

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.

like image 102
Cheticamp Avatar answered Sep 20 '22 05:09

Cheticamp