background

In the pages of e-commerce apps, we often meet such requirements: some operation bits at the top + recommendation flow of TAB at the bottom, and TAB can be top (for example, the homepage of JINGdong), such as the structure of dewu Channel page

In order to realize such a structure, we naturally thought of using CoordinatorLayout+AppBarLayout+ViewPager. In general, there is no problem with this method, but in the process of business anyway, the structure is gradually not enough and there are some defects, mainly including the following points:

  1. If the content in the header is long, the View inside AppBarLayout will always be loaded into memory, which will affect page performance
  2. The top part is more troublesome to replace, because the top part is fixed in the layout, not conducive to expansion
  3. The sliding up and down is not consistent. The top part and AppBarLayout are separated, so the rolling state will be lost in the sliding up and down process
  4. The scrolling state cannot be monitored correctly when sliding up and down

Introduction to Use

Github address: github.com/ToryCrox/Ne…

The basic use

  1. Define the parent layout of NestedParentRecyclerView, use the same method and common RecyclerView
<com.tory.nestedceiling.widget.NestedParentRecyclerView
    android:id="@+id/nested_rv"
    android:layout_width="match_parent"
    android:layout_height="match_parent"/>
Copy the code
  1. Set child View, nested internal RecyclerView must use NestedChildRecyclerView, such as this child View is ViewPager+Fragment structure
override fun onCreateView(inflater: LayoutInflater, 
    container: ViewGroup? , savedInstanceState:Bundle?).: View? {
    val recyclerView = NestedChildRecyclerView(requireContext())
    recyclerView.id = R.id.recyclerView
    return recyclerView
}
Copy the code
  1. To configure the child View container, here refers to the direct child View of NestedParentRecyclerView, which is used to mark which child View can be nested sliding, requires the following two key steps:

    1. Set the width and height of the container to MATCH_PARENT
    2. Call NestedCeilingHelper. SetNestedChildContainerTag (view)

class LastViewPager2ItemView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : AbsModuleView<LastViewPager2Model>(context, attrs) {

    val tabLayout = findViewById<TabLayout>(R.id.tab_layout)
    val viewPager = findViewById<ViewPager2>(R.id.view_pager)

    init {
        layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
        NestedCeilingHelper.setNestedChildContainerTag(this)}}Copy the code

Configure the top suction distance

Sometimes the nested View does not occupy the height of the parent View and is some distance from the top, especially if the title bar is transparent:

  1. NestedChildRecyclerView set topOffset
toolbar.doOnPreDraw {

 Log.d("TransparentToolbar", "topOffset " + toolbar.measuredHeight)

    recyclerView.topOffset = toolbar.measuredHeight

 }
Copy the code
  1. View rewrite onMeasure, you need to call NestedCeilingHelper. WrapContainerMeasureHeight


override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {

    super.onMeasure(widthMeasureSpec, NestedCeilingHelper.wrapContainerMeasureHeight(this, heightMeasureSpec))

}
Copy the code

State of preservation

If a sub-view uses a Fragment, we need to pay special attention to the Fragment state preservation and recovery, which will lead to leakage. We need to deal with the process of state preservation and recovery by ourselves

  1. SavedStateRegistry Registers save events
  2. Called at bindViewsavedStateRegistry. ConsumeRestoredStateForKey consumed saved content

init {
    registerSaveState()
}


fun onBind(a) {
    consumeRestoreState()
}


 /** * register to save the state, as long as it is guaranteed to be unique, in View init with */

fun registerSaveState(a) {
    val viewName = this.javaClass.simpleName
 requireActivity().savedStateRegistry.registerSavedStateProvider("save_state_item_$viewName") {
 val bundle = Bundle()
        val container = SparseArray<Parcelable>()
        bundle.putSparseParcelableArray("save_state_item_view_$viewName", container)
        saveHierarchyState(container)
        bundle
    }
}



 /** * Consumption State */

fun consumeRestoreState(a) {
    val activity = requireActivity()
    if(! activity.savedStateRegistry.isRestored) {if (ChannelHelper.DEBUG) {
            throw IllegalStateException("ConsumeRestoreState must be called after restore, recommended in onBind")}return
    }

    val viewName = this.javaClass.simpleName
    val bundle = requireActivity().savedStateRegistry.consumeRestoredStateForKey("save_state_item_$viewName") ?: return
    val savedState = bundle.getSparseParcelableArray<Parcelable>("save_state_item_view_$viewName")
    if(savedState ! =null) {
        restoreHierarchyState(savedState)
    }
}
Copy the code

Introduction to the principle of nested rolling

Nested scrolling we generally use NestedScrollingParent and NestedScrollingChild, which is essentially the process by which the Child passes the sliding state to the Parent.

