前言
日常开发中,下拉刷新列表是一个常见的不能再常见的需求了,github上也有很多成熟了下拉刷新库可供学习,但如果要刷新的列表被ScrollView或者RecyclerView包围,可能很多传统的下拉刷新的实现就没办法使用了,那我们如何在嵌套滑动中和谐共处地实现下拉刷新呢?
先看最终效果图:
基础知识——嵌套滚动
按照使用习惯,用户会在手机屏幕上使用上下或者左右滑动手势来滚动页面或者列表,但如果在同一个界面有多个控件可以滑动时,如何协调多个控件响应用户操作是一个颇为复杂的问题。一般来说,我们可以在控件中实现dispatchTouchEvent(), onTouchEvent(), onInterceptTouchEvent()三连击来控制滑动操作,但这个实现方式有个漏洞,如果Touch事件传递过程中,某个View获得处理Touch事件机会,那么其他View就再也没有机会去处理这个Touch事件了,直到下一次手指再按下,也就是说,这种方法无法让多个View协同处理一个滑动事件。
这时候,Google霸霸就跳出来给我们指出了一条明路——NestedScrolling机制。NestedScrolling可以很好地解决嵌套控件中滑动事件的拦截、分发和使用的问题,使用NestedScrolling后的效果如下:
可以看到,顶部的AppBar和下面的ListView都是可以滑动的,使用了NestedScrolling后,只有ListView下滑到顶部时,AppBar才会响应下滑事件。
NestedScrolling的想法并不复杂,它会把嵌套的控件分为父View和子View,控件接收到的每个滑动事件都分开几个阶段通知父View,父View决定事件的处理,并负责把处理完的事件分发给子View,层层传递下去,直到滑动事件消耗完:
场景一:
- 子View:爸爸,我准备在x轴方向滑动50px,有什么吩咐没
- 父View:好的,没什么吩咐的,你滑吧。
- 子View:遵命!滑动ing...... 爸爸,我滑完了,总共滑了50px。
- 父View:好的,记得每次都要提前汇报!
场景二:
- 子View:爸爸,我准备在x轴方向滑动50px,有什么吩咐没
- 父View:你x轴的50px我要全部没收,你别动了
- 子View:纳尼 w(゚Д゚)w 好吧谁让你是爸爸...
具体到代码实现,Google提供了NestedScrollingParent和NestedScrollingChild两个接口,如果自定义的View要当父亲来嵌套子View,那么请实现NestedScrollingParent,如果要当儿子被父View包含,请实现NestedScrollingChild,大多数情况下,我们自定义的View最好同时继承NestedScrollingParent和NestedScrollingChild来提高使用时的灵活性。
我们先来看看NestedScrollingParent和NestedScrollingChild:
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);
@ScrollAxis
int getNestedScrollAxes();
}
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);
}
可以看出Parent和Child很多方法名都是接近的,Google霸霸怕我们处理不好这些事件的分发,还贴心的给我们提供了两个对应的辅助类:NestedScrollingParentHelper和NestedScrollingChildHelper,如果要写一个简单的NestedScrolling对象,只需要调用NestedScrollingHelper对应的方法就可以了,例如:
class NestedScrollingView extends ViewGroup implements NestedScrollingChild{
private NestedScrollingChildHelper mChildHelper = new NestedScrollingChildHelper(this);
//...
@Override
boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow){
return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed,
dxUnconsumed, dyUnconsumed,offsetInWindow)
}
}
滑动事件传递简要流程图如下(不包含Fling事件):
关于NestedScrollingParent和NestedScrollingChild方法参数和返回值的详细说明可以参考这里,接下来会结合下拉刷新的需求来实现具体代码。
Lollipop及以上版本的所有View都已经支持了NestedScroll机制,Lollipop之前版本可以通过Support包进行向前兼容。
此外,26.0.0中NestedScroll得到了加强,对嵌套滚动的API做了些改进,出现了新的接口NestedScrollingParent2和NestedScrollingChild2。新的接口在部分方法之上添加了一个新的参数 type ,type参数告诉你是什么类型的输入在驱动scroll事件,目前可以是这两种选项之一:ViewCompat.TYPE_TOUCH 和ViewCompat.TYPE_NON_TOUCH。详细可以参考这里,下文将使用新的API。
实现需求——下拉刷新
要在NestScrolling的基础上实现下拉刷新功能,我们首先定义一个继承ViewGroup的CustomRefreshLayout控件,并重写其中的onMeasure和onLayout方法,实现对滑动控件和刷新控件的宽高测量和具体布局工作:
注:下面的代码使用了Kotlin语言编写,语法不复杂不会影响阅读体验,了解更多关于Kotlin的内容可以看我这篇文章
public class CustomRefreshLayout : ViewGroup {
private var mTarget: View? = null // 被滑动的控件
private var mSpinner: FrameLayout by Delegates.notNull() // 刷新控件
init {
mSpinner = FrameLayout(context)
addView(mSpinner)
}
//...
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
if (mTarget == null) {
ensureTarget()
}
if (mTarget == null) {
return
}
mTarget?.measure(MeasureSpec.makeMeasureSpec(
measuredWidth - paddingLeft - paddingRight,
MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(
measuredHeight - paddingTop - paddingBottom, MeasureSpec.EXACTLY))
mSpinner.measure(
MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.AT_MOST),
MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.AT_MOST))
mOriginalSpinnerOffsetTop = -(mSpinner.measuredHeight)
mSpinnerIndex = -1
// 获取刷新控件的序号
for (index in 0 until childCount) {
if (getChildAt(index) === mSpinner) {
mSpinnerIndex = index
break
}
}
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
val width = measuredWidth
val height = measuredHeight
if (childCount == 0) {
return
}
if (mTarget == null) {
ensureTarget()
}
val child = mTarget ?: return
val childLeft = paddingLeft
var childTop = paddingTop
val childWidth = width - paddingLeft - paddingRight
val childHeight = height - paddingTop - paddingBottom
child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight)
val spinnerWidth = mSpinner.measuredWidth
val spinnerHeight = mSpinner.measuredHeight
mSpinner.layout(width / 2 - spinnerWidth / 2, mCurrentSpinnerOffsetTop,
width / 2 + spinnerWidth / 2, mCurrentSpinnerOffsetTop + spinnerHeight)
}
private fun ensureTarget() {
// 确保要处理滑动的控件存在
if (mTarget == null) {
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child != mSpinner) {
mTarget = child
break
}
}
}
}
}
这其中有一个坑,刷新控件mSpinner的位置可能不是ViewGroup的最后一个,所以在绘制时可能被滑动控件覆盖,要解决这问题,可以重写getChildDrawingOrder方法来指定绘制顺序:
override fun getChildDrawingOrder(childCount: Int, i: Int): Int {
return when {
mSpinnerIndex < 0 -> i
i == childCount - 1 -> // 最后一位绘制
mSpinnerIndex
i >= mSpinnerIndex -> // 提早一位绘制
i + 1
else -> // 保持原顺序绘制
i
}
}
有了NestedScrolling后,我们可以不用关注触摸事件,只需要处理滑动事件中,所以让CustomRefreshLayout实现NestedScrollingParent2和NestedScrollingChild2两个接口。
public class CustomRefreshLayout : ViewGroup, NestedScrollingParent2, NestedScrollingChild2 {
private val mParentHelper = NestedScrollingParentHelper(this)
private val mChildHelper = NestedScrollingChildHelper(this)
private var mStatus: Status = Status.Ready
//...
// NestedScrollingParent
override fun onStartNestedScroll(child: View, target: View, nestedScrollAxes: Int, type: Int): Boolean {
return (isEnabled && mStatus != Status.Refresh
&& nestedScrollAxes and ViewCompat.SCROLL_AXIS_VERTICAL != 0 //只接受垂直方向的滚动
&& type == ViewCompat.TYPE_TOUCH)//只接受touch的滚动,不接受fling
}
override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
mParentHelper.onNestedScrollAccepted(child, target, axes, type)
// 分发事件给Nested Parent
startNestedScroll(axes and ViewCompat.SCROLL_AXIS_VERTICAL, type)
// 重置计数
mTotalUnconsumed = 0f
mStatus = Status.Pull
}
override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray?, type: Int) {
consumed ?: return
// 如果在下拉过程中,直接响应并消耗上滑距离,调整Spinner位置
if (dy > 0 && mTotalUnconsumed > 0) {
if (dy > mTotalUnconsumed) {
consumed[1] = dy - mTotalUnconsumed.toInt()
mTotalUnconsumed = 0f
} else {
mTotalUnconsumed -= dy.toFloat()
consumed[1] = dy
}
moveSpinner(mTotalUnconsumed)
}
// 让Nested Parent来处理剩下的滑动距离
val parentConsumed = mParentScrollConsumed
if (dispatchNestedPreScroll(dx - consumed[0], dy - consumed[1], parentConsumed, null, type)) {
consumed[0] += parentConsumed[0]
consumed[1] += parentConsumed[1]
}
}
override fun onStopNestedScroll(target: View, type: Int) {
mParentHelper.onStopNestedScroll(target)
// 如果有处理过滑动事件,执行滑动停止后的操作
if (mTotalUnconsumed > 0) {
finishSpinner(mTotalUnconsumed)
mTotalUnconsumed = 0f
}
// 分发事件给Nested Parent
stopNestedScroll(type)
}
override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int,
dxUnconsumed: Int, dyUnconsumed: Int, type: Int) {
// 首先分发事件给Nested Parent
dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
mParentOffsetInWindow, type)
// 考虑到有时候可能被两个nested scrolling view包围,这里计算滑动距离时要加上Nested Parent滑动的距离
// 如果可以刷新,移动刷新控件的位置
val dy = dyUnconsumed + mParentOffsetInWindow[1]
if (dy < 0 && !canChildScrollUp()) {
mTotalUnconsumed += Math.abs(dy).toFloat()
moveSpinner(mTotalUnconsumed)
}
}
// NestedScrollingChild,全部交由Childer Helper来处理
override fun startNestedScroll(axes: Int, type: Int): Boolean {
return mChildHelper.startNestedScroll(axes, type)
}
override fun stopNestedScroll(type: Int) {
mChildHelper.stopNestedScroll(type)
}
override fun hasNestedScrollingParent(type: Int): Boolean {
return mChildHelper.hasNestedScrollingParent(type)
}
override fun dispatchNestedScroll(dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int,
dyUnconsumed: Int, offsetInWindow: IntArray?, type: Int): Boolean {
return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed,
dxUnconsumed, dyUnconsumed, offsetInWindow, type)
}
override fun dispatchNestedPreScroll(dx: Int, dy: Int, consumed: IntArray?, offsetInWindow: IntArray?, type: Int): Boolean {
return mChildHelper.dispatchNestedPreScroll(
dx, dy, consumed, offsetInWindow, type)
}
/**
* 移动刷新控件的垂直位置
*/
private fun moveSpinner(overscrollTop: Float) {
if (mSpinner.visibility != View.VISIBLE) {
mSpinner.visibility = View.VISIBLE
}
val move = if (overscrollTop <= mRefreshSlop) {
overscrollTop
} else {
mRefreshSlop + (overscrollTop - mRefreshSlop) / 2f
}.toInt()
val targetOffsetTop = mOriginalSpinnerOffsetTop + move
setSpinnerOffsetTopAndBottom(targetOffsetTop - mCurrentSpinnerOffsetTop)
}
/**
* 停止下拉后的操作
*/
private fun finishSpinner(overscrollTop: Float) {
if (overscrollTop > mRefreshSlop) {
setRefreshing(true, true /* notify */)
} else {
// cancel refresh
mStatus = Status.Ready
animateSpinnerToReady()
}
}
/**
* 设置刷新状态
* @param refreshing 是否在刷新
* @param notify 是否通知listener
*/
private fun setRefreshing(refreshing: Boolean, notify: Boolean) {
val isRefreshing = mStatus == Status.Refresh
if (isRefreshing != refreshing) {
ensureTarget()
if (refreshing) {
mStatus = Status.Refresh
animateSpinnerToRefresh()
if (notify) {
mOnRefreshListener?.onRefresh()
}
} else {
mStatus = Status.Ready
animateSpinnerToReady()
}
}
}
/**
* 设置Spinner的位置
*/
private fun setSpinnerOffsetTopAndBottom(offset: Int) {
mSpinner.bringToFront()
ViewCompat.offsetTopAndBottom(mSpinner, offset)
mCurrentSpinnerOffsetTop = mSpinner.top
if (!mIsSpinnerOver) {
ViewCompat.offsetTopAndBottom(mTarget, offset)
}
}
}
再为下拉刷新添加合适的动画效果,比如说简单的平移:
/**
* 让Spinner带动画移动到准备位置
*/
private fun animateSpinnerToReady() {
mAnimateFrom = mCurrentSpinnerOffsetTop
mAnimateToReady.reset()
mAnimateToReady.duration = ANIMATE_DURATION.toLong()
mAnimateToReady.interpolator = ANIMATE_INTERPOLATOR
mSpinner.clearAnimation()
mSpinner.startAnimation(mAnimateToReady)
}
//移动到准备位置的动画
private val mAnimateToReady = object : Animation() {
public override fun applyTransformation(interpolatedTime: Float, t: Transformation) {
val targetTop = mAnimateFrom + ((mOriginalSpinnerOffsetTop - mAnimateFrom) * interpolatedTime).toInt()
val offset = targetTop - mCurrentSpinnerOffsetTop
setSpinnerOffsetTopAndBottom(offset)
updateProgress()
}
}
/**
* 让Spinner带动画移动到刷新位置
*/
private fun animateSpinnerToRefresh() {
mAnimateFrom = mCurrentSpinnerOffsetTop
mAnimateToRefresh.reset()
mAnimateToRefresh.duration = ANIMATE_DURATION.toLong()
mAnimateToRefresh.interpolator = ANIMATE_INTERPOLATOR
mSpinner.clearAnimation()
mSpinner.startAnimation(mAnimateToRefresh)
}
//移动到刷新位置的动画
private val mAnimateToRefresh = object : Animation() {
public override fun applyTransformation(interpolatedTime: Float, t: Transformation) {
val endTarget = mOriginalSpinnerOffsetTop + mRefreshSlop
val targetTop = mAnimateFrom + ((endTarget - mAnimateFrom) * interpolatedTime).toInt()
val offset = targetTop - mCurrentSpinnerOffsetTop
setSpinnerOffsetTopAndBottom(offset)
updateProgress()
}
}
核心功能完成后,在加上一些对外暴露的接口就大功告成了!完整代码在这里,最终实现的效果是这样的:
后记
文章实现的下拉刷新控件CustomRefreshLayout的原理其实和官方提供的SwipeRefreshLayout很类似,区别只在于SwipeRefreshLayout还处理了Touch事件,而CustomRefreshLayout没有,所以CustomRefreshLayout只可以与NestedScrollView或者RecyclerView等实现了NestedScrolling的控件协同使用,有兴趣学习或者使用的同学可以根据需要再扩展一下。