其他
丝滑体验:Android自定义Behavior实践
Calculus_小王的博客地址:
https://juejin.cn/user/1033973970513326/posts
从下方到header下边界:这个阶段Rv的高度在不断变化,如果真的一直改变高度的话那整个的测量就会变得非常频繁,而且手动setLayoutParams按滑动的频率可能发生抖动。换个思路Rv的初始y轴在下方,然后逐渐回到0,所以可以用translationY来操作这个效果 和header一直上推:这个阶段header需要根据Rv发生的滑动,作同步的变化,上滑1dp,两者同时上滑1dp,根据上一点的思路,这里我们也用translationY来操作,对于header上推就是从0~ -height的变化 当header没入后:这个阶段就是单纯的自身滑动的过程了,没有任何压力
当header没入后:这个阶段也是Rv自身滑动的阶段,所以可以通过computeVerticalScrollOffset判断自身是否有可以下滑的量,如果够用,那就自己滑就可以了,如果不够,那就需要将自身和header一起下推 header还未固定:这时就是上面的第二种情况,header需要一直滑动到自身translationY为0为止 header固定后:这时就是一开始的相反情况,调整Rv的Y轴就好了
Rv的最大高度应该是从TitleBar以下的全部 header是初始固定在TitleBar下的
滑动阻尼,也就是认为滑动不到位,需要复位,如果到位就需要帮助触达。其实就是在阶段1时,松手的情况,不希望Rv停留在该位置,而是只有两种状态:展开|收缩 Rv滑动到最下面就不能滑动了,类似于BottomSheet的Peek差不多 ……
最外层是一个CoordinatorLayout,当然这个没必要,替换成FrameLayout也是一样的 背景图就一张铺满的图片 TitleBar简单一点是个TextView,这里固定高度了,因为下面需要MarginTop做的垂直排布,所以最外层改成LinearLayout也是可以的 CoordinatorLayout来负责header和Rv的滑动协调 header是一个比较简单的组合
<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>
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
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
}
}
// 上滑
// 初始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 {
// 推完了剩下就自己滑就好了
}
}
}
// 下拉
(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
}
}
}
}
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, "配送中")
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
}
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?) {
}
if (animaState) {
// 动画正在执行中,所有滑动全部吞掉
consumed[1] += dy
return
}
}
if (child.translationY >= 0) {
// 如果顶部header还在,那就屏蔽fling
consumed[1] += dy
return
}
}
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)
}
}