Android 嵌套滚动NestedScrollView+TabLayout+ViewPager+Fragment+RecyclerView 实现京东、美团首页效果Tab页滚动到顶部时自动吸附

先上效果图(TabLayout滚动到顶部时自动吸附):

先看下布局:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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"
    tools:context=".nestedscroll.NestedScrollActivity">

    <com.zs.test.nestedscroll.ZSNestedScrollView
        android:id="@+id/activity_nested_scroll_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

            <View
                android:id="@+id/activity_nested_view"
                android:layout_width="match_parent"
                android:layout_height="200dp"
                android:background="@drawable/home_top_banner1" />

            <LinearLayout
                android:id="@+id/activity_nested_ll"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:orientation="vertical">

                <com.google.android.material.tabs.TabLayout
                    android:id="@+id/activity_nested_tb"
                    android:layout_width="match_parent"
                    android:layout_height="50dp"
                    android:background="#ff7f00"
                    app:tabSelectedTextColor="#0000ff"
                    app:tabTextColor="#00ff00" />

                <androidx.viewpager.widget.ViewPager
                    android:id="@+id/activity_nested_viewpager"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent" />

            </LinearLayout>
        </LinearLayout>
    </com.zs.test.nestedscroll.ZSNestedScrollView>

</LinearLayout>

其中 com.zs.test.nestedscroll.ZSNestedScrollView 是实现此效果的关键所在,其他都是常规的布局代码

package com.zs.test.nestedscroll

import android.content.Context
import android.os.Build
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import androidx.annotation.RequiresApi
import androidx.core.widget.NestedScrollView
import androidx.recyclerview.widget.RecyclerView
import com.zs.test.util.log

@RequiresApi(Build.VERSION_CODES.M)
class ZSNestedScrollView : NestedScrollView {
    /**
     * 顶部的view  id = activity_nested_view
     */
    private var topView: View? = null

    /**
     * 包裹TabLayout+RecyclerView 的 LinearLayout id = activity_nested_ll
     */
    private var contentView: ViewGroup? = null

    /**
     * 处理惯性滑动的工具类
     */
    private var mFlingHelper: FlingHelper? = null

    /**
     * 记录当前自身已经滑动的距离
     */
    var totalDy = 0

    /**
     * 用于判断RecyclerView是否在fling
     */
    var isRecyclerViewStartFling = false

    /**
     * 记录当前滑动的y轴加速度
     */
    private var velocityY = 0

    constructor(context: Context) : super(context) {
        init()
    }

    /**
     * 必须的构造函数,系统会通过反射来调用此构造方法完成view的创建
     */
    constructor(context: Context, attr: AttributeSet) : super(context, attr) {
        init()
    }

    constructor (context: Context, attr: AttributeSet, defZStyle: Int) : super(
        context,
        attr,
        defZStyle
    ) {
        init()
    }

    private fun init() {
        mFlingHelper = FlingHelper(context)
        //添加滚动监听 v就是当前NestedScrollLayout
        setOnScrollChangeListener { v, scrollX, scrollY, oldScrollX, oldScrollY ->
            if (isRecyclerViewStartFling) {
                totalDy = 0
                isRecyclerViewStartFling = false
            }
            //scrollY 是 当前向上滑动了多少 0 就是一点没滑动 就是在顶部状态
            if (scrollY == 0) {
                log("TOP SCROLL")
                // refreshLayout.setEnabled(true);
            }
            //v.measuredHeight() 就是屏幕高度

            if (scrollY == topView!!.measuredHeight) {
                log("BOTTOM SCROLL v.getMeasuredHeight() = " + v.measuredHeight)
                //滑动到底部以后 还有惯性让子类接着来滑动
                dispatchChildFling()
            }
            //在RecyclerView fling情况下,记录当前RecyclerView在y轴的偏移
            totalDy += scrollY - oldScrollY
        }
    }

    private fun dispatchChildFling() {
        if (velocityY != 0) {
            //将惯性的加速度转换为具体的距离
            val splineFlingDistance = mFlingHelper!!.getSplineFlingDistance(velocityY)
            //举例解释:假设用力滑动一下 能滑动100个单位的距离,totalDy是外层ZSNestedScrollView已经滑动的距离
            // 假设是50 那么还有50 咋办呢 ,要让子布局(RecycleView)来滑动剩下的50
            if (splineFlingDistance > totalDy) {
                childFling(
                    mFlingHelper!!.getVelocityByDistance(
                        splineFlingDistance - java.lang.Double.valueOf(
                            totalDy.toDouble()
                        )
                    )
                )
            }
        }
        //重置变量
        totalDy = 0
        velocityY = 0
    }

    private fun childFling(velY: Int) {
        if (contentView != null) {
            val childRecyclerView: RecyclerView? = getChildRecyclerView(contentView!!)
            childRecyclerView?.fling(0, velY)
        }
    }

    private fun getChildRecyclerView(viewGroup: ViewGroup): RecyclerView? {
        for (i in 0 until viewGroup.childCount) {
            val view = viewGroup.getChildAt(i)
            if (view is RecyclerView && view.javaClass == RecyclerView::class.java) {
                return viewGroup.getChildAt(i) as RecyclerView
            } else if (viewGroup.getChildAt(i) is ViewGroup) {
                val childRecyclerView: ViewGroup? =
                    getChildRecyclerView(viewGroup.getChildAt(i) as ViewGroup)
                if (childRecyclerView is RecyclerView) {
                    return childRecyclerView
                }
            }
            continue
        }
        return null
    }

