自定义NestedScrollingParent和使用CoordinatorLayout并自定义Behavior两种方式实现一个UI交互界面

本文是为初接触NestedScrolling但是对其运行机制有所了解的朋友准备的,大神可自行绕过。
NestedScrolling机制问世这么久,网上对于嵌套滑动的介绍数不胜数,对其运行机制不熟悉的可以参考Android NestedScrolling全面解析 - 带你实现一个支持嵌套滑动的下拉刷新(上篇)或者嵌套滚动利器–NestedScrolling机制这两篇文章。


特别注意:本文内容较多,请耐心阅读,相信你一定会有所收获

本文实现效果预览

最终效果图
由于gif上传有大小要求所以对其进行了压缩。为了更好的理解最终效果,下面简单说一下效果。

初始状态时,Tab栏的中心线应与红色背景图的底部边缘重合。当我们滑动RecyclerView的时候,Tab栏和RecyclerView会一起上滑知道Tab栏顶端与标题栏底端重叠(到此时再往上滑,RecyclerView就会进行滑动)。当整体上滑到最顶端时往下滑,会优先滑动RecyclerView,当RecyclerView不能向下滑动的时候,Tab栏和RecyclerView将会整体下滑,直到初始状态,结束。

分析此过程:

  1. Tab栏(以下称NavView,实际是RadioGroup)会跟随RecyclerView上滑或下滑,且宽度会发生变化。可滑动的条件有两种:

    	- 上滑阶段,NavView未滑动到最顶端
    	- 下滑阶段,NavView未滑动到最低端且RecyclerView不能向下滑动
    
  2. 红色背景图(以下称TopView,实际是ConstraintLayout)会随着RecyclerView或者NavView改变透明度,当NavView到达最顶端,TopView完全透明,当NavView到达最低端,TopView完全不透明。

  3. 标题栏(以下称TitleView,实际是LinearLayout)会随着RecyclerView或者NavView渐变背景透明度和文字颜色。当NavView到达最顶端,TitleView完全不透明,当NavView到达最低端,TitleView完全透明,当NavView滑动到一半时,TitleView的文字颜色改变,在一半位置以上时为蓝色,一半位置以下时为白色,TitleView中相应的返回箭头也会改变颜色。

效果实现方法分析:

  1. 由于整个滑动过程中事件分发无中断,按照以往方式来说,只有依靠手动分发事件来实现,实现起来难度较大,逻辑较多。
  2. 使用NestedScrolling机制,由于RecyclerView已经实现了NestedScrollingChild2接口,那么我们只需要自定义一个ViewGroup实现NestedScrollingParent2接口即可实现。
  3. 使用CoordinatorLayout并自定义Behavior来实现。

第2、3中方法难度都不是很大,下面就来使用两种方法进行实现。

使用NestedScrolling机制实现效果

自定义TitleStickyNavLayout

  1. 定义一个TitleStickyNavLayout继承自FrameLayout并实现NestedScrollingParent2。
  2. 添加构造方法
    constructor(context: Context) : this(context, null)
    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) 
  1. 定义几个变量。
    private var mTitleView: View? = null   
    private var mTopView: View? = null
    private var mNavView: View? = null
    private var mRecyclerView: RecyclerView? = null

然后得有变量来记录几个View的高度。

    private var mTitleViewHeight = 0
    private var mTopViewHeight = 0
    private var mNavViewHeight = 0
  1. 有了上面的变量就得在适当时机为其赋值。
    几个View的赋值我们应该在onFinishInflate()方法中进行。
    override fun onFinishInflate() {
        super.onFinishInflate()
        mTitleView = findViewById(R.id.title_view)
        mTopView = findViewById(R.id.id_titleStickyNavLayout_topView)
        mNavView = findViewById(R.id.id_titleStickyNavLayout_NavView)
        val view =
            findViewById<View>(R.id.id_titleStickyNavLayout_recyclerView) as? RecyclerView ?: throw RuntimeException(
                "id_titleStickyNavLayout_recyclerView show used by RecyclerView !"
            )
        mRecyclerView = view
    }