NestedScrollingChild NestedScrollingParent instructions
startNestedScroll onStartNestedScroll childThe call to theparentThe callback,onStartNestedScrollThe return value determines whether subsequent nested slide events are passed toparentTo deal with
onNestedScrollAccepted ifonStartNestedScrollReturns true, this method is called back.
dispatchNestedPreScroll onNestedPreScroll childPre-slip triggerparentThe callback,parentDecide whether to slide or not according to its condition, if consumed, the consumed value is passed backchild
scrollBy childIf you scroll, you have to subtract the cost of the previous step
dispatchNestedScroll onNestedScroll childTrigger after scrollparentThe callback,parentTo receivechildThe unconsumed rolling distance determines whether to slide according to its own situation. Generally, if the consumed distance is not 0, the child cannot slide
dispatchNestedPreFling onNestedPreFling childFling before triggerparentThe callback
dispatchNestedFling onNestedFling
stopNestedScroll onStopNestedScroll
getNestedScrollAxes Gets the slide direction. This method is invoked actively

instructions

  1. The nested scroll Api has been iterated over several versions, currently two and three
  2. NestedScrollingParent must be implemented to receive nested sliding parent views, while child views are not required, but recommendedNestedScrollingChildAnd the use ofNestedScrollingChildHelperTo pass events
  3. NestedScrollingParent and NestedScrollingChild do not necessarily have a direct parent-child View relationship, because NestedScrollingChild will look up the Parent level
  4. OnNestedPreScroll -> onNestedScroll is called back to a Child while it is in a Fling.type==ViewCompat.``TYPE_TOUCHSo touch scroll,type==ViewCompat.``TYPE_NON_TOUCHThis represents a scroll
  5. RecyclerView only implements NestedScrollingChild, not NestedScrollingParent, which results in RecyclerView can only be used as a Child in nested scrolling process, but not as a Parent

Nesting problem handling

Look for nested subViews

Although the Child will pass the scrolling state in the nested scrolling process, the parent RecyclerView will directly intercept the touch event of the Child view in sliding, so the nested Child View needs to be identified by tags. Here we use Tag to mark the view

view.setTag(R.id.nested_child_item_container, Boolean.TRUE);
Copy the code

The nested View is recognized in onChildAttachedToWindow of the parent View’s NestedParentRecyclerView


@Override
public void onChildAttachedToWindow(@NonNull View child) {
    if(isTargetContainer(child)) { mContentView = (ViewGroup) child; ViewGroup.LayoutParams lp = child.getLayoutParams(); }}@Override
public void onChildDetachedFromWindow(@NonNull View child) {
    if (child == mContentView) {
        mContentView = null;
        log("onChildDetachedFromWindow...."); }}Copy the code

However, this is not enough, because NestedScrollingChild is usually not a direct child of NestedScrollingParent. NestedScrollingChild must be NestedChildRecyclerView to facilitate subsequent scrolling


public static NestedChildRecyclerView findChildScrollTarget(@Nullable View sourceView) {
    if(sourceView == null|| sourceView.getVisibility() ! = View.VISIBLE) {return null;
    }

    if (sourceView instanceof NestedChildRecyclerView) {
        return (NestedChildRecyclerView) sourceView;
    }

    if(! (sourceViewinstanceof ViewGroup)) {
        return null;
    }

    ViewGroup contentView = (ViewGroup) sourceView;
    int childCount = contentView.getChildCount();

    for (int i = childCount - 1; i >= 0; i--) {
        View view = contentView.getChildAt(i);
        int centerX = (view.getLeft() + view.getRight()) / 2;
        int contentLeft = contentView.getScrollX();
        if (centerX <= contentLeft || centerX >= contentLeft + contentView.getWidth()) {
            continue;
        }

        NestedChildRecyclerView target = findChildScrollTarget(view);
        if(target ! =null) {returntarget; }}return null;

}
Copy the code
  • In ViewPager, NestedChildRecyclerView may not be the first Child of the ViewPager, so you need to calculate whether the View is in the Parent’s viewable area

Child View touch event interception

Nested scrolling to solve the first problem is to make the child View can touch sliding, need a parent RecyclerView can intercept touch to the child View events

// NestedParentRecyclerView.java
@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
    booleanisTouchInChildArea = (mContentView ! =null) && (e.getY() > mContentView.getTop()) && (e.getY() < mContentView.getBottom()) && FindTarget.findChildScrollTarget(mContentView) ! =null;
    // This control slides to the bottom or touches the region in a sub-nested layout without intercepting events
    if (isTouchInChildArea) {
        if (getScrollState() == SCROLL_STATE_SETTLING) {
            // Stop while you are trying to fling
           stopScroll();
        }
        return false;
    }
    return super.onInterceptTouchEvent(e);
}
Copy the code

