使用Behavior实现一种跟随滚动的嵌套滑动效果

·  阅读 848

本文是对文章中方法介绍+小例子,我学会了Behavior的拓展,将使用Behavior的多种能力,依赖布局嵌套滑动等,实现一种常用的嵌套机制。目标效果如下:

behavior-nested2.gif

布局设置

首先,设置xml布局,界面只有两个元素,TextViewRecyclerView。其中TextView比较简单,只是设置了背景,其他的都没有设置。而RecyclerView也比较简单,就是一个普通的列表,具体的Adapter也不再赘述了。

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".view.TouchActivity">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="#0000FF" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#00FFFF"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        tools:itemCount="10"
        tools:listitem="@layout/item_recycler" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>
复制代码

若是直接运行的话,会看不到TextView。因为CoordinatorLayout在不设置Behavior的情况下,相当于是一个FrameLayout,因此TextView会被RecyclerView覆盖在下方而导致什么都看不到。

Behavior

为了避免覆盖的情况,下面将定义多个Behavior,从而逐步实现目标效果。

ToBottomBehavior 为TextView留出位置

首先CoordinatorLayout默认是把子View都堆在左上角的,因此想要TextView显示出来,需要将RecyclerView向下移动,移动的距离为TextView的高度。因此,需要重写BehavioronLayoutChild方法,然后重新进行layout

class ToBottomBehavior(
    context: Context,
    attr: AttributeSet? = null
    // 由于只给RecyclerView使用,所以这里泛型直接使用RecyclerView
) : CoordinatorLayout.Behavior<RecyclerView>(context, attr) {

    override fun onLayoutChild(
        parent: CoordinatorLayout,
        child: RecyclerView,
        layoutDirection: Int
    ): Boolean {
        // 低于两个子View的时候不去布局了
        if (parent.childCount < 2) {
            return false
        }
        val firstView = parent.getChildAt(0)
        // measure的时候height是match_parent的高度,
        // 而实际布局后,高度变成了match_parent减去firstView的高度
        child.layout(0, firstView.measuredHeight, child.measuredWidth, child.measuredHeight)
        return true
    }

}
复制代码

然后将这个Behavior设置给RecyclerView即可:

<androidx.coordinatorlayout.widget.CoordinatorLayout...>
    
    <TextView .../>
    
    <androidx.recyclerview.widget.RecyclerView ... 
      	app:layout_behavior=".behavior.ToBottomBehavior"/>

</androidx.coordinatorlayout.widget.CoordinatorLayout>
复制代码

此时运行后就会显示出TextView了:

touch01.jpg

TouchBehavior 开启嵌套滑动

经过前面的设置,已经实现了上面是TextView下面是RecyclerView的布局了。后面该做的事就是在滚动的时候,让TextView先向上滚出屏幕:然后再继续RecyclerView的滚动。

本次所做的任务是由RecyclerView的滚动驱动TextView向上滚动,很明显就是嵌套滑动了。首先将滑动分配给TextView,当TextView滚出屏幕后不再消耗滚动事件,然后再还给RecyclerView去滚动。

class TouchBehavior(
    context: Context,
    attr: AttributeSet? = null
) : CoordinatorLayout.Behavior<View>(context, attr) {

    override fun onStartNestedScroll(
        coordinatorLayout: CoordinatorLayout,
        child: View,
        directTargetChild: View,
        target: View,
        axes: Int,
        type: Int
    ): Boolean {
        // 只拦截纵向的滚动
        return axes == ViewCompat.SCROLL_AXIS_VERTICAL
    }

    override fun onNestedPreScroll(
        coordinatorLayout: CoordinatorLayout,
        child: View,
        target: View,
        dx: Int,
        dy: Int,
        consumed: IntArray,
        type: Int
    ) {
        // 注意,手指向上滑动的时候,dy大于0。向下的时候dy小于0。
        val translationY = child.translationY
        if (-translationY >= child.measuredHeight || dy < 0) {
            // child已经滚动到屏幕外了,或者向下滚动,就不去消耗滚动了
            return
        }
        // 还差desireHeight距离将会移出屏幕外
        val desireHeight = translationY + child.measuredHeight
        if (dy <= desireHeight) {
            // 将dy全部消耗掉
            child.translationY = translationY - dy
            consumed[1] = dy
        } else {
            // 消耗一部分的的dy
            child.translationY = translationY - desireHeight
            consumed[1] = desireHeight.toInt()
        }
    }

}
复制代码

注意这里省略了一个onNestedScrollAccepted方法,因为我们这个比较简单,不需要什么初始化前置操作。然后设置给TextView,因为嵌套滑动的触发者是RecyclerView,作用者(parent)是TextView。所以需要将这个Behavior设置给TextView

<androidx.coordinatorlayout.widget.CoordinatorLayout ...>

    <TextView ...
        app:layout_behavior=".behavior.TouchBehavior"/>
    
    <RecyclerView .../>
    
</androidx.coordinatorlayout.widget.CoordinatorLayout>
复制代码

touch02.gif

从上图可以看到的是,嵌套滑动已初具模型。先是滚动TextView,然后接着滚动RecyclerView。但是还是有一点问题,就是在滚动的时候TextViewRecyclerView分离了,中间留有一大片的空白。因此,还需要继续优化,在TextView滚动的时候将RecyclerView也向上滚动。

这种情况应该是属于Behavior的依赖功能,也就是RecyclerView依赖于TextView,并一直处于TextView的下方。因此,这个操作应该放在RecyclerViewBehavior之中。

class ToBottomBehavior(..){
	...