测量View的高度我们可以放在onSizeChanged()方法中。

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)

        mTitleViewHeight = mTitleView!!.measuredHeight
        mNavViewHeight = mNavView!!.measuredHeight
        mTopViewHeight = mTopView!!.measuredHeight + mNavViewHeight / 2
    }
  1. 测量子View并且设置TitleStickyNavLayout的大小。
    总宽度就是屏幕宽度,总高度=TitleView的高度+ NavView的高度 + RecyclerView的高度 + 三者纵向上的Margin之和。
    需要注意的是,由于NavView上滑到最顶端时,屏幕纵向只有TitleView、NavView、RecyclerView,且RecyclerView的高度占满屏幕剩余空间,因此需要在测量TitleStickyNavLayout时动态设置RecyclerView的高度。
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)

        mTopView?.measure(widthMeasureSpec, mHeightMeasureSpec) //不限制TopView高度
        mNavView?.measure(widthMeasureSpec, mHeightMeasureSpec) //不限制NavView高度

        var totalHeight = mTopView!!.measuredHeight + mNavView!!.measuredHeight / 2 + mRecyclerView!!.measuredHeight
        totalHeight += getViewMarginVertical(mTopView!!)
        totalHeight += getViewMarginVertical(mNavView!!)
        totalHeight += getViewMarginVertical(mRecyclerView!!)

        val params = mRecyclerView!!.layoutParams
        params.height =
            measuredHeight - mTitleView!!.measuredHeight - mNavView!!.measuredHeight -
                    getViewMarginVertical(mNavView!!) - getViewMarginVertical(mTopView!!) -
                    getViewMarginVertical(mRecyclerView!!)

        setMeasuredDimension(
            measuredWidth,
            totalHeight
        )
    }
    private fun getViewMarginVertical(view: View): Int {
        val margins = getViewMargin(view)
        return margins[1] + margins[3]
    }
    private fun getViewMargin(view: View): IntArray {
        val margins = IntArray(4)
        val params = view.layoutParams as LayoutParams
        margins[0] = params.marginStart
        margins[1] = params.topMargin
        margins[2] = params.marginEnd
        margins[3] = params.bottomMargin
        return margins
    }
  1. 布局子View。
    原则是:先布局TopView,根据TopView的高度布局NavView,根据NavView的bottom布局RecyclerView。最后布局TitleView,因为TitleView不能被任何View挡住。
    由于NavView的宽度会跟随变化,所以定义一个变量mNavViewMargin来保存最新的margin值,初始状态下mNavViewMargin的值为20dp。
    由于NavView和RecyclerView的位置会发生变化,所以定义一个变量mNavViewTranslateY来保存最新位移距离,初始状态下mNavViewTranslateY的值为0,到达最顶端时mNavViewTranslateY的值为最大,动态计算该值,计算方法稍后介绍。
    由于TitleView的背景色和TopView的透明度都需要同步改变,所以需要一个变量mTranslateRate来保存当前已经滑动的距离与可滑动的最大距离的比率,该值取[0f ,1.0f]范围的值。
    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        Log.e(TAG, "onLayout")
        mTopView!!.layout(0, 0, mTopView!!.measuredWidth, mTopViewHeight)
        mNavView!!.layout(
            (measuredWidth - mNavView!!.measuredWidth) / 2 + mNavViewMargin,
            mTopViewHeight - mNavViewHeight / 2 -mNavViewTranslateY,   //注意 mNavViewTranslateY的值恒为非负数
            (measuredWidth + mNavView!!.measuredWidth) / 2 - mNavViewMargin,
            mTopViewHeight + mNavViewHeight / 2-mNavViewTranslateY
        )

        val recyclerParams = mRecyclerView!!.layoutParams as LayoutParams
        mRecyclerView!!.layout(
            recyclerParams.marginStart,
            mNavView!!.bottom + recyclerParams.topMargin,
            recyclerParams.marginStart + mRecyclerView!!.measuredWidth,
            mNavView!!.bottom + recyclerParams.topMargin + mRecyclerView!!.measuredHeight
        )

        mTitleView!!.layout(0, 0, mTitleView!!.measuredWidth, mTitleView!!.measuredHeight)
    }
  1. 下面就该来处理嵌套滑动了。我们需要实现NestedScrollingParent2要求的五个方法:
    fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int)

    fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean

    fun onStopNestedScroll(target: View, type: Int)

    fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int)

    fun onNestedScroll(target: View,dxConsumed: Int,dyConsumed: Int,dxUnconsumed: Int,dyUnconsumed: Int,type: Int)
  1. 我们知道onStartNestedScroll()方法的返回值用来判断该View是否参与嵌套滑动,而我们的需求中只能纵向滑动,因此我们重写该方法,当方向为纵向时加入嵌套滑动:
    override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {
        return (axes and ViewCompat.SCROLL_AXIS_VERTICAL) != 0
    }
  1. 当滑动RecyclerView的时候,首先父级View会接收到回调来优先处理滑动,该过程发生在onNestedPreScroll()方法中,因此我们需要重写它来实现NavView和RecyclerView的位移并消耗掉dy。
    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
        Log.e(TAG, "onNestedPreScroll,dy = $dy")
        //是否消耗dy,由上述两个条件来决定
        val interceptScrollEvent =
            dy > 0 && (mNavView!!.top > mTitleViewHeight) ||
                    dy < 0 && mNavView!!.top < (mTopViewHeight - mNavViewHeight / 2) && !mRecyclerView!!.canScrollVertically(-1)

        if (interceptScrollEvent) {
            //最大可消耗的y方向的距离
            val maxDy = mTopViewHeight - mNavViewHeight / 2 - mTitleViewHeight
            //最大高度
            val maxTop = mTopViewHeight - mNavViewHeight / 2
            //进行拦截并自己消耗dy
            val newTop = mTopViewHeight - mNavViewHeight / 2 - mNavViewTranslateY - dy
            //防止滑动出界
            if (newTop <= mTitleViewHeight) mNavViewTranslateY = maxDy
            else if (newTop >= maxTop) mNavViewTranslateY = 0
            else mNavViewTranslateY += dy
            
            //计算滑动比率
            mTranslateRate = mNavViewTranslateY.toFloat() / maxDy

            Log.e(TAG, "重新计算,dy = $dy, mNavViewTranslateY = $mNavViewTranslateY, mTranslateRate = $mTranslateRate")
            //重新设置NavView的宽度
            setNavViewWidth()
            //更新TopView透明度和TitleView的背景色
            updateAlpha()
            //消耗掉dy
            consumed[1] = dy

            requestLayout()  

            //需要通过offsetTopAndBottom来位移,否则滑动会抖动,
            //猜测通过重新Layout后RecyclerView的位置发生变化,与手指的位置之间的dy突然减小导致
            //以后验证猜测或者如果有人清楚具体原因 欢迎留言解惑,在此谢过!
            mNavView!!.offsetTopAndBottom(-dy)
            mRecyclerView!!.offsetTopAndBottom(-dy)
        }
    }
  1. 关于手指松开后的惯性作用,我们不需要自己实现,因为NestedScrollingParent2已经帮我们实现好了,这也是NestedScrollingParent2比NestedScrollingParent要有优势的地方之一。

