CoordinatorLayout 的今生前世

联动效果

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

 

                                                  一起动才够嗨!Android CoordinatorLayout 自定义 Behavior_android

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

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

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

                                    一起动才够嗨!Android CoordinatorLayout 自定义 Behavior_android_02

 

 

这是仿三星 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​​ (响应事件)或作为被监听的对象。

一起动才够嗨!Android CoordinatorLayout 自定义 Behavior_java_03

一起动才够嗨!Android CoordinatorLayout 自定义 Behavior_安卓_04

 

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

                                                一起动才够嗨!Android CoordinatorLayout 自定义 Behavior_java_05

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

实战:仿三星 One UI

再贴一遍效果图:

 

                                               一起动才够嗨!Android CoordinatorLayout 自定义 Behavior_android_02

先看看布局:

<?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 停留在半折叠状态是不合适的,应当根据具体位置自动完全折叠或完全展开。

                                      一起动才够嗨!Android CoordinatorLayout 自定义 Behavior_java_07

 

 

实现思路不难,监听停止滑动事件,判断当前 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()
// ...
}

到这就完美啦!恭喜????