联动效果

现代化的 Android 开发一定对 CoordinatorLayout 不陌生,CoordinatorLayout + AppBarLayout + CollapsingToolbarLayout + Toolbar 的全家桶更是信手拈来,无需一行代码光靠 xml 就能实现下面这种折叠导航栏的炫酷效果:

这种搭配的教程已经非常多了,不是本文的重点。在使用 xml 时候肯定不少同学掉过一个坑:界面主要内容与头部元素重叠了!粗略了解一下因为 CoordinatorLayout 的布局方式类似 FrameLayout 默认情况下所有元素都会叠加在一起,解决方案也非常玄学,就是给内容元素添加一个 app:layout_behavior="@string/appbar_scrolling_view_behavior" 属性就好了,简直像黑魔法!

Unfortunately,代码并没有魔法,我们能偷懒是因为有人封装好了。跟踪进这个字符串是 com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior 显然这是个类!事实上这就是今天的重头戏 —— Behavior.

这个效果太复杂了,所以 Google 才会帮我们包装好,下面换一个简单的例子便于学习:

这是仿三星 One UI 的界面。上面是一个头布局,下面是一个 RecyclerView,向上滑动时首先头布局收缩渐隐并有个视差效果,头部彻底隐藏后 RecyclerView 无缝衔接。向下滑动时同理。

事件拦截实现

在继续探索之前,先思考一下如果没有 CoordinatorLayout 这种现代化东西怎么办?因为这牵扯到滑动手势与 View 效果的糅合,毫无疑问应该从触摸事件上入手。简单起见暂时只考虑手指向上滑动(列表向下展示更多内容),大概需要进行以下操作:

  1. 在父布局 onInterceptTouchEvent 中拦截事件。
  2. 父布局 onTouchEvent 处理事件,对 HeaderView 进行操作(移动、改变透明度等)。
  3. HeaderView 完全折叠后父布局不再拦截事件,RecyclerView 正常处理滑动。

现在已经遇到问题了。因为一开始父布局拦截了事件,因此根据 Android 事件分发机制,哪怕后续不再拦截其子控件也无法收到事件,除非重新触摸,这就造成了两者的滑动不能无缝衔接。

接着还有一个问题,反过来当 RecyclerView 向下滑动至顶部时,如何通知 HeaderView 展开?

哪怕解决了上述主要问题,肯定还有其他小毛病,例如子控件无法触发点击事件等等等非常恼人💢。假设你是大佬完美解决了所有问题,肯定耦合特别严重,又是自定义 View 又是互相引用的乱七八糟😵 所以现在就不往下深究了,有闲情雅致有能力的同学可以尝试实现。

NestingScroll

从 Android 5.0 (API21) 开始 Google 给出了官方解决方案 - NestingScroll,这是一个嵌套滑动机制,用于协调父/子控件对滑动事件的处理。他的基本思想就是,事件直接传到子控件,由子控件询问父控件是否需要滑动,父控件处理后给出已消耗的距离,子控件继续处理未消耗的距离。当子控件也滑到顶(底)时将剩余距离交给父控件处理。让我来生动地解释一下:

子:开始滑动喽,准备滑300px,爸爸你要不要先滑?
父:好嘞,我先滑100px到顶了,你继续。
子:收到,我接着滑160px到底了,爸爸剩下的交给你了。
父:好的还有40px,我继续滑(也可以不滑忽略此回调)

就这样,父控件没有拦截事件,而是子控件收到事件后主动询问,在他们的协调配合之下完成了无缝滑动衔接。为了实现这点,Google 准备了两个接口:NestedScrollingParent, NestedScrollingChild.

NestedScrollingParent 主要方法如下:

  • onStartNestedScroll : Boolean - 是否需要消费这次滑动事件。(爸爸你要不要先滑?)
  • onNestedScrollAccepted - 确认消费滑动回调,可以执行初始化工作。(好嘞我先滑)
  • onNestedPreScroll - 在子控件处理滑动事件之前回调。(我先滑了100px)
  • onNestedScroll - 子控件滑动之后的回调,可以继续执行剩余距离。(还有40px我继续滑)
  • onStopNestedScroll - 事件结束,可以做一些收尾工作。

类似的还有 Fling 相关接口。

NestedScrollingChild 主要方法如下:

  • startNestedScroll - 开始滑动。
  • dispatchNestedPreScroll - 在自己滑动之前询问父组件。
  • dispatchNestedScroll - 在自己滑动之后把剩余距离通知父组件。
  • stopNestedScroll - 结束滑动。