到此,自定义TitleStickyNavLayout已经完成,稍后给出布局文件代码。

布局文件

<com.peng.administrator.qhdstudyonline.view.custom.TitleStickyNavLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/back_gray"
        tools:context=".view.activity.exam.NewErrorCollectSubjectActivity">


    <android.support.constraint.ConstraintLayout
            android:id="@id/id_titleStickyNavLayout_topView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:paddingBottom="20dp"
            android:background="@drawable/banner02">
        <TextView
                android:id="@+id/tvErrorTotal"
                android:layout_width="wrap_content"
                android:layout_height="0dp"
                app:layout_constraintLeft_toLeftOf="parent"
                app:layout_constraintTop_toTopOf="@id/tvErrorSingle"
                app:layout_constraintBottom_toBottomOf="@id/tvErrorJudge"
                app:layout_constraintRight_toLeftOf="@id/tvErrorSingle"
                android:background="@drawable/bg_ring"
                android:gravity="center"
                android:textColor="@android:color/white"
                android:visibility="invisible"
        />
        <TextView
                android:id="@+id/tvErrorSingle"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                app:layout_constraintLeft_toRightOf="@id/tvErrorTotal"
                app:layout_constraintRight_toRightOf="parent"
                app:layout_constraintTop_toBottomOf="parent"
                app:layout_constraintBottom_toTopOf="@id/tvErrorMulti"
                android:layout_marginTop="20dp"
                android:layout_marginStart="30dp"
                android:paddingTop="10dp"
                android:drawableStart="@drawable/drawable_white_point"
                android:drawablePadding="10dp"
                android:textSize="12sp"
                android:textColor="@android:color/white"
                android:visibility="invisible"
        />
        <TextView
                android:id="@+id/tvErrorMulti"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                app:layout_constraintLeft_toLeftOf="@id/tvErrorSingle"
                app:layout_constraintRight_toRightOf="@id/tvErrorSingle"
                app:layout_constraintTop_toBottomOf="@id/tvErrorSingle"
                app:layout_constraintBottom_toTopOf="@id/tvErrorJudge"
                android:layout_marginTop="10dp"
                android:drawableStart="@drawable/drawable_white_point"
                android:drawablePadding="10dp"
                android:textSize="12sp"
                android:textColor="@android:color/white"
                android:visibility="invisible"
        />
        <TextView
                android:id="@+id/tvErrorJudge"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                app:layout_constraintLeft_toLeftOf="@id/tvErrorSingle"
                app:layout_constraintRight_toRightOf="@id/tvErrorSingle"
                app:layout_constraintTop_toBottomOf="@id/tvErrorMulti"
                app:layout_constraintBottom_toBottomOf="@id/tvErrorTotal"
                android:layout_marginTop="10dp"
                android:drawableStart="@drawable/drawable_white_point"
                android:drawablePadding="10dp"
                android:paddingBottom="10dp"
                android:textSize="12sp"
                android:textColor="@android:color/white"
                android:visibility="invisible"
        />

    </android.support.constraint.ConstraintLayout>
    <RadioGroup android:layout_width="match_parent"
                android:id="@id/id_titleStickyNavLayout_NavView"
                android:layout_height="wrap_content"
                android:orientation="horizontal"
                android:gravity="center"
                android:background="@drawable/bg_span_white1"
    >
        <RadioButton
                android:id="@+id/rbSingle"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:lineSpacingExtra="5dp"
                android:padding="10dp"
                android:gravity="center"
                android:button="@null"
                android:background="@drawable/bg_rb"
        />
        <RadioButton
                android:id="@+id/rbMulti"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:lineSpacingExtra="5dp"
                android:padding="10dp"
                android:gravity="center"
                android:button="@null"
                android:layout_marginStart="30dp"
                android:background="@drawable/bg_rb"
        />
        <RadioButton
                android:id="@+id/rbJudge"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:lineSpacingExtra="5dp"
                android:padding="10dp"
                android:gravity="center"
                android:button="@null"
                android:layout_marginStart="30dp"
                android:background="@drawable/bg_rb"
        />
    </RadioGroup>
    <android.support.v7.widget.RecyclerView
            android:id="@id/id_titleStickyNavLayout_recyclerView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@drawable/bg_span_white"
            android:layout_margin="10dp"
            android:padding="20dp"
            android:overScrollMode="never"
            android:nestedScrollingEnabled="true"
    />
    <include layout="@layout/title_layout"
             android:id="@id/title_view"/>
