Recently, I have been trying to study custom View, and I have been very interested in NestedScroll before. This time, I have tried to make it and made such effect, so I will record it and share it with you

Considering the convenience of implementation and the reuse of modification in the later stage, I did not fully use NestedScroll to achieve the implementation, but wrapped a layer of RefreshLayout on the inner nested slide StickyNavLayout to achieve a drop-down refresh effect

Let’s take a look at the implementation of inline sliding, StickyNavLayout:

StickyNavLayout inherits the LinearLayout, because the system already inherits the NestedScrollingParent in ViewParent by default, and this time you only need to use the method in NestedScrollingParent, so you don’t need to inherit it again

Basic layout

“StickyNavLayout” consists of three parts. At the top is a ConstraintLayout with nested slides on, NestedConstraintLayout, a TabLayout in the middle, and a ViewPager at the bottom. There are three fragments in ViewPager. The first fragment is a simple RecyclerView. The second and third fragments also use NestedConstraintLayout as the root

<com.hsmedia.uidemo.StickyNavLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

            <com.hsmedia.uidemo.NestedConstraintLayout
                android:layout_width="match_parent"
                android:layout_height="200dp">

                <ImageView
                    android:layout_width="50dp"
                    android:layout_height="50dp"
                    android:src="@drawable/signure"
                    />

            </com.hsmedia.uidemo.NestedConstraintLayout>

            <com.google.android.material.tabs.TabLayout
                android:id="@+id/tab_layout"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                app:tabIndicatorColor="#4884e3"
                app:tabTextColor="# 888888"
                app:tabSelectedTextColor="# 333333"
                app:tabMode="fixed"
                app:tabIndicatorFullWidth="false"
                android:background="@color/white"
                />

            <androidx.viewpager.widget.ViewPager
                android:id="@+id/vp"
                android:layout_width="match_parent"
                android:layout_height="match_parent"/>

        </com.hsmedia.uidemo.StickyNavLayout>
Copy the code

Note: RecycleView implements NestedScrollingChild, NestedScrollingChild2, NestedScrollingChild3 interfaces by default, so there is no need to implement it again

NestedScroll process

First, to pass slides to the viewGroup that handles slides nested slides, which is StickyNavLayout in this case, you need to enable nested slides in the view that is passing slides, which is NestedConstraintLayout in this case

Enable nesting sliding

NestedConstraintLayout:

init {
        isNestedScrollingEnabled = true} override fun onTouchEvent(event: MotionEvent?) :Boolean {
        when(event? .action){
            MotionEvent.ACTION_DOWN->{
                downX = (event.x + 0.5f).toInt()
                downY = (event.y + 0.5f).toInt()
                // Only vertical sliding is handled here
                startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL)
                addVelocityTracker(event)
            }
Copy the code

StickyNavLayout:

override fun onStartNestedScroll(child: View? , target: View? , nestedScrollAxes: Int):Boolean {
return(nestedScrollAxes and ViewCompat.SCROLL_AXIS_VERTICAL) ! =0 // Returns true when sliding vertically
}
Copy the code

To deal with insensitive

Once nested slides are enabled, it’s time to actually transfer the slide distance. NestedConstraintLayout transfers the slide distance and StickyNavLayout handles the slide distance as follows

NestedScrollingChild receive sliding, sliding distance to NestedScrollingParent first, the parent determine whether need to consume, if you don’t have spare after consumption or use, return it to the child, the child again after processing is passed to the parent, Parent reprocesses the remaining sliding distance

Specific implementation code:

NestedConstraintLayout:

MotionEvent.ACTION_MOVE->{
    val x = (event.x + 0.5f)
    val y = (event.y+ 0.5f)
    val dx = downX - x
    val dy = downY - y
    Log.i(TAG, "onTouchEvent: dx:$dx dy:$dy")
    addVelocityTracker(event)
    dispatchNestedPreScroll(dx.toInt(),dy.toInt(),consumed,null)
    // Let's say I consumed 100 here
    / / scrollBy (0100).
    / / dispatchNestedScroll (0100, consumed [0] - 0, consumed [1] - 100)
}
Copy the code

StickyNavLayout:

override fun onNestedPreScroll(target: View? , dx: Int, dy: Int, consumed: IntArray?) {
        super.onNestedPreScroll(target, dx, dy, consumed) scroller? .abortAnimation()if (dy < 0) {// Slide down
            if (scrollY > 0&& target? .canScrollVertically(-1) = =false) {if (abs(dy) < scrollY){
                    scrollBy(0, dy) consumed? .set(1,dy)
                }else{
                    scrollBy(0, -scrollY) consumed? .set(1,scrollY)
                }
            }
            Log.i(TAG, "onNestedPreScroll: dy: $dy scrollY :$scrollY")}else{
            // Slide up
            if (scrollY <= maxMoveDistance){
                scrollBy(0, min(dy,maxMoveDistance - scrollY)) consumed? .set(1,min(dy,maxMoveDistance - scrollY))
            }
        }
        requestLayout()
    }
Copy the code

It is important to note that maxMoveDistance refers to the distance between TabLayout and the top, the height of NestedConstraintLayout. Sliding down can only consume as much distance as the current scrollY. The excess needs to be returned to the child View, and sliding down will consume at most the maxMoveDistance, and the excess needs to be returned to the child View

Processing fling

At this point, a simple nested slide has been implemented, but that alone is not enough. If the user quickly swipes the current control, the interface freezes, not as smoothly as expected. This is because the fling operation has not been implemented yet. How to create a NestedScroll fling

Again, let’s look at the flowchart implementation

It can be seen that the implementation of fling and scroll are basically the same. The parent decides whether it needs to spend and returns it to the child if it does not. The child judges again and returns it to the parent if it does not need to spend

NestedConstraintLayout:

MotionEvent.ACTION_UP ->{ mVelocityTracker? .computeCurrentVelocity(1000) dispatchNestedPreFling(-(mVelocityTracker? .xVelocity? :0f),-(mVelocityTracker? .yVelocity? :0f)) recycleVelocityTracker() }Copy the code

StickyNavLayout:

override fun onNestedPreFling(target: View? , velocityX: Float,velocityY: Float): Boolean {
        if (target is RecyclerView){// If the first item is not present, use RecycleView to execute the fling
            val linearManager : LinearLayoutManager = target.layoutManager as LinearLayoutManager
            val isFirstItemVisible = linearManager.findFirstVisibleItemPosition() == 0
            if(! isFirstItemVisible)return false
        }
        if(abs(velocityY) > mMinFlingVelocity){ scroller? .abortAnimation()if (velocityY < 0) {// Slide downscroller? .fling(0,scrollY,velocityX.toInt(),velocityY.toInt(),0.0.0.0)
                postOnAnimation(flingRunner) // Each frame goes to the notification execution
            }else{
                // Slide upscroller? .fling(0,scrollY,velocityX.toInt(),velocityY.toInt(),0.0.0,maxMoveDistance)
                postOnAnimation(flingRunner) // Each frame goes to the notification execution
            }
        }
        Log.i(TAG, "onNestedPreFling: scrollY ${scrollY >= maxMoveDistance}")
        return scrollY < maxMoveDistance
    }

    private inner class MyFlingRunner : Runnable{
        override fun run() {
            if(scroller? .computeScrollOffset() ==true){ val currentY = scroller? .currY? :0
                Log.i(TAG, "onNestedPreFling: currentY $currentY scrollY : $scrollY")
                val distance = currentY - scrollY
                Log.i(TAG, "onNestedPreFling: distance : $distance")
                if (distance <= maxMoveDistance && distance >=-maxMoveDistance){
                    scrollBy(0,distance)
                    postOnAnimation(this)
                    requestLayout()
                }
            }
        }
    }
Copy the code

Note that StickyNavLayout determines the current state of recycleView. If the RecycleView slides, it will be directly handed over to recycleView to process the sliding. The parent does not intervene. The parent uses the OverScroller to process and transfer a maximum value to the OverScroller. The OverScroller calculates the specific sliding distance of each frame according to the current speed

One more thing to note about StickyNavLayout is that it uses scrollBy to slide and to hold. When you slide, you need to rearrange the layout. Otherwise, the bottom of the viewPager will be blank

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        maxMoveDistance = getChildAt(0).measuredHeight
        // The size of the viewPager is dynamically calculated based on the sliding distance
        getChildAt(2).measure(widthMeasureSpec,MeasureSpec.makeMeasureSpec((measuredHeight - (maxMoveDistance + getChildAt(1).measuredHeight)) + scrollY,MeasureSpec.AT_MOST))
    }
