联动效果
现代化的 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 效果的糅合,毫无疑问应该从触摸事件上入手。简单起见暂时只考虑手指向上滑动(列表向下展示更多内容),大概需要进行以下操作:
- 在父布局
onInterceptTouchEvent
中拦截事件。 - 父布局
onTouchEvent
处理事件,对 HeaderView 进行操作(移动、改变透明度等)。 - 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()
// ...
}
到这就完美啦!恭喜🎉
文章每周持续更新,可以微信搜索「 程序猿养成中心 」第一时间阅读和催更(比博客早一到两篇哟),另外“点击公众号下方面试/更多资料”,直接免费获取一二线互联网企业Android开发岗面试题汇总(答案解析)以及Android架构知识点汇总pdf+超清Android进阶思维脑图。
网友评论