以及 Fling 相关接口和其他一些东西。

最终执行顺序如下(父控件接受事件、用户触发了抛掷):子startNestedScroll → 父onStartNestedScroll → 父onNestedScrollAccepted ||→ 子dispatchNestedPreScroll → 父onNestedPreScroll ||→ 子dispatchNestedScroll → 父onNestedScroll ||→ 子dispatchNestedPreFling → 父onNestedPreFling ||→ 子dispatchNestedFling → 父onNestedFling ||→ 子stopNestedScroll → 父onStopNestedScroll

RecyclerView 已经默认实现了 Child 接口,现在只要给外层布局实现 Parent 接口并作出正确反应,应该就可以达到目的了,最麻烦的事件转发已经在 RecyclerView 内部实现。但是... 还是需要自己定义个外部 Layout?似乎依然有点麻烦并且解耦不彻底。

当当当!Behavior 登场!

CoordinatorLayout 名副其实,它是一个可以协调各个子 View 的布局。注意区别 NestedScrolling 机制,后者只能调度父子两者的滑动,而前者可以协调所有子 View 的所有动作。有了这个神器后我们不再需要自定义 Layout 来实现嵌套滑动接口了,并且可以实现更复杂的效果。CoordinatorLayout 只能提供一个平台,具体效果的实现需要依赖 Behavior. CoordinatorLayout 的所有直接子控件都可以设置 Behavior,其定义了这个 View 应当对触摸事件做何反应,或者对其他 View 的变化做何反应,成功地将具体实现从 View 中抽离出来。

CoordinatorLayout 类似于网游的中央服务器。对于嵌套滑动来说,它实现了 NestedScrollingParent 接口因此可以接受到子 View 的滑动信息,并且分发给所有子 View 的 Behavior 并将它们的响应汇总起来返回给滑动 View。对于依赖其他 View 的功能,当有 View 属性发生改变时它会通知所有声明了监听的子 View 的 Behavior.

注意:无论嵌套多少级的滑动事件都可以被转发。但是只有直接子 View 可以设置 Behavior (响应事件)或作为被监听的对象。

除此之外,Behavior 还有 onInterceptTouchEvent, onTouchEvent 方法,重点是它接收到的不仅仅是自己范围内的事件。也就是说现在子 View 可以直接拦截父布局的事件了。利用这一点我们可以轻松做出拖拽移动,其他 View 跟随的效果,比如这样

Behavior 像是一个集大成者,它能够进行事件处理、嵌套滑动协调、子控件变化监听,甚至还能直接修改布局(onMeasureChild, onLayoutChild 这里面的 Child 指的就是 Behavior 所对应的子控件)这有什么用呢?通过一开始的例子来看看吧。

实战:仿三星 One UI

再贴一遍效果图:

先看看布局:

<?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">

    <LinearLayout
        android:id="@+id/imagesTitleBlockLayout"
        android:layout_width="match_parent"
        android:layout_height="@dimen/title_block_height"
        android:gravity="center"
        android:orientation="vertical"
        app:layout_behavior=".ui.images.NestedHeaderScrollBehavior">

        <TextView
            style="@style/text_view_primary"
            android:text="@string/nav_menu_images"
            android:textSize="40sp" />

        <TextView
            android:id="@+id/imagesSubtitleTextView"
            style="@style/text_view_secondary"
            android:textSize="18sp"
            tools:text="183 images" />
    </LinearLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/imagesRecyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior=".ui.images.NestedContentScrollBehavior"
        tools:listitem="@layout/rv_item_images_img" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

一般来说为了简单,我们会选定1个 View 用于响应嵌套滑动,其他 View 监听此 View来同步改变。HeaderView 的效果比较复杂我不希望它承担太多工作,因此这里让 RecyclerView 自己处理嵌套滑动问题。

这里一个重要原因是 HeaderView 有了视差效果。否则的话让 HeaderView 响应滑动,RecyclerView 只需要紧贴着 HeaderView 移动就行了,更简单。

处理嵌套滑动

现在开始编写 RecyclerView 所需的 Behavior. 第一个要解决的问题就是重叠,这就需要刚刚提到的干预布局。核心思想是一开始获取 HeaderView 的高度,作为 RecyclerView 的 Top 属性,就可以实现类似 LinearLayout 的布局了。

