嵌套滑动撸一个ui交互效果
本文是为初接触NestedScrolling但是对其运行机制有所了解的朋友准备的,大神可自行绕过。
NestedScrolling机制问世这么久,网上对于嵌套滑动的介绍数不胜数,对其运行机制不熟悉的可以参考Android NestedScrolling全面解析 - 带你实现一个支持嵌套滑动的下拉刷新(上篇)或者嵌套滚动利器–NestedScrolling机制这两篇文章。
特别注意:本文内容较多,请耐心阅读,相信你一定会有所收获
本文实现效果预览
由于gif上传有大小要求所以对其进行了压缩。为了更好的理解最终效果,下面简单说一下效果。
初始状态时,Tab栏的中心线应与红色背景图的底部边缘重合。当我们滑动RecyclerView的时候,Tab栏和RecyclerView会一起上滑知道Tab栏顶端与标题栏底端重叠(到此时再往上滑,RecyclerView就会进行滑动)。当整体上滑到最顶端时往下滑,会优先滑动RecyclerView,当RecyclerView不能向下滑动的时候,Tab栏和RecyclerView将会整体下滑,直到初始状态,结束。
分析此过程:
-
Tab栏(以下称NavView,实际是RadioGroup)会跟随RecyclerView上滑或下滑,且宽度会发生变化。可滑动的条件有两种:
- 上滑阶段,NavView未滑动到最顶端 - 下滑阶段,NavView未滑动到最低端且RecyclerView不能向下滑动
-
红色背景图(以下称TopView,实际是ConstraintLayout)会随着RecyclerView或者NavView改变透明度,当NavView到达最顶端,TopView完全透明,当NavView到达最低端,TopView完全不透明。
-
标题栏(以下称TitleView,实际是LinearLayout)会随着RecyclerView或者NavView渐变背景透明度和文字颜色。当NavView到达最顶端,TitleView完全不透明,当NavView到达最低端,TitleView完全透明,当NavView滑动到一半时,TitleView的文字颜色改变,在一半位置以上时为蓝色,一半位置以下时为白色,TitleView中相应的返回箭头也会改变颜色。
效果实现方法分析:
- 由于整个滑动过程中事件分发无中断,按照以往方式来说,只有依靠手动分发事件来实现,实现起来难度较大,逻辑较多。
- 使用NestedScrolling机制,由于RecyclerView已经实现了NestedScrollingChild2接口,那么我们只需要自定义一个ViewGroup实现NestedScrollingParent2接口即可实现。
- 使用CoordinatorLayout并自定义Behavior来实现。
第2、3中方法难度都不是很大,下面就来使用两种方法进行实现。
使用NestedScrolling机制实现效果
自定义TitleStickyNavLayout
- 定义一个TitleStickyNavLayout继承自FrameLayout并实现NestedScrollingParent2。
- 添加构造方法
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
- 定义几个变量。
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
- 有了上面的变量就得在适当时机为其赋值。
几个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
}
- 测量子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
}
- 布局子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)
}
- 下面就该来处理嵌套滑动了。我们需要实现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)
- 我们知道onStartNestedScroll()方法的返回值用来判断该View是否参与嵌套滑动,而我们的需求中只能纵向滑动,因此我们重写该方法,当方向为纵向时加入嵌套滑动:
override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {
return (axes and ViewCompat.SCROLL_AXIS_VERTICAL) != 0
}
- 当滑动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)
}
}
- 关于手指松开后的惯性作用,我们不需要自己实现,因为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:
- RecyclerView在NavView下面展示,因此RecyclerView需要依赖NavView。
- NavView滑动到最顶端时RecyclerVeiw的高度应占满屏幕剩余部分,因此RecyclerView的高度确定还需要依赖TitleView。结合1,RecyclerView应同时依赖TitleView和NavView。
- NavView的起始位置应满足NavView的中心线与TopView的底边重合,因此NavView应该依赖TopView。
- NavView滑动到的最顶端应满足NavView的top与TitleView的bottom相等,因此NavVeiw应该依赖TopView。结合3,NavView应该同时依赖TitleView和TopView。
- 此为结论:根据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来确定位置。下面就来实现。
- 首先要编写构造器
constructor() : super()
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
this.mContext = context
//RecyclerView上下都有一个margin,分别是10dp,因此纵向margin总共是20dp
mTotalVerticalMargin = DimenUtil.dip2px(context, 20f)
}
- 根据依赖关系,重写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
}
- 重写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滑动的时候进行滑动即可。
编写开始。先来大概考虑一下需要用到哪些变量。
- 我们要根据TopView和TitleView的高度来确定NavView的初始位置和最顶端的位置,因此我们需要保存这两个View和他们的高度。
private var mTopView: View? = null
private var mTitleView: View? = null
private var mTitleViewHeight = 0
private var mTopViewHeight = 0
- NavView在滑动过程中,我们需要记录当前NavView滑动的距离,初始状态下为0,到达最顶端时为最大值(需要动态计算);滑动过程中NavView的宽度也会变化,我们需要记录NavView的左右侧的margin;
private var mNavViewTranslateY = 0 //y方向上相对于起始位置总共移动了多少距离 正
private var mNavViewMargin = 0 //横向距离两侧的margin
- 在NavView滑动过程中,TopView色背景色和TitleView的透明度需要跟随变化,所以需要一个变量来保存当前的位移进度,也就是mNavViewTranslateY 与最大可移动距离的比率。
private var mTranslateRate = 0f //当前移动的距离与可移动距离的比例
- 在我的需求里,TopView的内容应该在TitleView下面同时在NavView的上面,纵向分别padding 20dp,因此我又定义了一个变量mar20 保存这20dp。
接下来开始干活。
确定初始位置
- 添加构造器
constructor() : super()
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
this.mContext = context
mNavViewMargin = DimenUtil.dip2px(context, 20f)
mar20 = DimenUtil.dip2px(context, 20f)
}
- 添加依赖关系
override fun layoutDependsOn(parent: CoordinatorLayout, child: RadioGroup, dependency: View): Boolean {
Log.e(TAG, "layoutDependsOn")
//title_view 或者 topView
return dependency is LinearLayout || dependency is ConstraintLayout
}
- 初始化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里面的方法。
- 支持嵌套滑动。
override fun onStartNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: RadioGroup,
directTargetChild: View,
target: View,
axes: Int,
type: Int
): Boolean {
return axes and ViewCompat.SCROLL_AXIS_VERTICAL != 0
}
- 在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实现功能时一样,我们无须自己处理滑动惯性。
- 在我的需求里,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;
}
看名字就跟测量和布局有莫大的关系。
接下来翻看官方文档:
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>
到此,两种方法都已经介绍完毕。