</com.peng.administrator.qhdstudyonline.view.custom.TitleStickyNavLayout>

使用CoordinatorLayout并自定义Behavior实现

分析View之间的依赖关系

整个页面布局采用CoordinatorLayout,其直接子View共有4个:TitleView、TopView、NavView、RecyclerView。
我们监听滑动的是RecyclerView,由于四者之间位置存在关联关系,据此我们可以尝试使用以下依赖关系来编写Behavior:

  1. RecyclerView在NavView下面展示,因此RecyclerView需要依赖NavView。
  2. NavView滑动到最顶端时RecyclerVeiw的高度应占满屏幕剩余部分,因此RecyclerView的高度确定还需要依赖TitleView。结合1,RecyclerView应同时依赖TitleView和NavView。
  3. NavView的起始位置应满足NavView的中心线与TopView的底边重合,因此NavView应该依赖TopView。
  4. NavView滑动到的最顶端应满足NavView的top与TitleView的bottom相等,因此NavVeiw应该依赖TopView。结合3,NavView应该同时依赖TitleView和TopView。
  5. 此为结论:根据1、2、3、4就可以将CoordinatorLayout的所有直接子View的位置确定好,因此不需要有其他依赖关系了,也就是说我们只需要编写两个Behavior即可,就叫他们NavBehavior和RecyclerviewBehavior吧,NavBehavior将会设置给NavView,RecyclerviewBehavior将会设置给RecyclerView。

设计依赖关系时应该注意不要有相互依赖的情况,因为一旦这种情况存在,你的应用会直接崩溃~~

