丝滑体验:Android自定义Behavior实践

效果展示

behavior演示效果 (1).gif

前景提要:本文的阅读需要基于NestedScroll嵌套滑动的基本知识有所了解,并且简单自定义过Behavior

效果定格图片

  1. 底下是一张背景图,状态栏作了沉浸效果,然后下面是一个RecyclerView

image.png

  1. 随着手指向上推动,Rv(RecyclerView)也随之上推,顶部的header的透明度逐渐变化,直到RV的上边界达到header的下边界为止,header展示完毕

image.png

  1. 继续向上推动,header随着RV一起上推,并联动顶部TitleBar发生透明度变化,同时header发生了scale变化

image.png

  1. 当header没(mo)入TitleBar后,标题随之展示,RV继续滑动,如果向下拉的话,当RV滚动量用完之后,会带着header一块回去,一如GIF中预览的效果一般

image.png

效果实现

思路分析

其实在定格图片中,已经分析了一部分了,接着从代码角度继续剖析一下设计思路:

  1. RV需要和header有滑动关系,那么理想来说,他们最好是同级的,可以通过CoordinatorLayout来协调,因为其中有同级嵌套滑动的分发可以使用
  2. TitleBar的联动依赖于header的推动情况,那其实可以根据header的移动,对外暴露监听,使其可以随之变化,那它与(Rv+header).CoordinatorLayout是同级的,且是线性垂直排布的
  3. Rv的上滑可以拆分为三个阶段
    • 从下方到header下边界: 这个阶段Rv的高度在不断变化,如果真的一直改变高度的话那整个的测量就会变得非常频繁,而且手动setLayoutParams按滑动的频率可能发生抖动。换个思路Rv的初始y轴在下方,然后逐渐回到0,所以可以用translationY来操作这个效果
    • 和header一直上推: 这个阶段header需要根据Rv发生的滑动,作同步的变化,上滑1dp,两者同时上滑1dp,根据上一点的思路,这里我们也用translationY来操作,对于header上推就是从0~ -height的变化
    • 当header没入后: 这个阶段就是单纯的自身滑动的过程了,没有任何压力
  4. Rv的下滑也可以拆分为三个阶段
    • 当header没入后: 这个阶段也是Rv自身滑动的阶段,所以可以通过computeVerticalScrollOffset判断自身是否有可以下滑的量,如果够用,那就自己滑就可以了,如果不够,那就需要将自身和header一起下推
    • header还未固定: 这时就是上面的第二种情况,header需要一直滑动到自身translationY为0为止
    • header固定后: 这时就是一开始的相反情况,调整Rv的Y轴就好了
  5. 由滑动分析可以得出两个结论
    • Rv的最大高度应该是从TitleBar以下的全部
    • header是初始固定在TitleBar下的
  6. 然后还有一些小细节需要注意
    • 滑动阻尼,也就是认为滑动不到位,需要复位,如果到位就需要帮助触达。其实就是在阶段1时,松手的情况,不希望Rv停留在该位置,而是只有两种状态:展开|收缩
    • Rv滑动到最下面就不能滑动了,类似于BottomSheet的Peek差不多
    • ……

布局

  1. 最外层是一个CoordinatorLayout,当然这个没必要,替换成FrameLayout也是一样的
  2. 背景图就一张铺满的图片
  3. TitleBar简单一点是个TextView,这里固定高度了,因为下面需要MarginTop做的垂直排布,所以最外层改成LinearLayout也是可以的
  4. CoordinatorLayout来负责header和Rv的滑动协调
  5. 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)
    }
}

猜你喜欢

转载自juejin.im/post/7249909247037898813