Play with Android nested scrolling

Play with Android nested scrolling

In the process of Android UI development, we often encounter the need for nested scrolling. The so-called nested scrolling means that the child view can scroll when the parent view can scroll, such as pull-to-refresh. In the previous version of WeChat Reading, there was a more complex nested scrolling example in the book discussion circle. I extracted it as an example for today's explanation:

The nesting of this example is relatively complex. The header at the top is the book cover, and the container below is composed of ViewPager+TabLayout (hereinafter referred to as VT container). The three items in ViewPager are three lists, which can also be scrolled. The business requirements are:

  1. VT containers can be scrolled;
  2. Book covers can be scrolled and have parallax;
  3. When the VT container is scrolled to the top, the list is scrolled, and the scrolling can be connected.
  4. When the list scrolls to the top, the book cover and VT container can be scrolled, and the scrolling can be continuous

Now that the logic is clear, let's see how to implement it. Before Android 5, for this kind of scrolling, we can only choose to intercept the event and handle it ourselves, but in a later version, Android launched the NestingScroll mechanism, which made life much easier for developers, and Android provided a very good container class: CoordinatorLayout, which greatly simplified the work of developers. Of course, we also need to invest energy to learn and use these new APIs.

Of course, we also need to know how to achieve these effects if we don't have these APIs. Therefore, this article will use three methods to achieve this effect:

  1. Pure event interception and dispatch solution
  2. Implementation scheme based on NestingScroll mechanism
  3. Implementation based on CoordinatorLayout and Behavior solution

The sample code is on Github and can be cloned and read in conjunction with the article.

Pure event interception and dispatch solution

This is the most primitive solution, and of course the most flexible. In principle, other solutions are based on the encapsulation provided by the system. When using this solution, we need to solve the following problems:

  1. Scrolling of view (Scroller);
  2. View velocity tracking (VelocityTracker);
  3. How can we pass the event to ListView when the VT container scrolls to the top?
  4. When ListView scrolls to the top, how does the VT container intercept the event?

Points 1 and 2 are the basic knowledge of scrolling, so we will not explain them in detail here. Why does point 3 appear? Because when the Android system dispatches an event, if the event is intercepted, then the subsequent events will not be passed to the child view. The solution is also very simple: actively dispatch a Down event when scrolling to the top:

  1. if (mTargetCurrentOffset + dy <= mTargetEndOffset) {
  2. moveTargetView(dy);
  3. // Re-dispatch the down event so that the list can continue to scroll
  4. int oldAction = ev.getAction();
  5. ev.setAction(MotionEvent.ACTION_DOWN);
  6. dispatchTouchEvent(ev);
  7. ev.setAction(oldAction);
  8. } else {
  9. moveTargetView(dy);
  10. }

So what is the problem with point 4? Here we need to be clear about a pitfall: not all events used will enter onInterceptTouchEvent. In one case, the child View actively calls parent.requestDisallowInterceptTouchEvent(true) to tell the system: I want this event, and the parent View does not want to intercept it. This is the so-called internal interception method. At some point in ListView, it will call this method. Therefore, once the event is passed to ListView, the external container cannot get this event. Therefore, we need to break its internal interception:

  1. @Override
  2. public void requestDisallowInterceptTouchEvent(boolean b) {
  3. // Remove the default behavior so that each event will pass through this Layout
  4. }

The method is as above, just remove the implementation of requestDisallowInterceptTouchEvent.

The main technical points have been proposed. Now let's look at the specific implementation, first using XML:

  1. <org.cgspine.nestscroll.one.EventDispatchPlanLayout
  2. android:id= "@+id/scrollLayout"  
  3. android:layout_marginTop= "?attr/actionBarSize"  
  4. android:layout_width= "match_parent"  
  5. android:layout_height= "match_parent"  
  6. app:header_view= "@+id/book_header"  
  7. app:target_view= "@+id/scroll_view"  
  8. app:header_init_offset= "30dp"  
  9. app:target_init_offset= "70dp" >
  10. < View  
  11. android:id= "@id/book_header"  
  12. android:layout_width= "120dp"  
  13. android:layout_height= "150dp"  
  14. android:background= "@color/gray" />
  15. <org.cgspine.nestscroll.one.EventDispatchTargetLayout
  16. android:id= "@id/scroll_view"  
  17. android:layout_width= "match_parent"  
  18. android:layout_height= "match_parent"  
  19. android:orientation= "vertical"  
  20. android:background= "@color/white" >
  21. <android.support.design.widget.TabLayout
  22. android:id= "@+id/tab_layout"  
  23. android:background= "@drawable/list_item_bg_with_border_top_bottom"  
  24. android:layout_width= "match_parent"  
  25. android:layout_height= "@dimen/tab_layout_height"  
  26. android:fillViewport= "true" />
  27. < android.support.v4.view.ViewPager
  28. android:id= "@+id/viewpager"  
  29. android:layout_width= "match_parent"  
  30. android:layout_height= "0dp"  
  31. android:layout_weight= "1" />
  32. </org.cgspine.nestscroll.one.EventDispatchTargetLayout>
  33. </org.cgspine.nestscroll.one.EventDispatchPlanLayout>

