现代化的 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 效果的糅合,毫无疑问应该从触摸事件上入手。简单起见暂时只考虑手指向上滑动(列表向下展现更多内容),大概须要进行如下操做:函数
onInterceptTouchEvent
中拦截事件。onTouchEvent
处理事件,对 HeaderView 进行操做(移动、改变透明度等)。如今已经遇到问题了。由于一开始父布局拦截了事件,所以根据 Android 事件分发机制,哪怕后续再也不拦截其子控件也没法收到事件,除非从新触摸,这就形成了二者的滑动不能无缝衔接。布局
接着还有一个问题,反过来当 RecyclerView 向下滑动至顶部时,如何通知 HeaderView 展开?post
哪怕解决了上述主要问题,确定还有其余小毛病,例如子控件没法触发点击事件等等等很是恼人💢。假设你是大佬完美解决了全部问题,确定耦合特别严重,又是自定义 View 又是互相引用的乱七八糟😵 因此如今就不往下深究了,有闲情雅致有能力的同窗能够尝试实现。学习
从 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?彷佛依然有点麻烦而且解耦不完全。
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 所对应的子控件)这有什么用呢?经过一开始的例子来看看吧。
再贴一遍效果图:
先看看布局:
<?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 的 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 的偏于与透明度,经过乘以一个系数来实现视差移动。
到此为止已经基本上实现了上述效果。
若是用户拖动到一半抬起了手指,让 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()
// ...
}
复制代码
到这就完美啦!恭喜🎉