Experiencia sedosa: práctica de comportamiento personalizado de Android

Mostrar resultados

efecto de demostración de comportamiento (1).gif

Resumen del prospecto: leer este artículo requiere NestedScrolluna comprensión del conocimiento básico del deslizamiento anidado y la personalización simpleBehavior

Efecto congelar imagen

  1. A continuación se muestra una imagen de fondo, la barra de estado está inmersa y, a continuación, se muestra un RecyclerView

imagen.png

  1. A medida que el dedo empuja hacia arriba, Rv (RecyclerView) también empuja hacia arriba, y la transparencia del encabezado superior cambia gradualmente hasta que el límite superior de RV alcanza el límite inferior del encabezado y se muestra el encabezado.

imagen.png

  1. Continúe empujando hacia arriba, el encabezado se empuja hacia arriba con el RV, y la transparencia de la barra de título superior se vincula para cambiar, y la escala del encabezado se cambia al mismo tiempo

imagen.png

  1. Cuando el encabezado no (mo) ingresa a la barra de título, se mostrará el título y el RV continuará deslizándose. Si tira hacia abajo, cuando se agote la cantidad de desplazamiento del RV, volverá con el encabezado, al igual que el efecto previsualizado en GIF

imagen.png

realización del efecto

Análisis de ideas

De hecho, en la imagen congelada, parte de ella ha sido analizada y luego continúa analizando las ideas de diseño desde el punto de vista del código:

  1. RV debe tener una relación deslizante con el encabezado, por lo que, idealmente, están mejor en el mismo nivel y se pueden coordinar a través de CoordinatorLayout, porque hay distribuciones deslizantes anidadas en el mismo nivel que se pueden usar
  2. La vinculación de TitleBar depende de la promoción del encabezado. De hecho, de acuerdo con el movimiento del encabezado, el monitoreo puede exponerse al exterior, de modo que puede cambiar en consecuencia. Entonces está al mismo nivel que (Rv+ header).CoordinatorLayout, y se organiza lineal y verticalmente de
  3. El deslizamiento de Rv se puede dividir en tres etapas.
    • Desde la parte inferior hasta el límite inferior del encabezado: la altura de Rv está cambiando constantemente en esta etapa. Si la altura realmente cambia todo el tiempo, la medición completa será muy frecuente, y setLayoutParams manual puede fluctuar con la frecuencia del deslizamiento .换个思路Rv的初始y轴在下方,然后逐渐回到0,所以可以用translationY来操作这个效果
    • Siga empujando hacia arriba con el encabezado: en esta etapa, el encabezado debe cambiar sincrónicamente de acuerdo con el deslizamiento de Rv, deslizarse hacia arriba en 1dp y ambos deslizarse hacia arriba en 1dp al mismo tiempo. De acuerdo con la idea del punto anterior , aquí también usamos translationY para operar, y empujar hacia arriba el encabezado es 0~ -heightel cambio de
    • 当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
    }
}

¿No es sencillo? Por supuesto, el proceso de pensamiento fue tortuoso y probé onStartNestedScrollestados de animación al principio return false, pero el efecto no fue satisfactorio. Que no coordine el deslizamiento no quiere decir que no se deslice por sí mismo, por lo que al principio optamos por intervenir en la recepción global de todo deslizamiento vertical.

Luego, después de complementar de esta manera, todavía existe fling, 当快速甩动上滑时,会直接顺滑进入一起推动的状态por lo que la solución sigue siendo la misma, intervenir y bloquear

if (type != ViewCompat.TYPE_TOUCH) {
    if (child.translationY >= 0) {
        // 如果顶部header还在,那就屏蔽fling
        consumed[1] += dy
        return
    }
}

Comportamiento final

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)
    }
}

Supongo que te gusta

Origin juejin.im/post/7249909247037898813
Recomendado
Clasificación