EventDispatchTargetLayout implements the custom interface ITargetView:

  1. public interface ITargetView {
  2. boolean canChildScrollUp();
  3. void fling( float vy);
  4. }

This is because it is separated from the specific business, and I don’t know what the inner box looks like (it may be ListView, or it may be ViewPager wrapping ListView)

The main implementation is in EventDispatchPlanLayout. When using it, just specify variables such as header_init_offset and target_init_offset in xml, which is basically independent of business logic.

The key implementation logic is in onInterceptTouchEvent and onTouchEvent. I personally do not recommend moving dispatchTouchEvent, although all events will pass through here, but this will obviously increase the complexity of code processing:

  1. public boolean onInterceptTouchEvent(MotionEvent ev) {
  2. ensureHeaderViewAndScrollView();
  3. final int   action = MotionEventCompat.getActionMasked(ev);
  4. int pointerIndex;
  5.  
  6. // Do not block the fast path of the event: if the target view can scroll up or `EventDispatchPlanLayout` is not enabled
  7. if (!isEnabled() || mTarget.canChildScrollUp()) {
  8. Log.d(TAG, "fast end onIntercept: isEnabled = " + isEnabled() + "; canChildScrollUp = "  
  9. + mTarget.canChildScrollUp());
  10. return   false ;
  11. }
  12. switch ( action ) {
  13. case MotionEvent.ACTION_DOWN:
  14. mActivePointerId = ev.getPointerId(0);
  15. mIsDragging = false ;
  16. pointerIndex = ev.findPointerIndex(mActivePointerId);
  17. if (pointerIndex < 0) {
  18. return   false ;
  19. }
  20. //Record the initial y value when down
  21. mInitialDownY = ev.getY(pointerIndex);
  22. break;
  23.  
  24. case MotionEvent.ACTION_MOVE:
  25. pointerIndex = ev.findPointerIndex(mActivePointerId);
  26. if (pointerIndex < 0) {
  27. Log.e(TAG, "Got ACTION_MOVE event but have an invalid active pointer id." );
  28. return   false ;
  29. }
  30.  
  31. final float y = ev.getY(pointerIndex);
  32. // Determine whether to drag
  33. startDragging(y);
  34. break;
  35.  
  36. case MotionEventCompat.ACTION_POINTER_UP:
  37. // Double finger logic processing
  38. onSecondaryPointerUp(ev);
  39. break;
  40.  
  41. case MotionEvent.ACTION_UP:
  42. case MotionEvent.ACTION_CANCEL:
  43. mIsDragging = false ;
  44. mActivePointerId = INVALID_POINTER;
  45. break;
  46. }
  47.  
  48. return mIsDragging;
  49. }