If the TOUCH_DOWN event does not fall on the child view at the beginning, the parent view starts to control the sliding. During the sliding process, the finger falls on the child view. At this time, because the child view does not take over the sliding event, the official sliding event is interrupted. The experience is not good, you have this problem in CoordinateLayout+AppBarLayout+ViewPager+RecyclerView layout structure: Sliding from the head, in the case of constant touch, sliding to RecyclerView, the whole sliding is interrupted, RecyclerView can not continue to slide.

Since the child view cannot take over touch events, the parent view can control the scrolling events of NestedChildRecyclerView

// NestedParentRecyclerView.java

public boolean onTouchEvent(MotionEvent e) {
    final int action = e.getActionMasked();
    switch (action) {
        case MotionEvent.ACTION_DOWN:
            mLastY = e.getY();
            mActivePointerId = e.getPointerId(0);
            mVelocityY = 0;
            stopScroll();
            break;
        case MotionEvent.ACTION_POINTER_DOWN:
            final int index = e.getActionIndex();
            mLastY = e.getY(index);
            mActivePointerId = e.getPointerId(index);
            break;
        case MotionEvent.ACTION_POINTER_UP:
            onSecondaryPointerUp(e);
            break;
        case MotionEvent.ACTION_MOVE:
            final int activePointerIndex = e.findPointerIndex(mActivePointerId);
            if (activePointerIndex == -1) {
                log("Invalid pointerId=" + mActivePointerId + " in onTouchEvent");
                break;
            }
            final float y = e.getY(activePointerIndex);
            if (isScrollEnd()) {
                // If the control has been slid to the bottom, let the subnested layout slide the remaining distance
                // If the subnested layout is not down to the top, you need to slide the subnested layout some distance first
                NestedChildRecyclerView child = FindTarget.findChildScrollTarget(mContentView);
                if(child ! =null) {
                    int deltaY = (int) (mLastY - y);
                    mTempConsumed[1] = 0;
                    child.doScrollConsumed(0, deltaY, mTempConsumed);
                    int consumedY = mTempConsumed[1];
                    if(consumedY ! =0 && NestedCeilingHelper.DEBUG) {
                        log("onTouch scroll consumed: " + consumedY);
                    }
                }
            }
            mLastY = y;
            break;
    }

    return super.onTouchEvent(e);

}
Copy the code
  • The doScrollConsumed method is the method used to directly scroll RecyclerView, which I’ll talk about separately

The child View passes the scroll event

After looking at the principles of nested scrolling, we know that the most important thing to implement nested scrolling is to implement two methods

  • OnNestedPreScroll: Before the child view scrolls

In the nested child RecyclerView model, if the parent RecyclerView can still scroll, you need to implement the method and pass the consumed distance back to the Consumed array



@Override

public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
    // If you slide up and the control does not slide to the bottom, you need to keep the control sliding for consistency
    boolean needKeepScroll = dy > 0 && !isScrollEnd();
    if (needKeepScroll) {
        mTempConsumed[1] = 0;
        doScrollConsumed(0, dy, mTempConsumed);
        consumed[1] = mTempConsumed[1]; }}Copy the code
  • OnNestedScroll: After a child View is scrolled

First of all, if the dyUnconsumed parameter returned by this method is not 0, it means that the nested child View can no longer scroll, and then the parent View can continue to scroll

@Override public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type, @NonNull int[] consumed) { onNestedScrollInternal(target, dyUnconsumed, type, consumed); } /** * dyUnconsumed ! So if = 0, the nested child view, that means the nested child view can't slide, that means it's at the top, greater than 0 means it's sliding, < 0 paddles * @param target * @param dyUnconsumed * @param type * @param consumed */ private void onNestedScrollInternal(@NonNull View target, int dyUnconsumed, int type, @NonNull int[] consumed) { if (dyUnconsumed == 0) { return; } mTempConsumed[1] = 0; doScrollConsumed(0, dyUnconsumed, mTempConsumed); int consumedY = mTempConsumed[1]; consumed[1] += consumedY; final int myUnconsumedY = dyUnconsumed - consumedY; dispatchNestedScroll(0, consumedY, 0, myUnconsumedY, null, type, consumed); }Copy the code

RecyclerView internal rolling state access

The process of the above rolling state, need to directly control RecyclerView rolling, scrollBy method is limited it can not obtain the actual consumption after rolling how much, by looking at the source code found RecyclerView is through scrollStep method for real rolling, Create a custom RecyclerView and set its package name to the same as RecyclerView. Create a custom RecyclerView and set its package name to the same as RecyclerView.

// NestedPublicRecyclerView

public class NestedPublicRecyclerView extends RecyclerView {

