最近在试着研究自定义View,之前一直对NestedScroll挺感兴趣的,这次试着做了做,做出了这样的效果,拿出来记录并分享下
考虑到实现方便以及后期的修改复用,我并没有完全使用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处理滑动距离的流程如下
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
同样的,先来看下流程图实现
可以看到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)
}
}
}
复制代码