The code logic is very clear, so there is no need to say more. Next, let's look at the processing logic of onTouchEvent.

  1. public boolean onTouchEvent(MotionEvent ev) {
  2. final int   action = MotionEventCompat.getActionMasked(ev);
  3. int pointerIndex;
  4.  
  5. if (!isEnabled() || mTarget.canChildScrollUp()) {
  6. Log.d(TAG, "fast end onTouchEvent: isEnabled = " + isEnabled() + "; canChildScrollUp = "  
  7. + mTarget.canChildScrollUp());
  8. return   false ;
  9. }
  10. // Speed ​​tracking
  11. acquireVelocityTracker(ev);
  12.  
  13. switch ( action ) {
  14. case MotionEvent.ACTION_DOWN:
  15. mActivePointerId = ev.getPointerId(0);
  16. mIsDragging = false ;
  17. break;
  18.  
  19. case MotionEvent.ACTION_MOVE: {
  20. pointerIndex = ev.findPointerIndex(mActivePointerId);
  21. if (pointerIndex < 0) {
  22. Log.e(TAG, "Got ACTION_MOVE event but have an invalid active pointer id." );
  23. return   false ;
  24. }
  25. final float y = ev.getY(pointerIndex);
  26. startDragging(y);
  27.  
  28. if (mIsDragging) {
  29. float dy = y - mLastMotionY;
  30. if (dy >= 0) {
  31. moveTargetView(dy);
  32. } else {
  33. if (mTargetCurrentOffset + dy <= mTargetEndOffset) {
  34. moveTargetView(dy);
  35. // Re-dispatch the down event so that the list can continue to scroll
  36. int oldAction = ev.getAction();
  37. ev.setAction(MotionEvent.ACTION_DOWN);
  38. dispatchTouchEvent(ev);
  39. ev.setAction(oldAction);
  40. } else {
  41. moveTargetView(dy);
  42. }
  43. }
  44. mLastMotionY = y;
  45. }
  46. break;
  47. }
  48. case MotionEventCompat.ACTION_POINTER_DOWN: {
  49. pointerIndex = MotionEventCompat.getActionIndex(ev);
  50. if (pointerIndex < 0) {
  51. Log.e(TAG, "Got ACTION_POINTER_DOWN event but have an invalid action index." );
  52. return   false ;
  53. }
  54. mActivePointerId = ev.getPointerId(pointerIndex);
  55. break;
  56. }
  57.  
  58. case MotionEventCompat.ACTION_POINTER_UP:
  59. onSecondaryPointerUp(ev);
  60. break;
  61.  
  62. case MotionEvent.ACTION_UP: {
  63. pointerIndex = ev.findPointerIndex(mActivePointerId);
  64. if (pointerIndex < 0) {
  65. Log.e(TAG, "Got ACTION_UP event but don't have an active pointer id." );
  66. return   false ;
  67. }
  68.  
  69. if (mIsDragging) {
  70. mIsDragging = false ;
  71. // Get instantaneous speed
  72. mVelocityTracker.computeCurrentVelocity(1000, mMaxVelocity);
  73. final float vy = mVelocityTracker.getYVelocity(mActivePointerId);
  74. finishDrag(( int ) vy);
  75. }
  76. mActivePointerId = INVALID_POINTER;
  77. //Release speed tracking
  78. releaseVelocityTracker();
  79. return   false ;
  80. }
  81. case MotionEvent.ACTION_CANCEL:
  82. releaseVelocityTracker();
  83. return   false ;
  84. }
  85.  
  86. return mIsDragging;
  87. }

Some people may ask: Why are there so many duplicate codes with onInterceptTouchEvent? This is because if the event is not interrupted and the subclass does not handle it, it will enter the onTouchEvent logic, so these duplicate processes are meaningful (actually copied from SwipeRefreshLayout). There are two main logics:

  1. Scroll container
  2. Scroll to a specific position and fling when TouchUp

The logic of scrolling container:

  1. private void moveTargetViewTo( int target) {
  2. target = Math. max (target, mTargetEndOffset);
  3. // Use offsetTopAndBottom to offset the view  
  4. ViewCompat.offsetTopAndBottom(mTargetView, target - mTargetCurrentOffset);
  5. mTargetCurrentOffset = target;
  6.  
  7. // Scroll the book cover view and position it according to TargetView
  8. int headerTarget;
  9. if (mTargetCurrentOffset >= mTargetInitOffset) {
  10. headerTarget = mHeaderInitOffset;
  11. } else if (mTargetCurrentOffset <= mTargetEndOffset) {
  12. headerTarget = mHeaderEndOffset;
  13. } else {
  14. float percent = (mTargetCurrentOffset - mTargetEndOffset) * 1.0f / mTargetInitOffset - mTargetEndOffset;
  15. headerTarget = ( int ) (mHeaderEndOffset + percent * (mHeaderInitOffset - mHeaderEndOffset));
  16. }
  17. ViewCompat.offsetTopAndBottom(mHeaderView, headerTarget - mHeaderCurrentOffset);
  18. mHeaderCurrentOffset = headerTarget;
  19. }

