先上效果图(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
}
}
?以上就是所有核心代码和逻辑。
|