确定好了依赖关系,还有一个需要确定的内容,嵌套滑动相关的处理方法应该写在哪?上面说过我们只定义两个Behavior,我们的目的是监听RecyclerView的滑动来消耗滑动产生的dy,以达到嵌套滑动的效果,为了让代码逻辑更加合理和简单,因此我们最好在NavBehavior中处理嵌套滑动。换个角度来讲,我总不能在RecyclerviewBehavior中去监听RecyclerView自己吧?(因为RecyclerviewBehavior中的child就是RecyclerView自己,在RecyclerviewBehavior中动态dependency的位置和大小,实在是不合理,但是也不是不可以)

编写Behavior

RecyclerviewBehavior

从上面的依赖分析中我们知道RecyclerviewBehavior需要依赖TitleView和NavView,依赖TitleView来确定高度,依赖NavView来确定位置。下面就来实现。

  1. 首先要编写构造器
    constructor() : super()
    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
        this.mContext = context
        //RecyclerView上下都有一个margin,分别是10dp,因此纵向margin总共是20dp
        mTotalVerticalMargin = DimenUtil.dip2px(context, 20f)
    }
  1. 根据依赖关系,重写fun layoutDependsOn(parent: CoordinatorLayout, child: RecyclerView, dependency: View): Boolean方法。
    由于TitleView的布局是LinearLayout,NavView的布局是RadioGroup,所以要用此来设置依赖关系。
    override fun layoutDependsOn(parent: CoordinatorLayout, child: RecyclerView, dependency: View): Boolean {
        Log.e(TAG, "layoutDependsOn")
        return dependency is RadioGroup || dependency is LinearLayout
    }
  1. 重写fun onDependentViewChanged(parent: CoordinatorLayout, child: RecyclerView, dependency: View): Boolean方法。
    override fun onDependentViewChanged(parent: CoordinatorLayout, child: RecyclerView, dependency: View): Boolean {
 Log.e(TAG, "onDependentViewChanged")
        if (dependency is RadioGroup) {
            //测量NavView的高度
            mNavHeight = dependency.height
            //通过NavView来设置RecyclerView的位置,实现NavView和RecyclerView同步滑动
            child.y = dependency.y + dependency.height + DimenUtil.dip2px(mContext, 10f)
        } else {
            //测量TitleView的高度
            mTitleViewHeight = dependency.height
        }
        //如果内容已经全部正常显示,就要设置RecyclerView的高度
        if (mNavHeight != 0 && mTitleViewHeight != 0) {
            val params = child.layoutParams
            params.height = ScreenUtil.getScreenHeight(mContext) - mNavHeight - mTitleViewHeight - mTotalVerticalMargin
            child.layoutParams = params
        }
        return true
    }

RecyclerviewBehavior的内容很简单,只是用代码实现了RecyclerView跟随NavView上下滑动。重点和难点是NavBehavior的编写。

NavBehavior

根据上面分析的依赖关系,NavBehavior要依赖TitleView和TopView来确定初始状态(或者是滑动到最底端时的状态)的位置,那么在滑动过程中的位置如何来确定呢?这里既用到了嵌套滑动相关方法的重新,它们会帮我们拿到手势滑动的距离,我们要做的就是在需要NavView滑动的时候进行滑动即可。

编写开始。先来大概考虑一下需要用到哪些变量。

  1. 我们要根据TopView和TitleView的高度来确定NavView的初始位置和最顶端的位置,因此我们需要保存这两个View和他们的高度。
    private var mTopView: View? = null
    private var mTitleView: View? = null
    private var mTitleViewHeight = 0
    private var mTopViewHeight = 0
  1. NavView在滑动过程中,我们需要记录当前NavView滑动的距离,初始状态下为0,到达最顶端时为最大值(需要动态计算);滑动过程中NavView的宽度也会变化,我们需要记录NavView的左右侧的margin;
    private var mNavViewTranslateY = 0 //y方向上相对于起始位置总共移动了多少距离 正
    private var mNavViewMargin = 0  //横向距离两侧的margin
  1. 在NavView滑动过程中,TopView色背景色和TitleView的透明度需要跟随变化,所以需要一个变量来保存当前的位移进度,也就是mNavViewTranslateY 与最大可移动距离的比率。
    private var mTranslateRate = 0f //当前移动的距离与可移动距离的比例
  1. 在我的需求里,TopView的内容应该在TitleView下面同时在NavView的上面,纵向分别padding 20dp,因此我又定义了一个变量mar20 保存这20dp。

接下来开始干活。