    override fun fling(velocityY: Int) {
        super.fling(velocityY)
        if (velocityY <= 0) {
            this.velocityY = 0
        } else {
            isRecyclerViewStartFling = true
            this.velocityY = velocityY
        }
    }

    /**
     * view 加载完成后执行
     */
    override fun onFinishInflate() {
        super.onFinishInflate()
        topView = (getChildAt(0) as ViewGroup).getChildAt(0)
        contentView = (getChildAt(0) as ViewGroup).getChildAt(1) as ViewGroup
    }

    /**
     * 参数	解释
     * target	触发嵌套滑动的 view
     * dx	表示 view 本次 x 方向的滚动的总距离,单位:像素
     * dy	表示 view 本次 y 方向的滚动的总距离,单位:像素
     * consumed	输出:表示父布局消费的水平和垂直距离。
     * type	触发滑动事件的类型:其值有
     * ViewCompat. TYPE_TOUCH
     * ViewCompat. TYPE_NON_TOUCH
     *
     */
    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
//        log(getScrollY()+"::onNestedPreScroll::"+topView.getMeasuredHeight())
//        log( "dx="+ dx + " dy=" + dy)
        // 如果能继续向上滑动,就滑动
        val canScroll = dy > 0 && scrollY < topView!!.measuredHeight
        if (canScroll) {
            scrollBy(0, dy)
            consumed[1] = dy
        }
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        val lp = contentView!!.layoutParams
        // getMeasuredHeight() 得到的就是屏幕高度 当前ZSNestedScrollView高度是MatchParent
        log("onMeasure getMeasuredHeight() = $measuredHeight")
        lp.height = measuredHeight
        // 调整contentView的高度为屏幕高度,这样ZSNestedScrollView总高度就是屏幕高度+topView的高度
        // 因此往上滑动 滑完topView后,TabLayout就卡在顶部了,因为ZSNestedScrollView滑不动了啊,就这么高
        // 接着在滑就是其内部的RecyclerView去滑动了
        contentView!!.layoutParams = lp
    }

}

关键逻辑都加了注释,这里就简单总结下实现思路吧:

第一步:在RecyclerView区域滚动时 要实现能够整体滚动,而不是单独的RecyclerView自身在滚动,这个我们借助系统的力量就可以了,我们写一个ZSNestedScrollView 继承NestedScrollView即可。

第二步:实现TabLayout滚动到顶部时自动吸附在顶部保持不动,这里我们做一个巧妙的的设置:我们把TabLayout + ViewPager的总高度设置为屏幕的高度,这样ZSNestedScrollView总高度就是屏幕高度+topView的高度 ,因此往上滑动 滑完topView后,TabLayout就会呆在顶部了不动了,此时在让RecyclerView内部滑动就可以了,如下图所示:

 第三步:第二步高度固定后,在RecyclerView区域滚动时,将不能再整体滚动,于是我们要手动处理,当在RecyclerView区域往上滑时,主动判断父布局ZSNestedScrollView是否可以滑动,是的话,让父类滑动:

 这里dy表示view本次y方式的股东的总距离,单位像素,scrollY是ZSNestedScrollView Y轴上已经滑动的距离,小于topView的高度说明还有继续往上滑的空间

第四步:处理惯性滑动,用户在RecyclerView区域用力往上一滑的时候,TabLayout吸附到顶部以后,应该有继续滑动的效果来,这样更顺畅,体验更好,京东、美图都是这样实现的。

(1)对ZSNestedScrollView 滚动进行监听,滚动到底部时也就是TabLayout卡在顶部的时候,进行惯性处理

 (2)如果速度不为0(有惯性需要处理)并且ZSNestedScrollView没有将惯性处理完,则子类自己处理

(3)子类找到RecyclerView,调用其fling方法

 上面涉及到一个工具类FlingHelper 代码如下:

package com.zs.test.nestedscroll

import android.content.Context
import com.zs.test.nestedscroll.FlingHelper
import android.view.ViewConfiguration

class FlingHelper(context: Context) {
    private fun getSplineDeceleration(i: Int): Double {
        return Math.log(
            (0.35f * Math.abs(i)
                .toFloat() / (mFlingFriction * mPhysicalCoeff)).toDouble()
        )
    }

    private fun getSplineDecelerationByDistance(d: Double): Double {
        return (DECELERATION_RATE.toDouble() - 1.0) * Math.log(d / (mFlingFriction * mPhysicalCoeff).toDouble()) / DECELERATION_RATE.toDouble()
    }

    fun getSplineFlingDistance(i: Int): Double {
        return Math.exp(getSplineDeceleration(i) * (DECELERATION_RATE.toDouble() / (DECELERATION_RATE.toDouble() - 1.0))) * (mFlingFriction * mPhysicalCoeff).toDouble()
    }

    fun getVelocityByDistance(d: Double): Int {
        return Math.abs((Math.exp(getSplineDecelerationByDistance(d)) * mFlingFriction.toDouble() * mPhysicalCoeff.toDouble() / 0.3499999940395355).toInt())
    }

    companion object {
        private val DECELERATION_RATE = (Math.log(0.78) / Math.log(0.9)).toFloat()
        private val mFlingFriction = ViewConfiguration.getScrollFriction()
        private var mPhysicalCoeff: Float = 0.0f
    }

    init {
        mPhysicalCoeff = context.resources.displayMetrics.density * 160.0f * 386.0878f * 0.84f
    }
}

 以上就是所有核心代码和逻辑。

猜你喜欢

转载自blog.csdn.net/u013347784/article/details/122800676