注意:①为了能够在 xml 中直接设置 Behavior 我们得写一个带有 attrs 参数的构造函数。② <View> 表示 Behavior 所设置到的 View 类型,因为这里不需要用到 RecyclerView 的特有 API 所以直接写 View 了。
class NestedContentScrollBehavior(context: Context?, attrs: AttributeSet?) :
        CoordinatorLayout.Behavior<View>(context, attrs) {
    private var headerHeight = 0

    override fun onLayoutChild(parent: CoordinatorLayout, child: View, layoutDirection: Int): Boolean {
        // 首先让父布局按照标准方式解析
        parent.onLayoutChild(child, layoutDirection)
        // 获取到 HeaderView 的高度
        headerHeight = parent.findViewById<View>(R.id.imagesTitleBlockLayout).height
        // 设置 top 从而排在 HeaderView的下面
        ViewCompat.offsetTopAndBottom(child, headerHeight)
        return true // true 表示我们自己完成了解析 不要再自动解析了
    }
}

正式开始嵌套滑动的处理,先处理手指向上滑动的情况。因为只有在 HeaderView 折叠后才允许 RecyclerView 滑动,因此要写在 onNestedPreScroll 方法里。对这些滑动回调不清楚的看看上面第二节 NestingScroll 相关部分。

    override fun onStartNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, directTargetChild: View,
                                     target: View, axes: Int, type: Int): Boolean {
        // 如果是垂直滑动的话就声明需要处理
        // 只有这里返回 true 才会收到下面一系列滑动事件的回调
        return (axes and ViewCompat.SCROLL_AXIS_VERTICAL) != 0
    }

    override fun onNestedPreScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dx: Int, dy: Int,
                                   consumed: IntArray, type: Int) {
        // 此时 RecyclerView 还没开始滑动
        super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
        if (dy > 0) { // 只处理手指上滑
            val newTransY = child.translationY - dy
            if (newTransY >= -headerHeight) {
                // 完全消耗滑动距离后没有完全贴顶或刚好贴顶
                // 那么就声明消耗所有滑动距离,并上移 RecyclerView
                consumed[1] = dy // consumed[0/1] 分别用于声明消耗了x/y方向多少滑动距离
                child.translationY = newTransY
            } else {
                // 如果完全消耗那么会导致 RecyclerView 超出可视区域
                // 那么只消耗恰好让 RecyclerView 贴顶的距离
                consumed[1] = headerHeight + child.translationY.toInt()
                child.translationY = -headerHeight.toFloat()
            }
        }
    }

并不复杂,核心思想是判断 RecyclerView 在移动用户请求的距离后,会不会超出窗口区域。如果不超出那么就全部消耗,RV 自己不再滑动。如果超出那么就只消耗不超出的那一部分,剩余距离由 RV 内部滑动。

接着写手指向下滑动的部分。因为这时候需要优先让 RecyclerView 滑动,在它滑动到顶的时候才需要整体下移让 HeaderView 显示出来,所以要在 onNestedScroll 里写。

    override fun onNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dxConsumed: Int,
                                dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray) {
        // 此时 RV 已经完成了滑动,dyUnconsumed 表示剩余未消耗的滑动距离
        super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                type, consumed)
        if (dyUnconsumed < 0) { // 只处理手指向下滑动的情况
            val newTransY = child.translationY - dyUnconsumed
            if (newTransY <= 0) {
                child.= newTransY
            } else {
                child.translationY = 0f
            }
        }
    }

比上一个简单一些。如果滑动后 RV 的偏移小于0(Y偏移<0代表向上移动)那么就表示还没有完全归位,那么消耗全部剩余距离。否则直接让 RV 归位就行了。

offsetTopAndBottom 与 translationY 的关系

从用途出发,offsetTopAndBottom 常用于永久性修改,translationY 常用于临时性修改(例如动画)这里我们也遵循了这个约定

从效果出发,offsetTopAndBottom(offset) 是累加的,其内部相当于 mTop+=offset,而 translationY 每次都是重新设置与已有值无关。

最关键是,onLayoutChild 有可能被多次触发,因此动画所使用的方法必须与调整布局所使用的方法不同。否则有可能出现滑动执行到一半结果触发了重新布局,结果自动归位,视觉上就是胡乱跳动。

处理 HeaderView

接下来开始写 HeaderView 的 Behavior 它的主要任务是监听 RecyclerView 的变化来改变 HeaderView 的属性。