确定初始位置
  1. 添加构造器
    constructor() : super()
    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
        this.mContext = context
        mNavViewMargin = DimenUtil.dip2px(context, 20f)
        mar20 = DimenUtil.dip2px(context, 20f)
    }
  1. 添加依赖关系
    override fun layoutDependsOn(parent: CoordinatorLayout, child: RadioGroup, dependency: View): Boolean {
        Log.e(TAG, "layoutDependsOn")
        //title_view 或者 topView
        return dependency is LinearLayout || dependency is ConstraintLayout
    }
  1. 初始化NavView的位置
    override fun onDependentViewChanged(parent: CoordinatorLayout, child: RadioGroup, dependency: View): Boolean {
        Log.e(TAG, "onDependentViewChanged")
        if (dependency is LinearLayout) {
            //记录titleView的高度
            mTitleViewHeight = dependency.height
            mTitleView = dependency
        }
        if (dependency is ConstraintLayout) {
            //记录topView的高度
            mTopViewHeight = dependency.height
            mTopView = dependency
        }
        //设置NavView的初始位置
        child.y = (mTopViewHeight - child.height / 2).toFloat() - mNavViewTranslateY
        //设置NavView的初始宽度和MarginStart
        val param = child.layoutParams as CoordinatorLayout.LayoutParams
        param.width = ScreenUtil.getScreenWidth(mContext) - 2 * mNavViewMargin
        param.marginStart = mNavViewMargin
        child.layoutParams = param
        return true
    }

到此,NavView的初始位置和宽度、margin就已经设置好了。

嵌套滑动动态设置NavVeiw的位置

像NestedScrolling机制的使用,我们也要重写一些相关方法来处理嵌套滑动,只是现在我们需要重写的是Behavior里面的方法。

  1. 支持嵌套滑动。
    override fun onStartNestedScroll(
        coordinatorLayout: CoordinatorLayout,
        child: RadioGroup,
        directTargetChild: View,
        target: View,
        axes: Int,
        type: Int
    ): Boolean {
        return axes and ViewCompat.SCROLL_AXIS_VERTICAL != 0
    }
  1. 在RecyclerView之前拦截滑动并消耗dy。
    override fun onNestedPreScroll(
        coordinatorLayout: CoordinatorLayout,
        child: RadioGroup,
        target: View,
        dx: Int,
        dy: Int,
        consumed: IntArray,
        type: Int
    ) {
        super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
        if (target is RecyclerView) {
            //上滑时是否拦截
            val interceptUp = dy > 0 && child.y > mTitleViewHeight
            //下滑时是否拦截
            val interceptDown = dy < 0 && child.y < mTopViewHeight - child.height / 2 && !target.canScrollVertically(-1)
            if (interceptUp || interceptDown) {
                //拦截滑动后,将进行消耗dy

                var newDy = 0
                //最大高度
                val maxTop = mTopViewHeight - child.height / 2
                //最大可消耗的y方向的距离
                val maxDy = maxTop - mTitleViewHeight

                //防止NavView滑动出界
                val newTop = maxTop - mNavViewTranslateY - dy
                if (newTop <= mTitleViewHeight) {
                    mNavViewTranslateY = maxDy
                    newDy = maxTop - mNavViewTranslateY - mTitleViewHeight
                } else if (newTop >= maxTop) {
                    mNavViewTranslateY = 0
                    newDy = maxTop - mNavViewTranslateY
                } else {
                    mNavViewTranslateY += dy
                    newDy = dy
                }
                //计算滑动完成比率
                mTranslateRate = mNavViewTranslateY.toFloat() / maxDy

                if (mTranslateRate == 1f) {
                    //如果滑动到了最顶端,将NavView的背景换位白色
                    child.setBackgroundResource(android.R.color.white)
                } else {
                    //否则,将NavView的背景换为白色填充的圆角矩形
                    child.setBackgroundResource(R.drawable.bg_span_white1)
                }

                //根据滑动比率计算新的margin
                mNavViewMargin = DimenUtil.dip2px(mContext, 20 - 20 * mTranslateRate)

                //设置NavView的宽度和marginStart
                val param = child.layoutParams as CoordinatorLayout.LayoutParams
                param.width = ScreenUtil.getScreenWidth(mContext) - 2 * mNavViewMargin
                param.marginStart = mNavViewMargin
                child.layoutParams = param

                //修改NavView的位置
                child.y = (maxTop - mNavViewTranslateY).toFloat()

                //根据滑动比率更新titleView的背景颜色和字体颜色
                updateTitle()
                //根据滑动比率更新topView的透明度
                updateTopView()
                //进行拦截并自己消耗dy
                consumed[1] = newDy
            }
        }
    }
    
    private fun updateTopView() {
        if (mTopView == null) {
            Log.e(TAG, "------------------ TopView = null -----------------------")
            return
        }
        mTopView?.alpha = 1 - mTranslateRate
    }

    private fun updateTitle() {
        if (mTitleView == null) {
            Log.e(TAG, "------------------ TitleView = null -----------------------")
            return
        }
        //渐变生成颜色值
        val colorInt = ArgbEvaluator().evaluate(mTranslateRate, 0x00FFFFFF.toInt(), 0xFFFFFFFF.toInt()) as Int
        mTitleView?.background?.setColorFilter(colorInt, PorterDuff.Mode.SRC)

        if ((colorInt shr 24 and 0xff) / 255.0f > 0.5f) {
            //如果背景颜色的透明度高于0.5f,修改字体颜色和返回按钮的颜色
            mTitleView?.findViewById<TextView>(R.id.tv_title)
                ?.setTextColor(ResourcesCompat.getColor(mContext.resources, R.color.tab_text_blue, mContext.theme))
            SomeCompat.polishDrawable(
                mContext,
                mTitleView?.findViewById<ImageView>(R.id.iv_back_title)?.drawable?.mutate(),
                R.color.back_more_more_gray
            )
        } else {
            如果背景颜色的透明度等于或者低于0.5f,还原字体颜色和返回按钮的颜色
            mTitleView?.findViewById<TextView>(R.id.tv_title)?.setTextColor(Color.WHITE)
            SomeCompat.polishDrawable(
                mContext,
                mTitleView?.findViewById<ImageView>(R.id.iv_back_title)?.drawable?.mutate(),
                android.R.color.white
            )
        }
    }