Copy the code

The drop-down refresh

In this case, the nested slide is finished. For the drop down refresh of the outer layer, I don’t nest NestedScroll on the outer layer, but use onInterceptTouchEvent to intercept the slide event

And when the child View, StickyNavLayout slides down, and it slides to the top, it blocks it

override fun canScrollVertically(direction: Int): Boolean {
        Log.i(TAG, "RefreshLayout canScrollVertically: scrollY $scrollY")
        if (direction == -1 && scrollY > 0) {return true
        }
        return false
    }
Copy the code
override fun onInterceptTouchEvent(event: MotionEvent?) :Boolean {
        val view = getChildAt(1)
        when(event? .action){
            MotionEvent.ACTION_DOWN->{
                downX = (event.x + 0.5f).toInt()
                downY = (event.y + 0.5f).toInt()
                mScrollY = scrollY
                Log.i(TAG, "onInterceptTouchEvent: downX $downY")
            }
            MotionEvent.ACTION_MOVE->{
                val x = (event.x + 0.5f)
                val y = (event.y+ 0.5f)
                val dx = downX - x
                val dy = downY - y
                Log.i(TAG, "onInterceptTouchEvent: downY $downY y $y dy $dy")
                if (abs(dy) >= minTouchSlop){
                    if (dy < 0) {// Slide down
                        if(! view.canScrollVertically(-1)) {// Subview can no longer slide down, block
                            Log.i(TAG, "onInterceptTouchEvent: intercept")
                            return true
                        }
                    }
                }
            }
        }
        return super.onInterceptTouchEvent(event)
    }
Copy the code

And when the finger is lifted, scroller is used to process according to the current sliding state

MotionEvent.ACTION_UP->{
                Log.i(TAG, "onTouchEvent: up moveY $moveY")
                if (moveY == -topHeight.toFloat()){
                    if (job == null){
                        job = MainScope().launch {
                            delay(1000)
                            doScroll()
                            moveY = 0f
                            job = null}}}else{
                    doScroll()
                }
            }
Copy the code
private fun doScroll(){
        // Note that dy is not the final position, but the offset position relative to start
        scroller.startScroll(0,moveY.toInt(),0.0 - moveY.toInt(),300)
        postOnAnimation(MyFlingRunner())
    }

    private inner class MyFlingRunner : Runnable{
        override fun run() {
            if (scroller.computeScrollOffset()){
                scrollTo(scroller.currX,scroller.currY)
                invalidate()
                postOnAnimation(this)}}}Copy the code