TouchUp scrolling logic:

  1. private void finishDrag( int vy) {
  2. Log.i(TAG, "TouchUp: vy = " + vy);
  3. if (vy > 0) {
  4. // Trigger fling downwards, need to scroll to the Init position
  5. mNeedScrollToInitPos = true ;
  6. mScroller.fling(0, mTargetCurrentOffset, 0, vy,
  7. 0, 0, mTargetEndOffset, Integer .MAX_VALUE);
  8. invalidate();
  9. } else if (vy < 0) {
  10. // Trigger fling upwards, need to scroll to the End position
  11. mNeedScrollToEndPos = true ;
  12. mScroller.fling(0, mTargetCurrentOffset, 0, vy,
  13. 0, 0, mTargetEndOffset, Integer .MAX_VALUE);
  14. invalidate();
  15. } else {
  16. // No fling is triggered, the principle of proximity
  17. if (mTargetCurrentOffset <= (mTargetEndOffset + mTargetInitOffset) / 2) {
  18. mNeedScrollToEndPos = true ;
  19. } else {
  20. mNeedScrollToInitPos = true ;
  21. }
  22. invalidate();
  23. }
  24. }

Of course, some flags will be set here. The specific implementation is in computeScroll, which belongs to the function of Scroller and will not be expanded here.

This explains the general logic clearly. For other details, please read the source code directly.

Implementation scheme based on NestingScroll mechanism

The NestingScroll mechanism was added in a certain version of the support package, but there are very few articles introducing it, so most people probably don't know about it. NestingScroll has two main interfaces:

  • NestedScrollingParent
  • NestedScrollingChild

When we need to use the NestingScroll feature, we just need to implement these two interfaces. The essence of NestingScroll is to intercept internally and then open the corresponding interface to the outside world. Therefore, it is difficult to implement the NestedScrollingChild interface. However, for controls like RecyclerView, the official has already implemented NestedScrollingChild for us. To meet our needs, we can just use it directly (ListView cannot be used, of course, you can also implement the NestedScrollingChild interface). And as long as NestedScrollingChild and NestedScrollingParent have a nested relationship, it is fine. It is not necessary that NestedScrollingChild is a direct child View.

Let's take a look at the definition of NestedScrollingParent:

  1. public interface NestedScrollingParent {
  2. // Whether to accept NestingScroll
  3. public boolean onStartNestedScroll( View child, View target, int nestedScrollAxes);
  4. //Accept NestingScroll's Hook
  5. public void onNestedScrollAccepted( View child, View target, int nestedScrollAxes);
  6. //NestingScroll ends
  7. public void onStopNestedScroll( View target);
  8. // NestingScroll is in progress. Important parameters dxUnconsumed, dyUnconsumed: used to indicate the amount of scrolling that has not been consumed. Usually, when the list is scrolled to the end, unconsumed amount will be generated.
  9. public void onNestedScroll( View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed);
  10. // Before NestingScroll scrolls. Important parameter consumed: It is used to tell the child View how much I have consumed. If all bits are consumed, then the child View can be consumed.
  11. public void onNestedPreScroll( View target, int dx, int dy, int [] consumed);
  12. // When fling
  13. public boolean onNestedFling( View target, float velocityX, float velocityY, boolean consumed);
  14. // Before fling: the fling event can be consumed by the parent element
  15. public boolean onNestedPreFling( View target, float velocityX, float velocityY);
  16. // Get the scroll axis: x-axis or y-axis
  17. public   int getNestedScrollAxes();
  18. }

The interface is very rich. There is a very important concept: consumption. For example, if I slide 10dp, the parent element will first check how much it can consume (for example, 4dp), and then pass the unconsumed amount to the child View (6dp). This converts the problem of nested scrolling into a problem of resource allocation. Very clever. In addition, the official NestedScrollingParentHelper class helps me implement some public methods and makes it compatible with lower versions. We should use it.

Written in ***

Although Google provides many new and interesting interfaces, it takes some effort to practice these new technologies. This is a very meaningful investment. Reading more and writing more can help us write better code in less time.

<<:  React Native touch event processing detailed explanation

>>:  Android Loader Detailed Explanation

Recommend

How does Bilibili promote itself and attract traffic?

The outline is as follows: 1. What is Bilibili? W...

“Chip Metabolism” Is LeTV’s first dual-chip phone reliable?

"The fastest chip should not only outperform...

up to date! Data rankings of 59 information flow advertising platforms!

The following is the latest traffic ranking of 59...

Why can’t you play good standalone games? Tencent can’t take the blame alone

Domestic stand-alone games are a topic that can n...

VR glasses startups are booming, and these manufacturers are really good at it

On March 26, 2014, Facebook spent $2 billion to a...

Who was the first person to reach the North Pole?

Many people are familiar with the story of Antarc...

4 tips and 6 taboos for live streaming sales

As a short video platform with more than 600 mill...

Wuchang SEO training: How to improve the weight of the website

SEO is not an immediate success, but a long proce...

The promotion process of "giving away books" to attract new customers

Nowadays, many fission methods are carried out th...