就像NestedScrolling实现功能时一样,我们无须自己处理滑动惯性。

  1. 在我的需求里,TopView的内容要在NavView上面,在TitleView下面,而且要距离两者都要20dp。由于TopView的top是屏幕最顶端(y=0)的位置,而bottom是与NavView的中心线重合,所以我们需要动态设置TopView的topPadding为TitleView的高度 + 20dp,bottomPadding为NavView的高度 / 2 +20dp。TitleView的高度和NavView的高度都需要等最后完成测量后才能确定正确的大小。而我们需要怎么监听这个测量状态呢?
    可以添加Global监听器,但是这是不太好的方法,我个人不想这么做。查看Behavior的源码我们看到了两个方法:
        public boolean onMeasureChild(@NonNull CoordinatorLayout parent, @NonNull V child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) {
            return false;
        }

        public boolean onLayoutChild(@NonNull CoordinatorLayout parent, @NonNull V child, int layoutDirection) {
            return false;
        }

看名字就跟测量和布局有莫大的关系。
接下来翻看官方文档:
onMeasureChild方法onLayoutChild方法google翻译如下:

onMeasureChild方法:
当父CoordinatorLayout即将测量给定的子视图时调用。

此方法可用于执行子视图的自定义或修改测量,以代替默认子测量行为。 Behavior的实现可以通过调用parent.onMeasureChild委托标准CoordinatorLayout测量行为。


onLayoutChild方法:
在父级CoordinatorLayout关于布局给定子视图时调用。

此方法可用于执行子视图的自定义或修改布局,以代替默认子布局行为。 Behavior的实现可以通过调用parent.onLayoutChild委托标准的CoordinatorLayout测量行为。

如果Behavior实现onDependentViewChanged(CoordinatorLayout,View,View)以更改视图的位置以响应从属视图的更改,那么它还应该以尊重这些依赖视图的方式实现onLayoutChild。 在布局依赖关系后,将始终为依赖视图调用onLayoutChild。

既然我需要在所有子View都测量完成后设置padding,那不就可以认为是在layoutChild之前设置padding嘛!所以有了以下代码:

    override fun onLayoutChild(parent: CoordinatorLayout, child: RadioGroup, layoutDirection: Int): Boolean {
        super.onLayoutChild(parent, child, layoutDirection)
        mTopView?.setPadding(0, mTitleViewHeight + mar20, 0, child.height / 2 + mar20)

        return false
    }

测试完美解决!!
在这里插入图片描述

布局文件代码