    @Override
    void scrollStep(int dx, int dy, @Nullable int[] consumed) {
        super.scrollStep(dx, dy, consumed);
    }

    public void doScrollConsumed(int dx, int dy, @NonNull int[] consumed) {
        consumed[0] = 0;
        consumed[1] = 1;
        scrollStep(dx, dy, consumed);
        int consumedX = consumed[0];
        int consumedY = consumed[1];
        if(consumedX ! =0|| consumedY ! =0) {
            // Distribute the scroll statedispatchOnScrolled(consumedX, consumedY); }}}Copy the code

Fling state processing

In order to make the whole scrolling process more coherent, we need to transfer the Fling state between the nested recyclerViews. However, the Fling state is also not exposed, so we need to expose it


/** * Get speed information */
@Nullable
public OverScroller getFlingOverScroll(a) {
    return mViewFlinger.mOverScroller;
}


 /** * Callback to the edge@param velocityX
 *  @param velocityY
 */

@Override
void absorbGlows(int velocityX, int velocityY) {
    //super.absorbGlows(velocityX, velocityY);
    onFlingEnd(velocityX, velocityY);
}

 /** * Callback to the edge@param velocityX
 *  @param velocityY
 */
protected void onFlingEnd(int velocityX, int velocityY) {}Copy the code

Fing is divided into two states to deal with

  1. NestedParentRecyclerView downward Fling, rolling to the edge of the transfer to NestedChildRecyclerView, because we rewrite the RecyclerView absorbGlows method, so the realization is very simple

@Override
protected void onFlingEnd(int velocityX, int velocityY) {
    super.onFlingEnd(velocityX, velocityY);
    if (velocityY > 0 && NestedCeilingHelper.USE_OVER_SCROLL) {
        // Use OverScroll to convey the scroll state
        RecyclerView child = FindTarget.findChildScrollTarget(mContentView);
        if(child ! =null) {
            if (NestedCeilingHelper.DEBUG) {
                log("onFlingEnd fling child velocityY: " + velocityY);
            }
            child.fling(0, velocityY); }}}Copy the code
  1. NestedChildRecyclerView in the process of upward Fling, touch the edge, need to transfer the rolling state of the Fling to the NestedParentRecyclerView to continue the Fling. Although the View is in an onNestedPreFling state, NestedChildRecyclerView may or may not Fling. So there are two cases:
  • When the Fling is not in place, NestedChildRecyclerView cannot continue to Fling. In this case, Fing event is directly assigned to NestedParentRecyclerView

@Override
public boolean onNestedFling(
        @NonNull View target, float velocityX, float velocityY, boolean consumed) {
    if(! consumed) { dispatchNestedFling(0, velocityY, true);
        fling(0, (int) velocityY);
        return true;
    }
    return false;

}
Copy the code
  • In the Fling process, NestedChildRecyclerView touches the edge and then transfers the Fling state to NestedParentRecyclerView. At this time, I use onNestedScroll to judge the Fling state. Then take over to stop NestedChildRecyclerView

private void onNestedScrollInternal(@NonNull View target, int dyUnconsumed, int type, @NonNull int[] consumed) {
     // omit part
    // dyUnconsumed is sliding if it is greater than 0, and is paddling if it is less than 0. Type is TYPE_NON_TOUCH, which indicates the Fling state
    if (dyUnconsumed < 0 && type == ViewCompat.TYPE_NON_TOUCH && target instanceof NestedChildRecyclerView) {
        NestedChildRecyclerView nestedView = (NestedChildRecyclerView) target;
        if(nestedView ! = FindTarget.findChildScrollTarget(mContentView)) { log("onNestedScrollInternal nestedView is changed, return");
            return;
        }

        OverScroller overScroller = nestedView.getFlingOverScroll();
        if (overScroller == null) {
            return;
        }

        float absVelocity = overScroller.getCurrVelocity();
        // nestedView.stopScroll();
        // Stop without updating the state because the state of the child view is updated in onStateChanged while fling
        nestedView.stopScrollWithoutState();
        float myVelocity = absVelocity * -1;
        fling(0, Math.round(myVelocity));

        if (NestedCeilingHelper.DEBUG) {
            log("onNestedScrollInternal start fling from child, absVelocity:" + absVelocity + ", myVelocity:"+ myVelocity); }}}Copy the code

conclusion

Overall implementation is a reference to the project github.com/solartcc/Ne… However, after clone was completed, many problems were found and it could not be used as a commercial app, so it was implemented by itself. In the process of realization also gradually deepen the understanding of the principle of NestedScrolling, clear up the various states of nested RecyclerView, can achieve a perfect nested RecyclerView structure, it should…

reference

  • Perhaps the most close to jingdong home page experience nested sliding roof effect Solartisan
  • Basic analysis of NestedScrolling nested sliding mechanism