嵌套滑动NestedScroll实现类新闻首页效果

最近在试着研究自定义View,之前一直对NestedScroll挺感兴趣的,这次试着做了做,做出了这样的效果,拿出来记录并分享下

device-2021-04-20-104913.2021-04-20 10_52_26.gif

考虑到实现方便以及后期的修改复用,我并没有完全使用NestedScroll来实现,而是在内层的嵌套滑动StickyNavLayout上包了一层RefreshLayout来做到一个下拉刷新的效果

先来看下内层嵌套滑动的实现,也就是StickyNavLayout:

StickyNavLayout继承了LinearLayout,因为系统默认已经在ViewParent中继承了NestedScrollingParent,而这次只需要使用到NestedScrollingParent中的方法,所以无需再次继承

基本布局

StickyNavLayout包含了三部分,最顶部是一个开启了嵌套滑动的ConstraintLayout,取名叫NestedConstraintLayout,中间是一个TabLayout,底部是一个ViewPager,ViewPager中是三个fragment,其中第一个fragment里是一个简单的RecyclerView,二三两个fragment同样使用了NestedConstraintLayout作为根布局

<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>
复制代码

注意:RecycleView默认实现了NestedScrollingChild,NestedScrollingChild2,NestedScrollingChild3接口,所以无需再次实现

NestedScroll的流程

首先,要将滑动事件传递到处理滑动嵌套滑动事件的viewGroup,也就是本例的StickyNavLayout中,需要先在传递滑动事件的view,也就是本例的NestedConstraintLayout,开启嵌套滑动才行

开启嵌套滑动

开启嵌套滑动流程

具体的实现代码,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()
                //此处只处理竖直方向的滑动
                startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL)
                addVelocityTracker(event)
            }
复制代码

StickyNavLayout:

override fun onStartNestedScroll(child: View?, target: View?, nestedScrollAxes: Int): Boolean {
return (nestedScrollAxes and ViewCompat.SCROLL_AXIS_VERTICAL) != 0 //垂直滑动时返回true
}
复制代码

处理scroll

开启嵌套滑动之后,接下来就是真正的开始传递滑动距离了,NestedConstraintLayout传递滑动距离及StickyNavLayout处理滑动距离的流程如下

image.png

NestedScrollingChild接收滑动事件后,先将滑动距离交给NestedScrollingParent,parent判断是否需要消耗,如果不消耗或者消耗之后还有多余,返还给child,child再次处理之后传递给parent,parent对剩余的滑动距离进行再处理

具体实现代码:

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)
    //比如说这里消耗了100
    //  scrollBy(0,100)
    //  dispatchNestedScroll(0,100,consumed[0]-0,consumed[1]-100)
}
复制代码

StickyNavLayout:

override fun onNestedPreScroll(target: View?, dx: Int, dy: Int, consumed: IntArray?) {
        super.onNestedPreScroll(target, dx, dy, consumed)
        scroller?.abortAnimation()
        if (dy < 0){
            //向下滑动
            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{
            //向上滑动
            if (scrollY <= maxMoveDistance){
                scrollBy(0, min(dy,maxMoveDistance - scrollY))
                consumed?.set(1,min(dy,maxMoveDistance - scrollY))
            }
        }
        requestLayout()
    }
复制代码

这里需要注意的是,maxMoveDistance指的是TabLayout距离顶部的距离,也就是NestedConstraintLayout的高度,向下滑动时,最多只能消耗当前的scrollY这么多的距离,多出的需要返回给子View,而向下滑动时,同样最多只能消耗maxMoveDistance的距离,多余的也同样需要返回给子View

处理fling

到此,一个简单的嵌套滑动就初步实现了,但是,光这样还不够。如果用户快速的去滑动当前控件,会发现界面出现卡顿,而没有像是预期中的流畅滑动控件。这里主要是因为还没有去实现fling操作导致的,下面来看一下,如何在NestedScroll中实现fling

同样的,先来看下流程图实现

image.png

可以看到fling的实现与scroll其实大同小异,同样先交给parent判断是否需要消耗,不消耗的话返还给child,child再次进行判断,不需要消耗的话再次交给parent

具体的代码实现:
NestedConstraintLayout:

MotionEvent.ACTION_UP ->{
    mVelocityTracker?.computeCurrentVelocity(1000)
    dispatchNestedPreFling(-(mVelocityTracker?.xVelocity?:0f),-(mVelocityTracker?.yVelocity?:0f))
    recycleVelocityTracker()
}
复制代码

StickyNavLayout:

 override fun onNestedPreFling(target: View?, velocityX: Float, velocityY: Float): Boolean {
        if (target is RecyclerView){//第一个item见不到的话,直接交给recycleview去执行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){
                //向下滑动
                scroller?.fling(0,scrollY,velocityX.toInt(),velocityY.toInt(),0,0,0,0)
                postOnAnimation(flingRunner) //每一帧去通知执行
            }else{
                //向上滑动
                scroller?.fling(0,scrollY,velocityX.toInt(),velocityY.toInt(),0,0,0,maxMoveDistance)
                postOnAnimation(flingRunner) //每一帧去通知执行
            }
        }
        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()
                }
            }
        }
    }
复制代码

这里需要注意的是,StickyNavLayout中先判断了当前recycleview的一个状态,如果recycleview有滑动过,直接就交给recycleview去处理滑动,parent不进行干预,而具体parent处理fling则是借用了OverScroller进行处理,给overScroller传递一个最大值,最小值这里不用考虑,由overScroller根据当前的速度去计算出每帧需要滑动的具体距离

还有一个需要注意的点,因为StickyNavLayout中,不管是处理滑动还是处理fling,都是使用的scrollBy去滑动,而滑动的时候,需要去重新调整当前布局,不然会导致viewPager底部出现空白的情况

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        maxMoveDistance = getChildAt(0).measuredHeight
        //这里viewPager的大小要根据滑动距离动态计算
        getChildAt(2).measure(widthMeasureSpec,MeasureSpec.makeMeasureSpec((measuredHeight - (maxMoveDistance + getChildAt(1).measuredHeight)) + scrollY,MeasureSpec.AT_MOST))
    }
复制代码

下拉刷新

到这里,嵌套滑动就处理完毕了,至于外层的下拉刷新,我这里并没有再外层再嵌套一层NestedScroll,而是用了onInterceptTouchEvent去拦截滑动事件

当子View,也就是StickyNavLayout向下滑动,且滑动到顶部时,进行拦截

override fun canScrollVertically(direction: Int): Boolean {
        Log.i(TAG, "RefreshLayout canScrollVertically: scrollY $scrollY")
        if (direction == -1 && scrollY > 0){
            return true
        }
        return false
    }
复制代码
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){
                        //说明是向下滑动
                        if (!view.canScrollVertically(-1)){
                            //子view不能再向下滑动了,拦截
                            Log.i(TAG, "onInterceptTouchEvent: intercept")
                            return true
                        }
                    }
                }
            }
        }
        return super.onInterceptTouchEvent(event)
    }
复制代码

并在手指抬起时,使用scroller根据当前滑动状态进行处理

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()
                }
            }
复制代码
private fun doScroll(){
        //注意dy不是最终的位置,而是相对于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)
            }
        }
    }
复制代码
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享