<android.support.design.widget.CoordinatorLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/back_gray"
        tools:context=".view.activity.exam.CoorErrorCollectSubjectActivity">


    <android.support.constraint.ConstraintLayout
            android:id="@id/id_titleStickyNavLayout_topView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@drawable/banner02">
        <TextView
                android:id="@+id/tvErrorTotal"
                android:layout_width="wrap_content"
                android:layout_height="0dp"
                app:layout_constraintLeft_toLeftOf="parent"
                app:layout_constraintTop_toTopOf="@id/tvErrorSingle"
                app:layout_constraintBottom_toBottomOf="@id/tvErrorJudge"
                app:layout_constraintRight_toLeftOf="@id/tvErrorSingle"
                android:background="@drawable/bg_ring"
                android:gravity="center"
                android:textColor="@android:color/white"
                android:visibility="invisible"
        />
        <TextView
                android:id="@+id/tvErrorSingle"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                app:layout_constraintLeft_toRightOf="@id/tvErrorTotal"
                app:layout_constraintRight_toRightOf="parent"
                app:layout_constraintTop_toBottomOf="parent"
                app:layout_constraintBottom_toTopOf="@id/tvErrorMulti"
                android:layout_marginTop="20dp"
                android:layout_marginStart="30dp"
                android:paddingTop="10dp"
                android:drawableStart="@drawable/drawable_white_point"
                android:drawablePadding="10dp"
                android:textSize="12sp"
                android:textColor="@android:color/white"
                android:visibility="invisible"
        />
        <TextView
                android:id="@+id/tvErrorMulti"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                app:layout_constraintLeft_toLeftOf="@id/tvErrorSingle"
                app:layout_constraintRight_toRightOf="@id/tvErrorSingle"
                app:layout_constraintTop_toBottomOf="@id/tvErrorSingle"
                app:layout_constraintBottom_toTopOf="@id/tvErrorJudge"
                android:layout_marginTop="10dp"
                android:drawableStart="@drawable/drawable_white_point"
                android:drawablePadding="10dp"
                android:textSize="12sp"
                android:textColor="@android:color/white"
                android:visibility="invisible"
        />
        <TextView
                android:id="@+id/tvErrorJudge"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                app:layout_constraintLeft_toLeftOf="@id/tvErrorSingle"
                app:layout_constraintRight_toRightOf="@id/tvErrorSingle"
                app:layout_constraintTop_toBottomOf="@id/tvErrorMulti"
                app:layout_constraintBottom_toBottomOf="@id/tvErrorTotal"
                android:layout_marginTop="10dp"
                android:drawableStart="@drawable/drawable_white_point"
                android:drawablePadding="10dp"
                android:paddingBottom="10dp"
                android:textSize="12sp"
                android:textColor="@android:color/white"
                android:visibility="invisible"
        />

    </android.support.constraint.ConstraintLayout>
    <RadioGroup android:layout_width="match_parent"
                android:id="@id/id_titleStickyNavLayout_NavView"
                android:layout_height="wrap_content"
                android:orientation="horizontal"
                android:gravity="center"
                android:background="@drawable/bg_span_white1"
                app:layout_behavior="@string/behavior_nav"
    >
        <RadioButton
                android:id="@+id/rbSingle"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:lineSpacingExtra="5dp"
                android:padding="10dp"
                android:gravity="center"
                android:button="@null"
                android:background="@drawable/bg_rb"
        />
        <RadioButton
                android:id="@+id/rbMulti"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:lineSpacingExtra="5dp"
                android:padding="10dp"
                android:gravity="center"
                android:button="@null"
                android:layout_marginStart="30dp"
                android:background="@drawable/bg_rb"
        />
        <RadioButton
                android:id="@+id/rbJudge"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:lineSpacingExtra="5dp"
                android:padding="10dp"
                android:gravity="center"
                android:button="@null"
                android:layout_marginStart="30dp"
                android:background="@drawable/bg_rb"
        />
    </RadioGroup>
    <android.support.v7.widget.RecyclerView
            android:id="@id/id_titleStickyNavLayout_recyclerView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@drawable/bg_span_white"
            android:layout_marginStart="10dp"
            android:layout_marginEnd="10dp"
            android:padding="20dp"
            android:overScrollMode="never"
            android:nestedScrollingEnabled="true"
            app:layout_behavior="@string/behavior_recyclerview"
    />
    <include layout="@layout/title_layout"
             android:id="@id/title_view"
    />
</android.support.design.widget.CoordinatorLayout>

到此,两种方法都已经介绍完毕。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值