class NestedHeaderScrollBehavior constructor(context: Context?, attrs: AttributeSet?) :
        CoordinatorLayout.Behavior<View>(context, attrs) {

    override fun layoutDependsOn(parent: CoordinatorLayout, child: View, dependency: View): Boolean {
        // child: 当前 Behavior 所关联的 View,此处是 HeaderView
        // dependency: 待判断是否需要监听的其他子 View
        return dependency.id == R.id.imagesRecyclerView
    }

    override fun onDependentViewChanged(parent: CoordinatorLayout, child: View, dependency: View): Boolean {
        child.translationY = dependency.translationY * 0.5f
        child.alpha = 1 + dependency.translationY / (child.height * 0.6f)
        // 如果改变了 child 的大小位置必须返回 true 来刷新
        return true
    }
}

这一个简单多了。layoutDependsOn 会对每一个子 View 触发一遍,通过某种方法判断是不是要监听的 View,只有这里返回了 true 才能收到对应 View 的后续回调。我们在 onDependentViewChanged 中根据 RecyclerView 的偏移量来计算 HeaderView 的偏于与透明度,通过乘以一个系数来实现视差移动。

到此为止已经基本上实现了上述效果。

Surprise! 自动归位

如果用户拖动到一半抬起了手指,让 UI 停留在半折叠状态是不合适的,应当根据具体位置自动完全折叠或完全展开。

实现思路不难,监听停止滑动事件,判断当前 RecyclerView 的偏移量,若超过一半就完全折叠否则就完全展开。这里需要借助 Scroller 实现动画。

Scroller 本质上是个计算器,你只需告诉它起始值、变化量、持续时间,就可以帮你算出任意时刻应该处于的位置,还可以定制不同缓动效果。通过高频率不断地计算不断地刷新不断地移动从而实现平滑动画。

OverScroller 包含了 Scroller 的全部功能并增加了额外功能,因此现在 Scroller 现在已被标注为弃用。

我们来修改一下 RV 对应的 NestedContentScrollBehavior.

    private lateinit var contentView: View // 其实就是 RecyclerView
    private var scroller: OverScroller? = null
    private val scrollRunnable = object : Runnable {
        override fun run() {
            scroller?.let { scroller ->
                if (scroller.computeScrollOffset()) {
                    contentView.translationY = scroller.currY.toFloat()
                    ViewCompat.postOnAnimation(contentView, this)
                }
            }
        }
    }

    override fun onLayoutChild(parent: CoordinatorLayout, child: View, layoutDirection: Int): Boolean {
        contentView = child
        // ...
    }

    private fun startAutoScroll(current: Int, target: Int, duration: Int) {
        if (scroller == null) {
            scroller = OverScroller(contentView.context)
        }
        if (scroller!!.isFinished) {
            contentView.removeCallbacks(scrollRunnable)
            scroller!!.startScroll(0, current, 0, target - current, duration)
            ViewCompat.postOnAnimation(contentView, scrollRunnable)
        }
    }

    private fun stopAutoScroll() {
        scroller?.let {
            if (!it.isFinished) {
                it.abortAnimation()
                contentView.removeCallbacks(scrollRunnable)
            }
        }
    }

首先定义三个变量并在合适的时候赋值。解释一下 scrollRunnable,在得到不同时间应该处于的不同位置后该怎么刷新 View 呢?因为滑动事件已经停止,我们得不到任何回调。王进喜说 没有条件就创造条件,这里通过 ViewCompat.postOnAnimation 让 View 在下一次绘制时执行定义好的 Runnable,在 Runnable 内部改变 View 位置,如果动画还没结束那么就再提交一个 Runnable,于是实现了连续不断的刷新。再写两个辅助函数便于开始和停止动画。

下面监听一下停止滑动的回调,根据情况来启动动画:

    override fun onStopNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, type: Int) {
        super.onStopNestedScroll(coordinatorLayout, child, target, type)
        if (child.translationY >= 0f || child.translationY <= -headerHeight) {
            // RV 已经归位(完全折叠或完全展开)
            return
        }
        if (child.translationY <= -headerHeight * 0.5f) {
            stopAutoScroll()
            startAutoScroll(child.translationY.toInt(), -headerHeight, 1000)
        } else {
            stopAutoScroll()
            startAutoScroll(child.translationY.toInt(), 0, 600)
        }
    }

最后完善一下,开始滑动时要停止动画,以免动画还没结束用户就迫不及待地又滑了一次:

    override fun onNestedPreScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dx: Int, dy: Int,
                                   consumed: IntArray, type: Int) {
        super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
        stopAutoScroll()
        // ...
    }

    override fun onNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dxConsumed: Int,
                                dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray) {
        super.onNestedScroll(coordinatorLayout, child, target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
                type, consumed)
        stopAutoScroll()
        // ...
    }

到这就完美啦!恭喜🎉

参考

Last modification:February 15, 2020