丝滑体验:Android自定义Behavior实践
效果展示
前景提要:本文的阅读需要基于NestedScroll
嵌套滑动的基本知识有所了解,并且简单自定义过Behavior
效果定格图片
- 底下是一张背景图,状态栏作了沉浸效果,然后下面是一个RecyclerView
- 随着手指向上推动,Rv(RecyclerView)也随之上推,顶部的header的透明度逐渐变化,直到RV的上边界达到header的下边界为止,header展示完毕
- 继续向上推动,header随着RV一起上推,并联动顶部TitleBar发生透明度变化,同时header发生了scale变化
- 当header没(mo)入TitleBar后,标题随之展示,RV继续滑动,如果向下拉的话,当RV滚动量用完之后,会带着header一块回去,一如GIF中预览的效果一般
效果实现
思路分析
其实在定格图片中,已经分析了一部分了,接着从代码角度继续剖析一下设计思路:
- RV需要和header有滑动关系,那么理想来说,他们最好是同级的,可以通过CoordinatorLayout来协调,因为其中有同级嵌套滑动的分发可以使用
- TitleBar的联动依赖于header的推动情况,那其实可以根据header的移动,对外暴露监听,使其可以随之变化,那它与(Rv+header).CoordinatorLayout是同级的,且是线性垂直排布的
- Rv的上滑可以拆分为三个阶段
- 从下方到header下边界: 这个阶段Rv的高度在不断变化,如果真的一直改变高度的话那整个的测量就会变得非常频繁,而且手动setLayoutParams按滑动的频率可能发生抖动。
换个思路Rv的初始y轴在下方,然后逐渐回到0,所以可以用translationY来操作这个效果
- 和header一直上推: 这个阶段header需要根据Rv发生的滑动,作同步的变化,上滑1dp,两者同时上滑1dp,根据上一点的思路,这里我们也用translationY来操作,对于header上推就是从
0~ -height
的变化 - 当header没入后: 这个阶段就是单纯的自身滑动的过程了,没有任何压力
- 从下方到header下边界: 这个阶段Rv的高度在不断变化,如果真的一直改变高度的话那整个的测量就会变得非常频繁,而且手动setLayoutParams按滑动的频率可能发生抖动。
- Rv的下滑也可以拆分为三个阶段
- 当header没入后: 这个阶段也是Rv自身滑动的阶段,所以可以通过
computeVerticalScrollOffset
判断自身是否有可以下滑的量,如果够用,那就自己滑就可以了,如果不够,那就需要将自身和header一起下推
- header还未固定: 这时就是上面的第二种情况,header需要一直滑动到自身
translationY为0为止
- header固定后: 这时就是一开始的相反情况,调整Rv的Y轴就好了
- 当header没入后: 这个阶段也是Rv自身滑动的阶段,所以可以通过
- 由滑动分析可以得出两个结论
- Rv的最大高度应该是从TitleBar以下的全部
- header是初始固定在TitleBar下的
- 然后还有一些小细节需要注意
- 滑动阻尼,也就是认为滑动不到位,需要复位,如果到位就需要帮助触达。其实就是在阶段1时,松手的情况,不希望Rv停留在该位置,而是只有两种状态:
展开|收缩
- Rv滑动到最下面就不能滑动了,类似于
BottomSheet的Peek
差不多 - ……
- 滑动阻尼,也就是认为滑动不到位,需要复位,如果到位就需要帮助触达。其实就是在阶段1时,松手的情况,不希望Rv停留在该位置,而是只有两种状态:
布局
- 最外层是一个CoordinatorLayout,当然这个没必要,替换成FrameLayout也是一样的
- 背景图就一张铺满的图片
- TitleBar简单一点是个TextView,这里固定高度了,因为下面需要MarginTop做的垂直排布,所以最外层改成LinearLayout也是可以的
- CoordinatorLayout来负责header和Rv的滑动协调
- header是一个比较简单的组合
<?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">
<ImageView
android:id="@+id/map"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitXY"
android:src="@drawable/img_1" />
<TextView
android:id="@+id/titleBar"
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_gravity="top"
android:alpha="0"
android:background="@color/color_yellow"
android:gravity="center|bottom"
android:paddingBottom="10dp" />
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="bottom"
android:layout_marginTop="50dp"
android:orientation="vertical">
<LinearLayout
android:id="@+id/orderStatusLine"
android:layout_width="match_parent"
android:layout_height="100dp"
android:layout_gravity="top"
android:alpha="0"
android:background="@color/white"
android:gravity="center_vertical"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="配送中"
android:textColor="@color/common_text_main_black"
android:textSize="@dimen/Big_text_size"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="骑手正在快马加鞭的配送中,请您耐心等待"
android:textColor="@color/common_text_main_black"
android:textSize="@dimen/Big_text_size"
android:textStyle="bold" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:nestedScrollingEnabled="true"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/orderStatusLine" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
代码
给Rv填充数据和一些基础操作就不写了,直接上正文内容。一共分为两部分,一部分是初始化时布局中的设置,另一部分是负责协调的自定义Behavior
// 这个就是最大的PEEK
view.recyclerView.translationY = OrderStatusBehavior.MAX_PEEK
// 这个放下一段代码
val behavior = OrderStatusBehavior(this)
// 这是个自定义的监听
behavior.listener = object : OrderStatusBehavior.OrderStatusListener {
// 这里就是TitleBar和header的互动
private val AIM_PERCENT = 0.7f
override fun onHeaderMove(percent: Float, title: String) {
// 这个监听顾名思义一下,header的移动程度,通过percent表示,上推过程中percent逐渐变大到1,下滑最小到固定时为0
// 这里就是TitleBar中何时显示文字了,这里的阈值判断是header移动到70%
if (percent >= AIM_PERCENT && view.titleBar.text.isEmpty()) {
view.titleBar.text = title
} else if (percent < AIM_PERCENT && view.titleBar.text.isNotEmpty()) {
view.titleBar.text = ""
}
// 这是透明度协调
view.titleBar.alpha = percent
}
}
// 这里绑定behavior,当然xml中也是一样可以绑定的(原理:根据路径反射实例化并绑定),但反正还要设置监听,那就放代码里吧
(view.orderStatusLine.layoutParams as? CoordinatorLayout.LayoutParams)?.behavior = behavior
然后就是重头戏,自定义Behavior,很多人对这玩意儿很害怕,搞不清楚它的原理,一开始我也是,但自己上手写一下后发现还挺有意思的,最终的Behavior贴在最后,先跟着我一步步慢慢写吧
一开始,非常简单,三个方法,其中最为重要的就是layoutDependsOn
决定了与谁进行协调,这里简单通过类型进行判断一下就好。然后既然要协调滑动,那就是嵌套滑动中两个老生常谈的方法,何时开始:onStartNestedScroll
,只要是垂直方向的,我们都要;第一次询问,预滚动onNestedPreScroll
,我们的思路就是在预滚动阶段处理我们需要手动判断的,而正式滚动阶段就由Rv自己做就好了,我们无须关心
class OrderStatusBehavior @JvmOverloads constructor(context: Context, attributeSet: AttributeSet? = null) : CoordinatorLayout.Behavior<View>(context, attributeSet), Animator.AnimatorListener {
override fun layoutDependsOn(parent: CoordinatorLayout, child: View, dependency: View): Boolean {
return dependency is RecyclerView
}
// child是自身;target是协调的目标view;dx\dy是x\y轴的滑动,向右为x轴u正方向,向下为y轴正方向,可以尝试画图辅助理解
// consumed是消费数组,[x,y]记录了x\y轴的滑动消费情况,如果需要消费,那就需要记录
// 如果不消费的话,那么不管你怎么滑,Rv自身在后续环节还会自身滑动,因为没有消费完
override fun onNestedPreScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
if (dy > 0) {
// 上滑
……
} else {
// 下拉
……
}
}
// child是自身,directTargetChild发起嵌套滑动的view,target也是
override fun onStartNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, directTargetChild: View, target: View, axes: Int, type: Int): Boolean {
// 位运算,取vertical位,即垂直滑动
return axes.and(ViewCompat.SCROLL_AXIS_VERTICAL) != 0
}
}
然后我们开始尝试填充onNestedPreScroll
中的内容,先是上滑。主旨思想就是translationY
位移和consumed[1]
消费。当然有些代码也可以再优化一下,这里只是跟着写的思路一块过一遍
if (dy > 0) {
// 上滑
// 初始y为0,上推过程中会逐步变到-height
// 初始,y被设置为一个常量,MAX_PEEK = 1300f
val y = target.translationY
// 上推时,translationY会<0,所以以此判断header是否还固定在原位
if (child.translationY >= 0 && y > child.height) {
// 如果header固定时,那childY就是第一阶段中,rv的上界
if (dy < y - child.height) {
// 滑动距离不足以使得rv达到上界,即滑动距离 < rv与header的之间的距离
// 此时,使得rv改变Y轴即可
target.translationY = y - dy
// 记录消费
consumed[1] += dy
} else {
// 如果一次滑动量很大,那就先让rv抵达header处,并消费全部
// 这里其实是个简化,理论上 下一个分发阶段需要处理,这里偷懒直接忽略
target.translationY = child.height.toFloat()
consumed[1] += dy
}
} else {
// 准备一起推
if (y > 0) {
// 还没把header推完
if (y - dy >= 0) {
// 也还推不倒头,就一起动
// 这里target.translationY -= dy是一样的,我是因为既然y都记录了,索性用了
target.translationY = y - dy
child.translationY -= dy
consumed[1] += dy
} else {
// 先把剩下的推推完
// header其实也可以直接设置-child.height,当然这里-y是异曲同工
child.translationY -= y
// rv推到头,就是y位移为0
target.translationY = 0f
// 这里是重头戏啊,因为一起推的距离是rv剩余的y位移,剩下多余的是需要交给下一轮让rv自行去推的
// 所以这也是为什么header为什么-y更好也更恰当
consumed[1] += y.toInt()
}
// ……这是一起推的阶段,还需要header进行一些scale和对外位移情况的暴露,先不关注
} else {
// 推完了剩下就自己滑就好了
}
}
}
接着是下拉的过程,这块就没有上推时那么多情况了,直接开干。其中强调了一个概念:过度消费
,虽然过度不好,但是这时是我们所期望的,因为fling也会带来滑动,如果太丝滑,滑动的阶段性就没法体现
else {
// 下拉
(target as? RecyclerView)?.let {
val offsetY = it.computeVerticalScrollOffset()
if (offsetY > 0) {
// 说明原来已经滑动过了,因为前面的推动都是translationY变化,影响不到它自身
// 这里写了两个判断,但是没作处理,是因为…做处理的话就会太丝滑了,在fling状态下就会忽闪忽闪的
// 所以我们的思路是,过度消费,也就全全由rv自己先去滑,因为它最多也就滑到header消失时刻的状态
if (offsetY + dy < 0) {
// 滑动的多了
} else {
// target自己可以处理
}
} else {
if (target.translationY >= MAX_PEEK) {
// 已经到底了,不允许继续下拉了,你可以尝试不加这个,看看效果Hh
return
}
if (target.translationY - dy > MAX_PEEK) {
// 拉过头就没了,这个同上,都是对PEEK_HEIGHT的兜底
// 对了,对于这个PEEK需要设置多少,你可以通过rv的height-需要露出的height得出
target.translationY = MAX_PEEK
return
}
// header的translationY标志着它的情况
if (child.translationY < 0) {
// 需要把header一块滑下来
if (child.translationY < dy) { // 因为带有方向,所以这两个都是负数,你需要理解成距离会更加合适
// 滑动距离不足以滑完header,那就一起动
child.translationY -= dy
target.translationY -= dy
consumed[1] += dy
} else {
// 如果够滑完的话,header就需要固定住了,把剩余的translationY滑掉
// 这里也是过度消费的思路,因为滑动距离过剩了,但我们希望先拉到固定贴合的状态先
// 而不是直接就下去了,太丝滑会不太好
// 不信邪的可以试试hhh
target.translationY -= child.translationY
child.translationY = 0f
consumed[1] += dy
}
// ……这是一起推的阶段,还需要header进行一些scale和对外位移情况的暴露,先不关注
} else {
// header已经固定好了,那就自己滑好了
target.translationY -= dy
consumed[1] += dy
}
}
}
}
把主体完成之后,header和rv的协调已经完成了,接着实现一些其他的互动。前面在一起推的上下两处留下了注释,现在填进去吧
companion object {
const val MAX_PEEK = 1300f
const val ALPHA_SPEED = 3f * 100
const val ANIM_DURATION = 300L
const val SCALE_PERCENT = 0.15f
}
var listener: OrderStatusListener? = null
interface OrderStatusListener {
fun onHeaderMove(percent: Float, title: String)
}
// 上推
val percent = -child.translationY / child.height
child.scaleX = 1 - percent * SCALE_PERCENT
listener?.onHeaderMove(percent, "配送中")
// 下拉
val percent = -child.translationY / child.height
child.scaleX = 1 - percent * SCALE_PERCENT
listener?.onHeaderMove(percent, "配送中")
还有一个header的透明度渐变,为了避免onNestedPreScroll
中的复杂度,将其抽离到onDependentViewChanged
中,当然写在滑动的地方也是一样的。因为透明度变化是对于上推\下拉均需处理,所以干脆抽象为对于rv的移动
override fun onDependentViewChanged(parent: CoordinatorLayout, child: View, dependency: View): Boolean {
if (child.translationY >= 0) {
// header固定状态下
// diff得出的就是rv顶部与header下边界的相差距离,也就是还差多少可以进入下一阶段
// ALPHA_SPEED是一个阈值距离,就是多少距离开始进入渐变状态
val diff = dependency.translationY - child.height
if (diff < ALPHA_SPEED && diff >= 0) {
// 这里转化为百分比
child.alpha = (ALPHA_SPEED - diff) / ALPHA_SPEED
} else if (diff >= ALPHA_SPEED) {
child.alpha = 0f
} else {
child.alpha = 1f
}
}
return true
}
做到了这一步,那剩下就是第一阶段滑动但未进入下一阶段时松手的问题了,这需要借助onStopNestedScroll
的帮助。根据滑动结束时的位置判断,需要执行何种动画,并标记动画状态
override fun onStopNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, type: Int) {
if (type == ViewCompat.TYPE_TOUCH) {
// 仅处理touch,区别与not_touch,如fling
super.onStopNestedScroll(coordinatorLayout, child, target, type)
val childY = child.height.toFloat()
val y = target.translationY
if (y < MAX_PEEK && y > childY) {
// 处于在中间状态中,即第一阶段状态
// 这里判别阈值设置了一半,也可以根据需要自行调整
val mid = (MAX_PEEK + childY) / 2f
if (y > mid) {
// 回缩
peekViewAnim(target, y, MAX_PEEK)
} else {
// 展开
peekViewAnim(target, y, childY)
}
}
}
}
private fun peekViewAnim(view: View, start: Float, end: Float) {
if (animaState) {
return
}
animaState = true
val anim = ObjectAnimator.ofFloat(view, "translationY", start, end)
anim.duration = ANIM_DURATION
anim.addListener(this)
anim.start()
}
private var animaState = false
override fun onAnimationStart(animation: Animator?) {
}
override fun onAnimationEnd(animation: Animator?) {
animaState = false
}
override fun onAnimationCancel(animation: Animator?) {
}
override fun onAnimationRepeat(animation: Animator?) {
}
为什么需要标记动画状态,这是一个非常有意思的命题。因为当你执行动画时,虽然touch结束了,但如fling的not_touch
还会触发,如果它继续走入onNestedPreScroll
那就会发生画面的抖动,到这里你已经可以运行试试了。那如何进行屏蔽呢,巧用过度消费的理念
override fun onNestedPreScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
if (animaState) {
// 动画正在执行中,所有滑动全部吞掉
consumed[1] += dy
return
}
}
是不是很简单。当然,思考的过程是曲折的,我一开始尝试onStartNestedScroll
对于动画状态return false
,但效果并不理想。因为不进行协调滑动,不代表它自身不进行滑动,所以一开始我们选择对所有垂直方向滑动全盘接收进行干预
然后这样补充了之后,还是存在fling
,当快速甩动上滑时,会直接顺滑进入一起推动的状态
,所以解决的思路还是如出一辙,进行干预阻塞
if (type != ViewCompat.TYPE_TOUCH) {
if (child.translationY >= 0) {
// 如果顶部header还在,那就屏蔽fling
consumed[1] += dy
return
}
}
最终的Behavior
class OrderStatusBehavior @JvmOverloads constructor(context: Context, attributeSet: AttributeSet? = null) : CoordinatorLayout.Behavior<View>(context, attributeSet), Animator.AnimatorListener {
companion object {
const val MAX_PEEK = 1300f
const val ALPHA_SPEED = 3f * 100
const val ANIM_DURATION = 300L
const val SCALE_PERCENT = 0.15f
}
var listener: OrderStatusListener? = null
override fun layoutDependsOn(parent: CoordinatorLayout, child: View, dependency: View): Boolean {
return dependency is RecyclerView
}
override fun onDependentViewChanged(parent: CoordinatorLayout, child: View, dependency: View): Boolean {
if (child.translationY >= 0) {
// header固定状态下
// diff得出的就是rv顶部与header下边界的相差距离,也就是还差多少可以进入下一阶段
// ALPHA_SPEED是一个阈值距离,就是多少距离开始进入渐变状态
val diff = dependency.translationY - child.height
if (diff < ALPHA_SPEED && diff >= 0) {
// 这里转化为百分比
child.alpha = (ALPHA_SPEED - diff) / ALPHA_SPEED
} else if (diff >= ALPHA_SPEED) {
child.alpha = 0f
} else {
child.alpha = 1f
}
}
return true
}
// child是自身;target是协调的目标view;dx\dy是x\y轴的滑动,向右为x轴u正方向,向下为y轴正方向,可以尝试画图辅助理解
// consumed是消费数组,[x,y]记录了x\y轴的滑动消费情况,如果需要消费,那就需要记录
// 如果不消费的话,那么不管你怎么滑,Rv自身在后续环节还会自身滑动,因为没有消费完
override fun onNestedPreScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
if (animaState) {
// 动画正在执行中,所有滑动全部吞掉
consumed[1] += dy
return
}
if (type != ViewCompat.TYPE_TOUCH) {
if (child.translationY >= 0) {
// 如果顶部header还在,那就屏蔽fling
consumed[1] += dy
return
}
}
if (dy > 0) {
// 上滑
// 初始y为0,上推过程中会逐步变到-height
// 初始,y被设置为一个常量,MAX_PEEK = 1300f
val y = target.translationY
// 上推时,translationY会<0,所以以此判断header是否还固定在原位
if (child.translationY >= 0 && y > child.height) {
// 如果header固定时,那childY就是第一阶段中,rv的上界
if (dy < y - child.height) {
// 滑动距离不足以使得rv达到上界,即滑动距离 < rv与header的之间的距离
// 此时,使得rv改变Y轴即可
target.translationY = y - dy
// 记录消费
consumed[1] += dy
} else {
// 如果一次滑动量很大,那就先让rv抵达header处,并消费全部
// 这里其实是个简化,理论上 下一个分发阶段需要处理,这里偷懒直接忽略
target.translationY = child.height.toFloat()
consumed[1] += dy
}
} else {
// 准备一起推
if (y > 0) {
// 还没把header推完
if (y - dy >= 0) {
// 也还推不倒头,就一起动
// 这里target.translationY -= dy是一样的,我是因为既然y都记录了,索性用了
target.translationY = y - dy
child.translationY -= dy
consumed[1] += dy
} else {
// 先把剩下的推推完
// header其实也可以直接设置-child.height,当然这里-y是异曲同工
child.translationY -= y
// rv推到头,就是y位移为0
target.translationY = 0f
// 这里是重头戏啊,因为一起推的距离是rv剩余的y位移,剩下多余的是需要交给下一轮让rv自行去推的
// 所以这也是为什么header为什么-y更好也更恰当
consumed[1] += y.toInt()
}
// ……这是一起推的阶段,还需要header进行一些scale和对外位移情况的暴露,先不关注
val percent = -child.translationY / child.height
child.scaleX = 1 - percent * SCALE_PERCENT
// child.scaleY = 1 - percent
listener?.onHeaderMove(percent, "配送中")
} else {
// 推完了剩下就自己滑就好了
}
}
} else {
// 下拉
(target as? RecyclerView)?.let {
val offsetY = it.computeVerticalScrollOffset()
if (offsetY > 0) {
// 说明原来已经滑动过了,因为前面的推动都是translationY变化,影响不到它自身
// 这里写了两个判断,但是没作处理,是因为…做处理的话就会太丝滑了,在fling状态下就会忽闪忽闪的
// 所以我们的思路是,过度消费,也就全全由rv自己先去滑,因为它最多也就滑到header消失时刻的状态
if (offsetY + dy < 0) {
// 滑动的多了
} else {
// target自己可以处理
}
} else {
if (target.translationY >= MAX_PEEK) {
// 已经到底了,不允许继续下拉了,你可以尝试不加这个,看看效果Hh
return
}
if (target.translationY - dy > MAX_PEEK) {
// 拉过头就没了,这个同上,都是对PEEK_HEIGHT的兜底
// 对了,对于这个PEEK需要设置多少,你可以通过rv的height-需要露出的height得出
target.translationY = MAX_PEEK
return
}
// header的translationY标志着它的情况
if (child.translationY < 0) {
// 需要把header一块滑下来
if (child.translationY < dy) { // 因为带有方向,所以这两个都是负数,你需要理解成距离会更加合适
// 滑动距离不足以滑完header,那就一起动
child.translationY -= dy
target.translationY -= dy
consumed[1] += dy
} else {
// 如果够滑完的话,header就需要固定住了,把剩余的translationY滑掉
// 这里也是过度消费的思路,因为滑动距离过剩了,但我们希望先拉到固定贴合的状态先
// 而不是直接就下去了,太丝滑会不太好
// 不信邪的可以试试hhh
target.translationY -= child.translationY
child.translationY = 0f
consumed[1] += dy
}
// ……这是一起推的阶段,还需要header进行一些scale和对外位移情况的暴露,先不关注
val percent = -child.translationY / child.height
child.scaleX = 1 - percent * SCALE_PERCENT
// child.scaleY = 1 - percent
listener?.onHeaderMove(percent, "配送中")
} else {
// header已经固定好了,那就自己滑好了
target.translationY -= dy
consumed[1] += dy
}
}
}
}
}
// child是自身,directTargetChild发起嵌套滑动的view,target也是
override fun onStartNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, directTargetChild: View, target: View, axes: Int, type: Int): Boolean {
// 位运算,取vertical位,即垂直滑动
return axes.and(ViewCompat.SCROLL_AXIS_VERTICAL) != 0
}
override fun onStopNestedScroll(coordinatorLayout: CoordinatorLayout, child: View, target: View, type: Int) {
if (type == ViewCompat.TYPE_TOUCH) {
// 仅处理touch,区别与not_touch,如fling
super.onStopNestedScroll(coordinatorLayout, child, target, type)
val childY = child.height.toFloat()
val y = target.translationY
if (y < MAX_PEEK && y > childY) {
// 处于在中间状态中,即第一阶段状态
// 这里判别阈值设置了一半,也可以根据需要自行调整
val mid = (MAX_PEEK + childY) / 2f
if (y > mid) {
// 回缩
peekViewAnim(target, y, MAX_PEEK)
} else {
// 展开
peekViewAnim(target, y, childY)
}
}
}
}
private fun peekViewAnim(view: View, start: Float, end: Float) {
if (animaState) {
return
}
animaState = true
val anim = ObjectAnimator.ofFloat(view, "translationY", start, end)
anim.duration = ANIM_DURATION
anim.addListener(this)
anim.start()
}
private var animaState = false
override fun onAnimationStart(animation: Animator?) {
}
override fun onAnimationEnd(animation: Animator?) {
animaState = false
}
override fun onAnimationCancel(animation: Animator?) {
}
override fun onAnimationRepeat(animation: Animator?) {
}
interface OrderStatusListener {
fun onHeaderMove(percent: Float, title: String)
}
}
转载自:https://juejin.cn/post/7249909247037898813