    override fun layoutDependsOn(
        parent: CoordinatorLayout,
        child: RecyclerView,
        dependency: View
    ): Boolean {
        return dependency === parent.getChildAt(0)
    }

    override fun onDependentViewChanged(
        parent: CoordinatorLayout,
        child: RecyclerView,
        dependency: View
    ): Boolean {
        child.translationY = dependency.translationY
        return true
    }

}
复制代码

ToBottomBehavior中重写了layoutDependsOn方法,并且只依赖于第一个子View。然后在onDependentViewChanged方法中通过tranlationY来移动child的位置。虽然这样写也是可以的,但是会有一个问题,就是当RecyclerView向上移动的时候,底部会留出一片空白。

这是因为在重写自定义布局的时候,给RecyclerView设置layout的时候,让它的高度变成了match_parent减去TextView的高度了。因此当它向上移动的时候,会因为高度不够而导致底部有留白。所以,onDependentViewChanged方法并不能只是改变位置,同样的还需要改变高度,因此这里直接重新layout了。

class ToBottomBehavior(...) {

    ...

    override fun onDependentViewChanged(
        parent: CoordinatorLayout,
        child: RecyclerView,
        dependency: View
    ): Boolean {
//        child.translationY = dependency.translationY
        child.layout(
            0,
            (dependency.bottom + dependency.translationY).toInt(),
            child.measuredWidth,
            child.measuredHeight
        )
        return true
    }

}
复制代码

这样的话,在TextView位置变化的时候,会重新对RecyclerView进行布局,因此高度也会有所变化,从而使其可以一直填充父布局而不会底部留白,效果如下。

behavior-nested.gif

此时大体上已经满足我们的要求了,而且滑动是连续的,在TextView滚动到屏幕外后,会紧接着进行RecyclerView的滚动,而不会发生停顿。但是上面其实还是有一个问题的,当RecyclerView滚动到顶部/底部的时候,应该会显示一个OverScroll的效果(一个蓝色的弧线),但是这里并没有显示。

behavior-overscroll.jpg

这是因为,在嵌套滑动的事件中,滑动事件是从child->parent->child->parent的。而Behavior就是担任parent角色的。一开始发生了滚动事件,从child中传递到parent中,然后我们在parent中处理TextView的向上移动这个动作,然后处理完后事件又传递给child进行滚动,当child滚动到底部后不能再滚动了,这时候事件又传递给了parent。但是,我们并没有处理这次的事件(onNestedScroll),因为会采用默认的实现,如下:

public void onNestedScroll(
    @NonNull CoordinatorLayout coordinatorLayout, 
    @NonNull V child,
    @NonNull View target, 
    int dxConsumed, 
    int dyConsumed, 
    int dxUnconsumed,
    int dyUnconsumed, 
    @NestedScrollType int type, 
    @NonNull int[] consumed
) {
    consumed[0] += dxUnconsumed;
    consumed[1] += dyUnconsumed;
    onNestedScroll(coordinatorLayout, child, target, dxConsumed, 
        dyConsumed, dxUnconsumed, dyUnconsumed, type);
}
复制代码

而在默认的实现中,直接将所有的事件给消耗了,在child不能消耗滚动事件后(滚动到底部),剩下的事件都被parent消耗了。这对于child而言,就是child滚动到底部后就停止了事件流,因此child才不会出现OverScroll效果的。所以,我们还应该重写onNestedScroll并给他一个空实现即可出现OverScroll效果了。

另外,顶部的TextView滚动出去之后就一直不见了。我们现在还想在RecyclerView滚动到顶部后,再继续滚动的时候把TextView再滚出来。这时候就是应该在onNestedScroll中操作了。

class TouchBehavior(...) {

    ...

    override fun onNestedScroll(
        coordinatorLayout: CoordinatorLayout,
        child: View,
        target: View,
        dxConsumed: Int,
        dyConsumed: Int,
        dxUnconsumed: Int,
        dyUnconsumed: Int,
        type: Int,
        consumed: IntArray
    ) {
        val translationY = child.translationY
        if (translationY >= 0 || dyUnconsumed > 0) {
            // 手指向上滚动或者child已经滚出了屏幕,不去处理
            return
        }
        if (dyUnconsumed > translationY) {
            // 全部消耗
            consumed[1] += dyUnconsumed
            child.translationY = translationY - dyUnconsumed
        } else {
            // 消耗一部分
            consumed[1] += child.translationY.toInt()
            child.translationY = 0F
        }
    }

}
复制代码

修改后的如下图所示:

behavior-nested2.gif

完整代码在这里: 点击跳转github链接

总结

完全的touch事件重写是很麻烦的,因此我们几乎不去重写这种事件,而是使用已经封装好的滑动事件。当然,嵌套滑动也是比较麻烦的,因为要实现一个嵌套滑动的parent或者child首先都需要自定义View,并且处理很麻烦的滑动关系,还需要处理子View的测量布局等。

CoordinatorLayout本身就已经实现了这些我们不是很关注的东西,并且将嵌套滑动过程中parent的功能都提取到了Behavior中,因此,我们可以不需要自定义View,只需要给View设置一个Behavior,就能轻轻松松让它作为一个嵌套滑动的parent,即使它并没有实现NestedScrollingParent3接口。

嵌套滑动的顺序是:child发起 -> parent先处理 -> child后处理 -> parent再处理 -> 还给child

作为parent,我们需要处理两次滑动事件,一次是刚开始的预处理(onNestedPreScroll),一次是child处理后又传递过来的(onNestedScroll),我们要做的就是判断当前要做的操作在哪个阶段就行了。

分类:
Android
标签:
分类:
Android
标签: