一块儿动才够嗨!Android CoordinatorLayout 自定义 Behavior

CoordinatorLayout 的此生前世

联动效果

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

这种搭配的教程已经很是多了,不是本文的重点。在使用 xml 时候确定很多同窗掉过一个坑:界面主要内容与头部元素重叠了!粗略了解一下由于 CoordinatorLayout 的布局方式相似 FrameLayout 默认状况下全部元素都会叠加在一块儿,解决方案也很是玄学,就是给内容元素添加一个 app:layout_behavior="@string/appbar_scrolling_view_behavior" 属性就行了,简直像黑魔法!android

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

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

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

事件拦截实现

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

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

如今已经遇到问题了。由于一开始父布局拦截了事件,所以根据 Android 事件分发机制,哪怕后续再也不拦截其子控件也没法收到事件,除非从新触摸,这就形成了二者的滑动不能无缝衔接。布局

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

哪怕解决了上述主要问题,确定还有其余小毛病,例如子控件没法触发点击事件等等等很是恼人💢。假设你是大佬完美解决了全部问题,确定耦合特别严重,又是自定义 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()
        // ...
    }
复制代码

到这就完美啦!恭喜🎉

参考

相关文章
相关标签/搜索