在此文章开始之前,我想抛出一个问题:如何解决滑动冲突?
用传统的思路解决,你可能会从 View 的 onInterceptTouchEvent() 和 onTouchEvent() 方法入手,根据业务的情况以及手指滑动的方向,按需拦截事件来解决视图之间的滑动冲突。
这种思路没有错,可以完美解决视图之间的滑动冲突。
但这种思路有个局限,它无法解决嵌套滑动问题。
为什么呢?因为目前绝大多数的滚动组件(RecyclerView,ScrollView,ListView等),我们翻看它们的源码,都可以看到它们在处理 move 事件时有一段这样的代码:
parent.requestDisallowInterceptTouchEvent(true);
这段代码会禁止父控件拦截 move、up 事件。意味着一旦滚动组件的内容被拖动了,事件就被滚动组件接管了,父控件无法再通过 onInterceptTouchEvent() 拦截同一事件序列中剩余的事件,因此更不会走到 onTouchEvent() 中,处理自己滚动的逻辑了。
因此
父控件只能等待下一个事件序列到来,才能调用 onInterceptTouchEvent() 拦截事件让自己滚动。所以用 onInterceptTouchEvent() 和 onTouchEvent() 实现的嵌套滑动不够连贯,需要两次滑动操作。
而真正的嵌套滑动应该是下面这样的效果,只需要一次滑动操作,滑动在父控件与子控件之间无缝的切换,一气呵成。
要实现这样的效果,利用的是自 Android API 21 后新增的嵌套滑动 API,也是一种新的滑动冲突解决方案,本文就会为大家介绍实现过程中用到的嵌套滑动 API,以及如何一步步实现上面的效果。
简单介绍下嵌套滑动机制
嵌套滑动 API 自安卓 5.1 引入,从最初引入时的 NestedScrollParent,NestedScrollChild,到现在的 NestedScrollParent3,NestedScrollChild3,已经发展了3个版本,每次的更新基本都是方法参数的调整,没有新增什么方法,所以嵌套滑动的执行流程也没啥变化。
嵌套滑动机制它不像事件分发机制那般复杂,有各种分发、拦截过程。它更像一套接口,需要支持嵌套滑动的 View 则去实现,不需要的话,不用管就行了。
看一下接口中都有哪些方法
NestedScrollChild
public interface NestedScrollingChild {
void setNestedScrollingEnabled(boolean enabled);
boolean isNestedScrollingEnabled();
boolean startNestedScroll(@ScrollAxis int axes);
void stopNestedScroll();
boolean hasNestedScrollingParent();
boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);
boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
@Nullable int[] offsetInWindow);
boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
boolean dispatchNestedPreFling(float velocityX, float velocityY);
}
NestedScrollParent
public interface NestedScrollingParent {
boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
void onStopNestedScroll(@NonNull View target);
void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed);
void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);
boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);
boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);
int getNestedScrollAxes();
}
在嵌套滑动机制中有两个角色:NestedScrollParent,NestedScrollChild。
为方便描述,在后文中 NestedScrollParent 用简称 NSP 代指,NestedScrollChild 用 NSC 代指。
这两个角色实际上是两个接口,接口定义了不少方法需要 View 来实现。
View 可以单独实现 NSP 或者 单独实现 NSC,也可以同时实现两者。
在下面的 demo 中,我们会自定义一个 Layout 并实现 NSP3,充当 Parent的角色,使用 RecyclerView 充当 Child 的角色,因为 RecyclerView 的默认实现中已经实现了 NSC3,所以我们不需要自己实现。
所以本文着重讲述 NSP 接口执行流程和实现过程。
从上面的接口描述来看 NSP 接口的执行流程可能并不清晰,这里给张流程图方便理解:
从接口调用的时机来看,NSP 的接口由 NSC 调用,以 RecyclerView 为例,具体的实现过程主要包含在其 onTouchEvent() 方法中:
手指滑动的过程包含着一次 down 事件,一次 up 事件,和多个 move 事件。
当 down 事件传递给 Child 时,也即手指滑动的起始动作,手指按下屏幕的时候,Child 要询问一遍 Parent 是否一起处理本次事件,调用 Parent 的 onStartNestedScroll() 方法,如果 Parent 的 onStartNestedScroll() 方法返回 true,表示 Parent 要一起处理本次滑动事件序列,紧接着 Parent 的 onNestedScrollAccepted() 会被调用。反之,onStartNestedScroll() 方法返回 false,Parent 不需要一起处理本次事件序列,那么后续的事件 Parent 都不参与,直到下一个 down 事件的到来。
Parent 表示要一起处理滑动事件后,move 事件到达 Child 时,Child 会调用 Parent 的 onNestedPreScroll(),并将本次应该消费的滑动距离作为参数传入。Parent 可以在 onNestedPreScroll() 中消耗一定滑动距离,也可以完全不消耗。
Parent 执行完 onNestedPreScroll() 方法后,Child 会处理剩下的滑动距离,让自身滑动。
Child 执行完自己的滑动逻辑后,如果还有滑动距离没被消耗,那么会传入 Parent 的 onNestedScroll() 方法中,Parent 可以在该方法把剩下的滑动距离消耗。
最后 up 事件来时,手指已经抬起。如果此时手指滑动速度大于阈值,就会产生 fling 操作。
Child 进一步调用 Parent 的 onNestedPreFling() 和 onNestedFling() 方法,随后 Child 会模拟一般手指滑动的过程:顺序执行 onNestedStart(),onNestedScrollAccepted(),RecyclerView 自身滑动,onNestedPreScroll(),onNestedScroll() 过程,并向 Parent 的方法接口传入 type = TYPE_NON_TOUCH 表此时的嵌套滑动过程是通过 fling 模拟的。然后,Child 会调用 Parent 的 onStopNestedScroll(),告诉 Parent 本次滑动事件结束,嵌套滑动完毕。
如果手指滑动速度未超过阈值,不会产生 fling 操作,Child 直接调用 Parent 的 onStopNestedcroll() 嵌套滑动事件结束。
本文中 RecyclerView 就是那个 Child,自定义 Layout 则为 Parent。
本文不会详细介绍 RecyclerView 的源码,只会在源码的基础上,给出其嵌套滑动结论性的总结,有朋友对源码的具体实现有兴趣的话,欢迎自行阅读,本文基于 RecyclerView v1.2.1 版本的源码,你可以在工程中引入依赖阅读源码,具体实现的地方可参考其 onTouchEvent() 方法:
implementation "androidx.recyclerview:recyclerview:1.2.1"
以上就是实现本文效果需要知道的关于嵌套滑动的知识,有了这部分基础后,我们就可以用另一种方式来完美地是实现嵌套滑动。
接下来向大家一步步演示,如何实现开头时完美嵌套滑动的效果。
首先,先把布局的基础工作做了,让界面显示出来。
我们新建一个类,命名为 NestedOverScrollLayout,继承自 ViewGroup,NestedScrollingParent3,有许多未实现的接口,我们先放着,空实现。
open class NestedOverScrollLayout : ViewGroup, NestedScrollingParent3 {
private var mVelocityTracker = VelocityTracker.obtain()
private var mScroller = Scroller(context)
private var mParentHelper: NestedScrollingParentHelper? = null
private var mTouchSlop: Int = 0
private var mMinimumVelocity: Float = 0f
private var mMaximumVelocity: Float = 0f
private var mCurrentVelocity: Float = 0f
private val mMaxDragRate = 2.5f
private val mMaxDragHeight = 250
private val mScreenHeightPixels = context.resources.displayMetrics.heightPixels
private var mHandler: Handler? = null
private var mNestedInProgress = false
private var mIsAllowOverScroll = true
private var mPreConsumedNeeded = 0
private var mSpinner = 0f
private var mReboundAnimator: ValueAnimator? = null
private var mReboundInterpolator = ReboundInterpolator(INTERPOLATOR_VISCOUS_FLUID)
private var mAnimationRunnable: Runnable? = null
private var mVerticalPermit = false
private var mRefreshContent: View? = null
constructor(context: Context) : super(context) {
init()
}
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
init()
}
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
init()
}
private fun init() {
setWillNotDraw(false)
mHandler = Handler(Looper.getMainLooper())
mParentHelper = NestedScrollingParentHelper(this)
ViewConfiguration.get(context).let {
mTouchSlop = it.scaledTouchSlop
mMinimumVelocity = it.scaledMinimumFlingVelocity.toFloat()
mMaximumVelocity = it.scaledMaximumFlingVelocity.toFloat()
}
}
override fun onFinishInflate() {
super.onFinishInflate()
val childCount = super.getChildCount()
for (i in 0 until childCount) {
val childView = super.getChildAt(i)
if (SmartUtil.isContentView(childView)) {
mRefreshContent = childView
break
}
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
var minimumWidth = 0
var minimumHeight = 0
val thisView = this
for (i in 0 until super.getChildCount()) {
val childView = super.getChildAt(i)
if (childView == null || childView.visibility == GONE) continue
if (mRefreshContent == childView) {
mRefreshContent?.let { contentView ->
val lp = contentView.layoutParams
val mlp = lp as? MarginLayoutParams
val leftMargin = mlp?.leftMargin ?: 0
val rightMargin = mlp?.rightMargin ?: 0
val bottomMargin = mlp?.bottomMargin ?: 0
val topMargin = mlp?.topMargin ?: 0
val widthSpec = getChildMeasureSpec(
widthMeasureSpec,
thisView.paddingLeft + thisView.paddingRight + leftMargin + rightMargin, lp.width
)
val heightSpec = getChildMeasureSpec(
heightMeasureSpec,
thisView.paddingTop + thisView.paddingBottom + topMargin + bottomMargin, lp.height
)
contentView.measure(widthSpec, heightSpec)
minimumWidth += contentView.measuredWidth
minimumHeight += contentView.measuredHeight
}
}
}
minimumWidth += thisView.paddingLeft + thisView.paddingRight
minimumHeight += thisView.paddingTop + thisView.paddingBottom
super.setMeasuredDimension(
resolveSize(minimumWidth.coerceAtLeast(super.getSuggestedMinimumWidth()), widthMeasureSpec),
resolveSize(minimumHeight.coerceAtLeast(super.getSuggestedMinimumHeight()), heightMeasureSpec)
)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
val thisView = this
for (i in 0 until super.getChildCount()) {
val childView = super.getChildAt(i)
if (childView == null || childView.visibility == GONE) continue
if (mRefreshContent == childView) {
mRefreshContent?.let { contentView ->
val lp = contentView.layoutParams
val mlp = lp as? MarginLayoutParams
val leftMargin = mlp?.leftMargin ?: 0
val topMargin = mlp?.topMargin ?: 0
val left = leftMargin + thisView.paddingLeft
val top = topMargin + thisView.paddingTop
val right = left + contentView.measuredWidth
val bottom = top + contentView.measuredHeight
contentView.layout(left, top, right, bottom)
}
}
}
}
override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
return MarginLayoutParams(context, attrs)
}
override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {
return false
}
override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
}
override fun onStopNestedScroll(target: View, type: Int) {
}
override fun onNestedScroll(
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
type: Int,
consumed: IntArray
) {
}
override fun onNestedScroll(
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
type: Int
) {
}
override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
}
}
可以看到,类里面声明了很多属性,大部分现在还没有用到,不过现在加上,后面用到这些属性时,会再提到。
在 init() 方法中,作必要的初始化工作:
private fun init() {
mHandler = Handler(Looper.getMainLooper())
mParentHelper = NestedScrollingParentHelper(this)
ViewConfiguration.get(context).let {
mTouchSlop = it.scaledTouchSlop
mMinimumVelocity = it.scaledMinimumFlingVelocity.toFloat()
mMaximumVelocity = it.scaledMaximumFlingVelocity.toFloat()
}
}
比如初始化一个 Handler,这个 handler 主要是为了后文做动画更新 UI 用的,所以传入主线程的 looper 即可。
因为 NestedOverScrollLayout 需要支持嵌套滑动,并在嵌套滑动中扮演 Parent 的角色,所以还需要初始化一个 NestedScrollingParentHelper() 辅助完成嵌套滑动操作。
最后是初始化一些变量:mTouchSlop,mMinimumVelocity,mMaximumVelocity,得到最小滑动距离阈值和滑动速度阈值。
在布局加载结束时,找到可以滚动的 View 作为内容布局,并赋值给 mRefreshContent 属性。
override fun onFinishInflate() {
super.onFinishInflate()
val childCount = super.getChildCount()
for (i in 0 until childCount) {
val childView = super.getChildAt(i)
if (SmartUtil.isContentView(childView)) {
mRefreshContent = childView
break
}
}
}
object SmartUtil {
fun isScrollableView(view: View?): Boolean {
return view is AbsListView
|| view is ScrollView
|| view is ScrollingView
|| view is WebView
|| view is NestedScrollingChild
}
fun isContentView(view: View?): Boolean {
return isScrollableView(view)
|| view is ViewPager
|| view is NestedScrollingParent
}
}
onMeasure() 和 onLayout() 就根据 mRefreshContent 进行测量和布局。
为了简单起见,NestedOverScrollLayout 只会包含一个 RecyclerView,所以把这个 RecyclerView 的大小、位置测量了就行了。大家看代码估计也能懂,测量的细节就略过了。
有了这些方法后呢,在布局中使用 NestedOverScrollLayout 就应该有内容显示出来了。
我们新建一个 Activity,修改布局文件,给 RecyclerView 一些假数据:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/eventViewGroup"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.jamgu.home.viewevent.nested.NestedOverScrollLayout
android:id="@+id/eventView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#f1227f">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/vRecycler1"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center" />
</com.jamgu.home.viewevent.nested.NestedOverScrollLayout>
</FrameLayout>
看看效果。
接下来,我们希望在 RecyclerView 在内容滑动到边界时,将无法消耗的滑动距离,交给 NestedOverScrollLayout 处理,根据上文对 NSP 接口调用时机的分析,RecyclerView 处理完自身滑动后,剩下的距离会传入 NestedOverScrollLayout 的 onNestedScroll() 方法,因此接下来要实现这个方法。
实现 onNestedScroll():让 RecyclerView 处理完自身滑动逻辑后,将剩下无法消耗的滑动距离交给 NestedOverScrollLayout 处理
向 NestedOverScrollLayout 中实现或加如下方法:
override fun onNestedScroll(
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
type: Int
) {
if (type == ViewCompat.TYPE_TOUCH) {
onNestedScrollInternal(dyUnconsumed, type, null)
}
}
override fun onNestedScroll(
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
type: Int,
consumed: IntArray
) {
if (type == ViewCompat.TYPE_TOUCH) {
onNestedScrollInternal(dyUnconsumed, type, consumed)
} else {
consumed[1] += dyUnconsumed
}
}
@Synchronized
private fun onNestedScrollInternal(dyUnconsumed: Int, type: Int, consumed: IntArray?) {
if (dyUnconsumed == 0) return
val dy = dyUnconsumed
if (type == ViewCompat.TYPE_NON_TOUCH) {
if (consumed != null) {
consumed[1] += dy
}
} else {
if ((dy < 0 && mIsAllowOverScroll && WidgetUtil.canRefresh(mRefreshContent, null))
|| (dy > 0 && mIsAllowOverScroll && WidgetUtil.canLoadMore(
mRefreshContent,
null
))
) {
mPreConsumedNeeded -= dy
moveTranslation(computeDampedSlipDistance(mPreConsumedNeeded))
if (consumed != null) {
consumed[1] += dy
}
}
}
}
private fun moveTranslation(dy: Float) {
for (i in 0 until super.getChildCount()) {
super.getChildAt(i).translationY = dy
}
mSpinner = dy
}
private fun computeDampedSlipDistance(originTranslation: Int): Float {
if (originTranslation >= 0) {
val dragRate = 0.5f
val m = if (mMaxDragRate < 10) mMaxDragRate * mMaxDragHeight else mMaxDragRate
val h = (mScreenHeightPixels / 2).coerceAtLeast(this.height)
val x = (originTranslation * dragRate).coerceAtLeast(0f)
val y = m * (1 - 100f.pow(-x / (if (h == 0) 1 else h)))
return y
} else {
val dragRate = 0.5f
val m = if (mMaxDragRate < 10) mMaxDragRate * mMaxDragHeight else mMaxDragRate
val h = (mScreenHeightPixels / 2).coerceAtLeast(this.height)
val x = -(originTranslation * dragRate).coerceAtMost(0f)
val y = -m * (1 - 100f.pow(-x / if (h == 0) 1 else h))
return y
}
}
从代码中可以发现有两个 onNestedScroll() 方法,它俩只有参数的区别,一个有 type 参数,一个有 consumed 参数,另一个没有。没有 consumed 参数的方法在 NestedScrollParent2 接口中定义,有 consumed 参数的方法在 NestedScrollParent3 接口被定义。
consumed 参数的意义在于,可以将 Parent 消耗的滑动距离记录下来,使 Child 能够知道 Parent 消耗了多少滑动距离。
这两个方法最终都会走到我们定义的 onNestedScrollInternal() 方法中:
@Synchronized
private fun onNestedScrollInternal(dyUnconsumed: Int, type: Int, consumed: IntArray?) {
if (dyUnconsumed == 0) return
val dy = dyUnconsumed
if (type == ViewCompat.TYPE_NON_TOUCH) {
if (consumed != null) {
consumed[1] += dy
}
} else {
if ((dy < 0 && mIsAllowOverScroll && WidgetUtil.canRefresh(mRefreshContent, null))
|| (dy > 0 && mIsAllowOverScroll && WidgetUtil.canLoadMore(
mRefreshContent,
null
))
) {
mPreConsumedNeeded -= dy
moveTranslation(computeDampedSlipDistance(mPreConsumedNeeded))
if (consumed != null) {
consumed[1] += dy
}
}
}
}
因为我们是竖向的嵌套滑动,所以只需要关注竖向剩余的滑动距离 dyUnconsumed。
当 type = ViewCompat.TYPE_NON_TOUCH 时,表示当前嵌套滑动是因 fling 操作引起的,fling 引起的嵌套滑动我们不需要在此做太多处理,如果 fling 经过 Child 处理后,还有剩余的 fling 滑动距离,我们就直接消耗。
主要关注的还是 type = ViewCompat.TYPE_TOUCH 的情况,也即因手指拖动产生的嵌套滑动事件。
if ((dy < 0 && mIsAllowOverScroll && WidgetUtil.canRefresh(mRefreshContent, null))
|| (dy > 0 && mIsAllowOverScroll && WidgetUtil.canLoadMore(
mRefreshContent,
null
))
) {
mPreConsumedNeeded -= dy
moveTranslation(computeDampedSlipDistance(mPreConsumedNeeded))
if (consumed != null) {
consumed[1] += dy
}
}
dy > 0 表示手指当前拖动方向是向上的,如果此时 mRefreshContent,也就是我们的 RecyclerView 的内容不能再往上滚动时,WidgetUtil.canLoadMore(mRefreshContent, null) 会返回 true。WidgetUtil 是我之前封装的一个方法,大家不必纠结它的具体实现,它的细节也不在本文讨论范围内,大家直接拿来用即可,WidgetUtil代码地址在这里。
同样,当 dy < 0,手指当前拖动方向是向下的,如果 RecyclerView 内容不能再向下滚动时,WidgetUtil.canRefresh(mRefreshContent, null)) 方法会返回 true。
这两个条件合起来的意思是,如果当前 RecyclerView 已经滚动到内容边界且即将过度滑动时,会满足条件,进入 if 语句。
在 if 语句中,我们需要让 NestedOverScrollLayout 消耗掉当前未被消耗的 y 距离,也就是 dy。
mPreConsumedNeeded -= dy
mPreConsumedNeeded 属性存储的是 NestedOverScrollLayout 内容需要偏移的原始距离,其值大于0时,内容向下偏移,反之向上偏移。
因为进入到 if 语句中说明 RecyclerView 已经滑动到边界了,如果是达到 RecyclerView 的内容上边界,若此时手指是往下滑的动作,dy < 0,RecyclerView 内容无法向下滚动了,应该让 RecyclerView 整体向下移动 -dy 的距离。
怎么让 RecyclerView 整体向下移动呢?就是让 RecyclerView 的 translationY 属性减去一个 dy 的距离。但我们在偏移之前还需要作其它操作,因此先将这个距离存储在 mPreConsumedNeeded 属性中。
RecyclerView 滑动到内容下边界并且手指向上滑时的情况也是如此,dy > 0,应该让 RecyclerView 整体向上移动 dy 的距离,让 RecyclerView 的 translationY 属性减去一个 dy 的距离。
mPreConsumedNeeded 的意义搞清楚后,下一步就要修改 translationY 值让 RecyclerView 整体移动。
moveTranslation(computeDampedSlipDistance(mPreConsumedNeeded))
为了构造过度滑动时的阻尼效果,在移动 RecyclerView 前还行要经过 computeDampedSlipDistance() 方法的计算,用公式 y = M(1-100^(-x/H)) ,让 mPreConsumedNeeded 转换为一个阻尼的滑动距离。
y = M(1-100^(-x/H) 意义
简单解释一下公式 y = M(1-100^(-x/H)) 的意义:
这个公式中 m 和 h 都是常数,当我们假定 m = 625,h = 2000 时,函数曲线为:
当 x 不断增加,y 先是缓慢上升,但随着 x 越来越大,y 的值趋向平稳,最后不变。
对应阻尼滑动的效果来说就是,阻尼滑动时,滑动的距离速度乘递减形式,渐渐的滑动速度越来越慢,最终怎么滑都滑不动了。
得出阻尼滑动距离后,传入 moveTranslation() 方法,让 RecyclerView 偏移:
private fun moveTranslation(dy: Float) {
for (i in 0 until super.getChildCount()) {
super.getChildAt(i).translationY = dy
}
mSpinner = dy
}
方法也很简单,值得说明的是 mSpinner 这个属性,它与 mPreConsumedNeeded 类似,都是 NestedOverScrollLayout 的内容偏移距离,不同的是,mPreConsumedNeeded 不会直接用来偏移,还会经过一步阻尼计算。而 mSpinner 是内容真正偏移后的距离,它俩意义一样,但值是有区别的,正常来说 mSpinner 的绝对值会比 mPreConsumedNeeded 的绝对值大。
到此,我们已经可以让 RecyclerView 内容无法滑动时,整体过度滑动一段距离了.
最后,别忘了在 NestedOverScrollLayout 中实现下面这段代码,这些是让嵌套滑动能够工作的基础代码:
override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {
return axes and ViewCompat.SCROLL_AXIS_VERTICAL != 0
}
override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
mParentHelper?.onNestedScrollAccepted(child, target, axes, type)
}
override fun onStopNestedScroll(target: View, type: Int) {
mParentHelper?.onStopNestedScroll(target, type)
}
来看看效果。
大家如果一步一步跟着来实现,自己操作下会发现确实有阻尼滑动的效果,但目前滑动体验还不好,比如 RecyclerView 已经整体向下偏移一段距离后,手指向上滑动,此时应该先让 RecyclerView 整体位置恢复到原位后,再让 RecyclerView 的内容向上滚动,现在实现的过程明显反了过来,比如下面这样:
分析下为什么会产生这样的效果,我们此时实现的过度滑动处理时机是在 NestedOverScrollLayout 的 onNestedScroll() 方法中,这个方法在 RecyclerView 处理自身的滚动后才会被调用,如果在调用 onNestedScroll() 方法前,滑动距离已经被 RecyclerView 消耗完了,dyUnConsumed = 0,那么 onNestedScroll() 调用是不会有任何效果的,从我们的实现来看也确实如此:
@Synchronized
private fun onNestedScrollInternal(dyUnconsumed: Int, type: Int, consumed: IntArray?) {
if (dyUnconsumed == 0) return
...
}
而要实现 RecyclerView 整体位置恢复到原位后,再让 RecyclerView 的内容向上滚动,处理时机显然需要在 RecyclerView 消耗滑动距离前。
还记得上面的嵌套滑动实现流程图吗,在 RecyclerView 处理滑动前有一个方法:onNestedPreScroll() ,这个方法会 RecyclerView 消耗滑动距离前调用,在这里实现完美符合我们的要求。
实现 onNestedPreScroll():RecyclerView 整体位置恢复到原位后,再让 RecyclerView 的内容向上滚动。
如果读了上文,你对 mPreConsumedNeeded 意义还是懵懂的状态,那么阅读下面的代码后,相信会加深你对该属性的理解。
override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
if (dy == 0) return
if (type == ViewCompat.TYPE_TOUCH) {
val consumedY: Int
if (mPreConsumedNeeded * dy < 0) {
consumedY = dy
mPreConsumedNeeded -= dy
moveTranslation(computeDampedSlipDistance(mPreConsumedNeeded))
} else {
val lastConsumedNeeded = mPreConsumedNeeded
if (dy.absoluteValue > mPreConsumedNeeded.absoluteValue) {
consumedY = mPreConsumedNeeded
mPreConsumedNeeded = 0
} else {
consumedY = dy
mPreConsumedNeeded -= dy
}
if (lastConsumedNeeded != mPreConsumedNeeded) {
moveTranslation(computeDampedSlipDistance(mPreConsumedNeeded))
}
}
consumed[1] = consumedY
}
}
直接看 if(mPreConsumedNeeded * dy < 0) 这段,如果 mPreConsumedNeeded 与 dy 相乘小于0,说明这两者的值一正一负。我们知道,dy > 0 时手指时向上滑动的,而 mPreConsumedNeeded < 0 时 RecyclerView 整体是有一个向上的偏移量的。
这时候应该顺着手指滑动的方向,将 dy 完整地消耗掉,加剧过度滑动。
consumedY = dy
mPreConsumedNeeded -= dy
moveTranslation(computeDampedSlipDistance(mPreConsumedNeeded))
反之,如果 mPreConsumedNeeded 与 dy 不同号,此时在竖直方向上有一个过度滑动的距离,就应该复原过度滑动。
举个例子,mPreConsumedNeeded < 0 && dy < 0,RecyclerView 整体向上偏移,手指向下滑动,应该先让 RecyclerView 的整体位置向下移动直至复原,然后再让 RecyclerView 内容滚动。
所以需要先将 mPreConsumedNeeded 消耗掉。
如果当前 dy 的值足够消耗掉 mPreConsumedNeeded,则让 consumedY 直接等于 mPreConsumedNeeded,如果不够的话,就让 consumedY 等于 dy。
最后调用 moveTranslation() 让 RecyclerView 整体偏移。
consumed[1] = consumed ,将在 NestedOverScrollLayout(Parent)这里消耗的距离保存,以便 RecyclerView(Child) 后续能够知道被消耗了多少距离。
跑一下,看看效果:
可以看到,一个基本的嵌套滑动效果已经有了,但目前 NestedOverScrollLayout 在过度滑动后,手指脱离屏幕时无法自己回到初始状态。
接下来就完善这点体验,让 NestedOverScrollLayout 每次过度滑动后都能自己回弹到初始位置。
在 NestedOverScrollLayout 中新建一个方法 animSpinner() :
private fun animSpinner(
endSpinner: Float,
startDelay: Long,
interpolator: Interpolator?,
duration: Long
): ValueAnimator? {
if (mSpinner != endSpinner) {
JLog.d(TAG, "start anim")
mReboundAnimator?.let {
it.duration = 0
it.cancel()
}
mAnimationRunnable = null
val endListener = object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
if (animation != null && animation.duration == 0L) {
return
}
mReboundAnimator?.let {
it.removeAllUpdateListeners()
it.removeAllListeners()
}
mReboundAnimator = null
}
}
val updateListener = ValueAnimator.AnimatorUpdateListener {
val spinner = it.animatedValue as? Int ?: 0
moveTranslation(spinner.toFloat())
}
ValueAnimator.ofInt(mSpinner.roundToInt(), endSpinner.roundToInt())
.also { mReboundAnimator = it }.let {
it.duration = duration
it.interpolator = interpolator
it.startDelay = startDelay
it.addListener(endListener)
it.addUpdateListener(updateListener)
it.start()
}
return mReboundAnimator
}
return null
}
通过方法注释也可以看出,这个方法是通过 ValueAnimator 模拟手指的滑动操作,使 NestedOverScrollLayout 内容移动到指定的偏移位置 endSpinner。
具体过程呢,就是通过 ValueAnimator.ofInt(mSpinner, endSpinner) 方法获得从 mSpinner 到 endSpinner 一个连续的插值,这个插值也就是 NestedOverScrollLayout 的内容偏移量,最后通过 moveTranslation() 让 NestedOverScrollLayout 移动内容。
我们可以通过传入不同的 interpolator 插值器来控制复原过程的速率变化,ValueAnimator 默认使用的 LinearInterpolator() 线性插值器,匀速地执行动画。
在 NestedOverScrollLayout 的开头,我们定义了一个自己实现的插值器,我们将用这个来模拟一个速度递减的动画过程。
private var mReboundInterpolator = ReboundInterpolator(INTERPOLATOR_VISCOUS_FLUID)
大家不必关心 ReboundInterpolator 类中具体是如何实现,只需要知道,通过这个插值器,能让 NestedOverScrollLayout 内容复原过程呈一个速度递减的状态。直接拿来用即可,具体源码地址
如果我们传入的 endSpinner 为 0,那么就是让 NestedOverScrollLayout 的内容偏移量恢复到 0 的位置,也即初始位置。
在 NestedOverScrollLayout 中定义一个方法:
private fun overSpinner() {
animSpinner(0f, 0, mReboundInterpolator, 1000)
}
并在 onStopNestedScroll() 方法中调用它,onStopNestedScroll() 方法在每次嵌套滑动结束时都会被调用,在这里让 NestedOverScrollLayout 内容复原正合适:
override fun onStopNestedScroll(target: View, type: Int) {
mParentHelper?.onStopNestedScroll(target, type)
overSpinner()
}
同样,运行,看看效果如何:
这下体验好很多了,不过还是有 bug,如果我们在 NestedOverScrollLayout 内容还没有回滚动到初始化状态时,再次用手下滑或上滑,回滚动画会出现奇怪的效果。这是因为上一个回滚动画还没有结束,我们就进行了滑动的操作,导致 translationY 的数据错乱,出现连续滑动不连贯,内容甚至跳跃的现象。
因此我们需要在下一次手指滑动时,终止掉上一次没完成的回滚动画,继续优化。
优化快速滑动时,内容跳跃的 bug
在 NestedOverScrollLayout 中维护一个属性 mNestedInProgress,如果 NestedOverScrollLayout 当前正在处理嵌套滑动,该属性为 true,反之为 false。
override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
mParentHelper?.onNestedScrollAccepted(child, target, axes, type)
mPreConsumedNeeded = reverseComputeFromDamped2Origin(mSpinner)
mNestedInProgress = true
}
override fun onStopNestedScroll(target: View, type: Int) {
mParentHelper?.onStopNestedScroll(target, type)
mNestedInProgress = false
overSpinner()
}
然后实现 NestedOverScrollLayout 的 dispatchTouchEvent() 方法:
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
ev ?: return false
if (mNestedInProgress) {
return super.dispatchTouchEvent(ev)
}
return super.dispatchTouchEvent(ev)
}
mNestedInProgress 属性主要是为了确保正常的嵌套滑动逻辑得以运行。其为 true 时,调用 super.dispatchTouchEvent() 让 action 按照默认逻辑下发给 RecyclerView,按照默认逻辑 RecyclerView 会在 onTouchEvent() 中调用 NestedOverScrollLayout 的嵌套滑动方法。
mPreConsumedNeeded = reverseComputeFromDamped2Origin(mSpinner)
同时在下一次嵌套滑动开始时,需要重置 mPreConsumedNeeded 的值。
还记得 mPreConsumedNeeded 属性的意义吗,我们每次嵌套滑动 NestedOverScrollLayout 的内容时,是先将 mPreConsumedNeeded 经过计算得到阻尼滑动值,再进行内容的移动,然后将移动后偏移量 translationY 赋值给 mSpinner。所以从 mSpinner 转变成 mPerConsumedNeeded 需要一个逆阻尼计算的过程,reverseComputeFromDamped2Origin() 方法就是做这样一件事。
怎么逆阻尼计算呢?我们用公式 y = M(1-100^(-x/H) ,将 原始移动距离 x 转换成阻尼移动距离 y,只需将公式反过来,根据对数与指数的转换公式,已知 y,逆推导 x,得到另一条公式 X = -H * log((1 - y / m), 100) ,运用这条公式就可以进行逆阻尼计算了。
private fun reverseComputeFromDamped2Origin(dampedDistance: Float): Int {
return if (dampedDistance >= 0) {
val dragRate = 0.5f
val m = if (mMaxDragRate < 10) mMaxDragRate * mMaxDragHeight else mMaxDragRate
val h = (mScreenHeightPixels / 2).coerceAtLeast(this.height)
val y = dampedDistance
JLog.d(TAG, "reverse ${(-h * log((1 - y / m), 100f))}")
((-h * log((1 - y / m), 100f)) / dragRate).roundToInt()
} else {
val dragRate = 0.5f
val m = if (mMaxDragRate < 10) mMaxDragRate * mMaxDragHeight else mMaxDragRate
val h = (mScreenHeightPixels / 2).coerceAtLeast(this.height)
val y = -dampedDistance
-((-h * log((1 - y / m), 100f)) / dragRate).roundToInt()
}
}
随后我们在 NestedOverScrollLayout 中新实现一个方法:
private fun interceptReboundByAction(action: Int): Boolean {
if (action == MotionEvent.ACTION_DOWN) {
mReboundAnimator?.let {
it.duration = 0
it.cancel()
}
mReboundAnimator = null
}
return mReboundAnimator != null
}
并在 dispatchTouchEvent() 和 onNestedScrollAccepted() 方法中调用它:
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
ev ?: return false
if (mNestedInProgress) {
return super.dispatchTouchEvent(ev)
}
val action = ev.actionMasked
if (interceptReboundByAction(action)) {
return false
}
return super.dispatchTouchEvent(ev)
}
override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
JLog.d(TAG, "onNestedScrollAccepted")
mParentHelper?.onNestedScrollAccepted(child, target, axes, type)
mPreConsumedNeeded = reverseComputeFromDamped2Origin(mSpinner)
mNestedInProgress = true
interceptReboundByAction(MotionEvent.ACTION_DOWN)
}
interceptReboundByAction(action) 返回 true 时,表此时正在执行回弹动画,会让 dispatchTouchEvent() 方法直接返回 false,该事件也不会分发给其下级 RecyclerView,而是上抛给其上级 ViewGroup,意味着 NestedOverScrollLayout 不处理该事件,让回弹动画继续执行。
interceptReboundByAction(action) 返回 false 时,意味着此时 NestedOverScrollLayout 没有在执行回弹动画,让事件正常下发。没有执行回弹动画可能是因为本来之前也没有滑动操作触发该动画,也可能是因为新到来的事件是一个全新的滑动事件,需要终止之前未完成的回弹动画。
运行一下,看看效果:
快速滑动很多下都没有问题,NestedOverScrollLayout 更加完善了。
但现在还有一个问题,我们的 NestedOverScrollLayout 不支持 Fling 操作,一般来说,当手指在屏幕上以很快的速度滑动时,手指离开后,内容应该按照手指滑动的惯性,再滑动一定距离,具体效果如下:
而目前的滑动体验是这样的,手指脱离屏幕后,内容就不动了:
接下来的内容,就为了优化这一点,也是最后一段优化。
让 NestedOverScrollLayout 支持 Fling 操作
在 NestedOverScrollLayout 中添加如下方法:
override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean {
return startFlingIfNeed(-velocityY)
}
private fun startFlingIfNeed(flingVelocity: Float): Boolean {
val velocity = if (flingVelocity == 0f) mCurrentVelocity else flingVelocity
if (velocity.absoluteValue > mMinimumVelocity) {
if (velocity < 0 && mIsAllowOverScroll && mSpinner == 0f
|| velocity > 0 && mIsAllowOverScroll && mSpinner == 0f
) {
mScroller.fling(0, 0, 0, (-velocity).toInt(), 0, 0, -Int.MAX_VALUE, Int.MAX_VALUE)
mScroller.computeScrollOffset()
val thisView: View = this
thisView.invalidate()
}
}
return false
}
override fun computeScroll() {
if (mScroller.computeScrollOffset()) {
val finalY = mScroller.finalY
if (finalY < 0 && WidgetUtil.canRefresh(mRefreshContent, null)
|| finalY > 0 && WidgetUtil.canLoadMore(mRefreshContent, null)
) {
if (mVerticalPermit) {
val velocity = if (finalY > 0) -mScroller.currVelocity else mScroller.currVelocity
animSpinnerBounce(velocity)
}
mScroller.forceFinished(true)
} else {
mVerticalPermit = true
val thisView = this
thisView.invalidate()
}
}
}
protected fun animSpinnerBounce(velocity: Float) {
if (mReboundAnimator == null) {
JLog.d(TAG, "animSpinnerBounce = $mSpinner")
if (mSpinner == 0f && mIsAllowOverScroll) {
mAnimationRunnable = BounceRunnable(velocity, 0)
}
}
}
protected inner class BounceRunnable internal constructor(var mVelocity: Float, var mSmoothDistance: Int) :
Runnable {
var mFrame = 0
var mFrameDelay = 10
var mLastTime: Long
var mOffset = 0f
override fun run() {
if (mAnimationRunnable === this) {
mVelocity *= if (abs(mSpinner) >= abs(mSmoothDistance)) {
if (mSmoothDistance != 0) {
0.45.pow((++mFrame * 2).toDouble()).toFloat()
} else {
0.85.pow((++mFrame * 2).toDouble()).toFloat()
}
} else {
0.95.pow((++mFrame * 2).toDouble()).toFloat()
}
val now = AnimationUtils.currentAnimationTimeMillis()
val t = 1f * (now - mLastTime) / 1000
val velocity = mVelocity * t
if (abs(velocity) >= 1) {
mLastTime = now
mOffset += velocity
moveTranslation(computeDampedSlipDistance(mOffset.roundToInt()))
mHandler?.postDelayed(this, mFrameDelay.toLong())
} else {
mAnimationRunnable = null
if (abs(mSpinner) >= abs(mSmoothDistance)) {
val duration = 10L * (abs(mSpinner - mSmoothDistance).dp2px(context))
.coerceAtLeast(30).coerceAtMost(100)
animSpinner(mSmoothDistance.toFloat(), 0, mReboundInterpolator, duration)
}
}
}
}
init {
mLastTime = AnimationUtils.currentAnimationTimeMillis()
mHandler?.postDelayed(this, mFrameDelay.toLong())
}
}
手指快速滑动产生 fling 操作后,RecyclerView 在执行自身的 fling 逻辑前,会先调用 NestedOverScrollLayout 的 onNestedPreFling() 方法,我们可以在这里模拟 NestedOverScrollLayout 的 fling 操作。
具体实现过程是利用 Scroller.fling() 方法,通过将 fling 的 y 速度传入该方法,该方法会通过 y 速度得到 NestedOverScrollLayout fling 最终会到达的位置,然后调用 NestedOverScrollLayout 的 invalidate() 方法让 Layout 重绘,重绘过程中会调用 Layout 的 computeScroll() 方法。
在 computeScroll() 方法,有一个递归调用:
if (finalY < 0 && WidgetUtil.canRefresh(mRefreshContent, null)
|| finalY > 0 && WidgetUtil.canLoadMore(mRefreshContent, null)
) {
if (mVerticalPermit) {
val velocity = if (finalY > 0) -mScroller.currVelocity else mScroller.currVelocity
animSpinnerBounce(velocity)
}
mScroller.forceFinished(true)
} else {
mVerticalPermit = true
val thisView = this
thisView.invalidate()
}
如果子 View,也就是 RecyclerView 在 fling 的过程中还没有到达内容边界,那么就会再调用一次 Layout 的 invalidate() 方法,invalidate() 方法最终又会调用 computeScroll()。
如此反复,直到 RecyclerView fling 到内容边界时,这个递归调用才会终止,并调用 animSpinnerBounce(velocity) 开始让 NestedOverScrollLayout 模拟过度滑动再回弹到初始位置的过程。
animSpinnerBounce(velocity) 方法里的内容我就不详细介绍了,相信大家根据代码中的注释,自己思考下,应该能够理解。
最后,你得到的就是文章开头的效果:
DEMO 的拓展使用场景
在本文的 demo 中,NestedOverScrollLayout 的子 View 中只有一个 RecyclerView,实际上,你可以拓展它,让它同时支持更多的子 View,需要做的修改就是让 NestedOverScrollLayout 的 onMeasure() 和 onLayout() 方法能够适配多个子 View 的情况。
在日常开发中,我们经常会碰到列表上拉加载和下拉刷新的场景。这个 demo 就是这种场景的简化版,只需要让 NestedOverScrollLayout 最多能够支持三个子 View,分别是最上面的 HeaderView,最下面的 FooterView,以及中间的 ContentView(RecyclerView)。
通过监听当前内容的移动距离,是否达到上拉加载或下拉刷新的移动阈值来做进一步的 UI 变化和业务拉取,比如下面这样:
上面的效果图来自 SmartRefreshLayout 刷新组件库,实际上,本篇实现的 DEMO 也是参考自该库,只不过我简化了很多东西,是这个库的究极简化版,也是这个库的核心内容之一。希望大家看完本篇内容后,都能够实现一个自己的刷新组件库,
源码地址在这,有需要的朋友自取。本篇内容就到此结束了,希望你对你有所帮助!
?
兄dei,如果觉得我写的还不错,麻烦帮个忙呗 😃
- 给俺点个赞被,激励激励我,同时也能让这篇文章让更多人看见,(#.#)
- 不用点收藏,诶别点啊,你怎么点了?这多不好意思!
- 噢!还有,我维护了一个路由库。。没别的意思,就是提一下,我维护了一个路由库 =.= !!
拜托拜托,谢谢各位同学!
|