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: - VT containers can be scrolled;
- Book covers can be scrolled and have parallax;
- When the VT container is scrolled to the top, the list is scrolled, and the scrolling can be connected.
- 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: - Pure event interception and dispatch solution
- Implementation scheme based on NestingScroll mechanism
- 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: - Scrolling of view (Scroller);
- View velocity tracking (VelocityTracker);
- How can we pass the event to ListView when the VT container scrolls to the top?
- 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: - if (mTargetCurrentOffset + dy <= mTargetEndOffset) {
- moveTargetView(dy);
- // Re-dispatch the down event so that the list can continue to scroll
- int oldAction = ev.getAction();
- ev.setAction(MotionEvent.ACTION_DOWN);
- dispatchTouchEvent(ev);
- ev.setAction(oldAction);
- } else {
- moveTargetView(dy);
- }
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: - @Override
- public void requestDisallowInterceptTouchEvent(boolean b) {
- // Remove the default behavior so that each event will pass through this Layout
- }
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: - <org.cgspine.nestscroll.one.EventDispatchPlanLayout
- android:id= "@+id/scrollLayout"
- android:layout_marginTop= "?attr/actionBarSize"
- android:layout_width= "match_parent"
- android:layout_height= "match_parent"
- app:header_view= "@+id/book_header"
- app:target_view= "@+id/scroll_view"
- app:header_init_offset= "30dp"
- app:target_init_offset= "70dp" >
- < View
- android:id= "@id/book_header"
- android:layout_width= "120dp"
- android:layout_height= "150dp"
- android:background= "@color/gray" />
- <org.cgspine.nestscroll.one.EventDispatchTargetLayout
- android:id= "@id/scroll_view"
- android:layout_width= "match_parent"
- android:layout_height= "match_parent"
- android:orientation= "vertical"
- android:background= "@color/white" >
- <android.support.design.widget.TabLayout
- android:id= "@+id/tab_layout"
- android:background= "@drawable/list_item_bg_with_border_top_bottom"
- android:layout_width= "match_parent"
- android:layout_height= "@dimen/tab_layout_height"
- android:fillViewport= "true" />
- < android.support.v4.view.ViewPager
- android:id= "@+id/viewpager"
- android:layout_width= "match_parent"
- android:layout_height= "0dp"
- android:layout_weight= "1" />
- </org.cgspine.nestscroll.one.EventDispatchTargetLayout>
- </org.cgspine.nestscroll.one.EventDispatchPlanLayout>
EventDispatchTargetLayout implements the custom interface ITargetView: - public interface ITargetView {
- boolean canChildScrollUp();
- void fling( float vy);
- }
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: - public boolean onInterceptTouchEvent(MotionEvent ev) {
- ensureHeaderViewAndScrollView();
- final int action = MotionEventCompat.getActionMasked(ev);
- int pointerIndex;
-
- // Do not block the fast path of the event: if the target view can scroll up or `EventDispatchPlanLayout` is not enabled
- if (!isEnabled() || mTarget.canChildScrollUp()) {
- Log.d(TAG, "fast end onIntercept: isEnabled = " + isEnabled() + "; canChildScrollUp = "
- + mTarget.canChildScrollUp());
- return false ;
- }
- switch ( action ) {
- case MotionEvent.ACTION_DOWN:
- mActivePointerId = ev.getPointerId(0);
- mIsDragging = false ;
- pointerIndex = ev.findPointerIndex(mActivePointerId);
- if (pointerIndex < 0) {
- return false ;
- }
- //Record the initial y value when down
- mInitialDownY = ev.getY(pointerIndex);
- break;
-
- case MotionEvent.ACTION_MOVE:
- pointerIndex = ev.findPointerIndex(mActivePointerId);
- if (pointerIndex < 0) {
- Log.e(TAG, "Got ACTION_MOVE event but have an invalid active pointer id." );
- return false ;
- }
-
- final float y = ev.getY(pointerIndex);
- // Determine whether to drag
- startDragging(y);
- break;
-
- case MotionEventCompat.ACTION_POINTER_UP:
- // Double finger logic processing
- onSecondaryPointerUp(ev);
- break;
-
- case MotionEvent.ACTION_UP:
- case MotionEvent.ACTION_CANCEL:
- mIsDragging = false ;
- mActivePointerId = INVALID_POINTER;
- break;
- }
-
- return mIsDragging;
- }
The code logic is very clear, so there is no need to say more. Next, let's look at the processing logic of onTouchEvent. - public boolean onTouchEvent(MotionEvent ev) {
- final int action = MotionEventCompat.getActionMasked(ev);
- int pointerIndex;
-
- if (!isEnabled() || mTarget.canChildScrollUp()) {
- Log.d(TAG, "fast end onTouchEvent: isEnabled = " + isEnabled() + "; canChildScrollUp = "
- + mTarget.canChildScrollUp());
- return false ;
- }
- // Speed tracking
- acquireVelocityTracker(ev);
-
- switch ( action ) {
- case MotionEvent.ACTION_DOWN:
- mActivePointerId = ev.getPointerId(0);
- mIsDragging = false ;
- break;
-
- case MotionEvent.ACTION_MOVE: {
- pointerIndex = ev.findPointerIndex(mActivePointerId);
- if (pointerIndex < 0) {
- Log.e(TAG, "Got ACTION_MOVE event but have an invalid active pointer id." );
- return false ;
- }
- final float y = ev.getY(pointerIndex);
- startDragging(y);
-
- if (mIsDragging) {
- float dy = y - mLastMotionY;
- if (dy >= 0) {
- moveTargetView(dy);
- } else {
- if (mTargetCurrentOffset + dy <= mTargetEndOffset) {
- moveTargetView(dy);
- // Re-dispatch the down event so that the list can continue to scroll
- int oldAction = ev.getAction();
- ev.setAction(MotionEvent.ACTION_DOWN);
- dispatchTouchEvent(ev);
- ev.setAction(oldAction);
- } else {
- moveTargetView(dy);
- }
- }
- mLastMotionY = y;
- }
- break;
- }
- case MotionEventCompat.ACTION_POINTER_DOWN: {
- pointerIndex = MotionEventCompat.getActionIndex(ev);
- if (pointerIndex < 0) {
- Log.e(TAG, "Got ACTION_POINTER_DOWN event but have an invalid action index." );
- return false ;
- }
- mActivePointerId = ev.getPointerId(pointerIndex);
- break;
- }
-
- case MotionEventCompat.ACTION_POINTER_UP:
- onSecondaryPointerUp(ev);
- break;
-
- case MotionEvent.ACTION_UP: {
- pointerIndex = ev.findPointerIndex(mActivePointerId);
- if (pointerIndex < 0) {
- Log.e(TAG, "Got ACTION_UP event but don't have an active pointer id." );
- return false ;
- }
-
- if (mIsDragging) {
- mIsDragging = false ;
- // Get instantaneous speed
- mVelocityTracker.computeCurrentVelocity(1000, mMaxVelocity);
- final float vy = mVelocityTracker.getYVelocity(mActivePointerId);
- finishDrag(( int ) vy);
- }
- mActivePointerId = INVALID_POINTER;
- //Release speed tracking
- releaseVelocityTracker();
- return false ;
- }
- case MotionEvent.ACTION_CANCEL:
- releaseVelocityTracker();
- return false ;
- }
-
- return mIsDragging;
- }
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: - Scroll container
- Scroll to a specific position and fling when TouchUp
The logic of scrolling container: - private void moveTargetViewTo( int target) {
- target = Math. max (target, mTargetEndOffset);
- // Use offsetTopAndBottom to offset the view
- ViewCompat.offsetTopAndBottom(mTargetView, target - mTargetCurrentOffset);
- mTargetCurrentOffset = target;
-
- // Scroll the book cover view and position it according to TargetView
- int headerTarget;
- if (mTargetCurrentOffset >= mTargetInitOffset) {
- headerTarget = mHeaderInitOffset;
- } else if (mTargetCurrentOffset <= mTargetEndOffset) {
- headerTarget = mHeaderEndOffset;
- } else {
- float percent = (mTargetCurrentOffset - mTargetEndOffset) * 1.0f / mTargetInitOffset - mTargetEndOffset;
- headerTarget = ( int ) (mHeaderEndOffset + percent * (mHeaderInitOffset - mHeaderEndOffset));
- }
- ViewCompat.offsetTopAndBottom(mHeaderView, headerTarget - mHeaderCurrentOffset);
- mHeaderCurrentOffset = headerTarget;
- }
TouchUp scrolling logic: - private void finishDrag( int vy) {
- Log.i(TAG, "TouchUp: vy = " + vy);
- if (vy > 0) {
- // Trigger fling downwards, need to scroll to the Init position
- mNeedScrollToInitPos = true ;
- mScroller.fling(0, mTargetCurrentOffset, 0, vy,
- 0, 0, mTargetEndOffset, Integer .MAX_VALUE);
- invalidate();
- } else if (vy < 0) {
- // Trigger fling upwards, need to scroll to the End position
- mNeedScrollToEndPos = true ;
- mScroller.fling(0, mTargetCurrentOffset, 0, vy,
- 0, 0, mTargetEndOffset, Integer .MAX_VALUE);
- invalidate();
- } else {
- // No fling is triggered, the principle of proximity
- if (mTargetCurrentOffset <= (mTargetEndOffset + mTargetInitOffset) / 2) {
- mNeedScrollToEndPos = true ;
- } else {
- mNeedScrollToInitPos = true ;
- }
- invalidate();
- }
- }
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: - public interface NestedScrollingParent {
- // Whether to accept NestingScroll
- public boolean onStartNestedScroll( View child, View target, int nestedScrollAxes);
- //Accept NestingScroll's Hook
- public void onNestedScrollAccepted( View child, View target, int nestedScrollAxes);
- //NestingScroll ends
- public void onStopNestedScroll( View target);
- // 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.
- public void onNestedScroll( View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed);
- // 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.
- public void onNestedPreScroll( View target, int dx, int dy, int [] consumed);
- // When fling
- public boolean onNestedFling( View target, float velocityX, float velocityY, boolean consumed);
- // Before fling: the fling event can be consumed by the parent element
- public boolean onNestedPreFling( View target, float velocityX, float velocityY);
- // Get the scroll axis: x-axis or y-axis
- public int getNestedScrollAxes();
- }
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. |