之前我们提及过嵌套滑动的概念,不知道大家忘记了没有。当时我们通过自定义Behavior实现了很多新颖的滑动效果。但是如果你选择开源,如果你仅仅是提供一个Behavior出去,那还是很尴尬的,用户需要把CoordinatorLayout什么的都要加到布局文件才能使用。所以今天我将介绍如何摒弃Behavior,直接使用滑动嵌套原理去实现一个下拉刷新、上拉加载更多的视图。
项目在github上,欢迎大家star、fork
效果
先看看效果图
回顾过去
之前我们已经对滑动嵌套的源码进行过分析,这里再啰嗦一下吧,毕竟这个是最关键的部分了。用NestedScrollView为例,RecyclerView等都雷同。
- 交互流程
之前我们说过了,嵌套滑动关键在于NestedScrollingChild与NestedScrollingParent接口的实现。NestedScrollView 实现了NestedScrollingChild接口,而CoordinatorLayout实现了NestedScrollingParent接口。
既然是涉及到滑动,那么万变不离其宗,肯定从onTouchEvent里触发。以NestedPreScroll方法为例,首先在NestedScrollView的Action_Move中触发
dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)
它实际是调用NestedScrollingChildHelper的dispatchNestedPreScroll方法,继续看ViewParentCompat怎么处理
ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);
我们出现了mNestedScrollingParent。不同版本的API实现不同的ViewParentCompat接口,这里我们选择android5.0的ViewParentCompatLollipopImpl。ViewParentCompatLollipopImpl继承ViewParentCompatStubImpl,这里使用我们就可以很清楚的看到CoordinatorLayout的触发时机了
@Override
public void onNestedPreScroll(ViewParent parent, View target, int dx, int dy,
int[] consumed) {
if (parent instanceof NestedScrollingParent) {
((NestedScrollingParent) parent).onNestedPreScroll(target, dx, dy, consumed);
}
}
- onNestedPreScroll与onNestedScroll的区别
这里我们仔细看下源码,先看看dispatchNestedPreScroll,只有NestedScrollingParent消费了,才能返回true,并且consumed数组的值会被带进来带出去
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
if (dx != 0 || dy != 0) {
.........................
ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);
.........................
return consumed[0] != 0 || consumed[1] != 0;
}
.........................
}
return false;
}
如果dispatchNestedPreScroll为true,那么deltaY的值会因为mScrollConsumed被消费的具体情况而做修改。当最终的偏移量大于mTouchSlop,则mIsBeingDragged才会被赋值为true,才会执行dispatchNestedScroll方法
if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) {
deltaY -= mScrollConsumed[1];
vtev.offsetLocation(0, mScrollOffset[1]);
mNestedYOffset += mScrollOffset[1];
}
if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) {
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
mIsBeingDragged = true;
if (deltaY > 0) {
deltaY -= mTouchSlop;
} else {
deltaY += mTouchSlop;
}
}
在onNestedScroll中还有很关键的一组变量,那就是unconsumedY和unconsumedX。由于我们仅考虑垂直方向,所以关注点放在unconsumedY上
if (mIsBeingDragged) {
.............................
if (overScrollByCompat(0, deltaY, 0, getScrollY(), 0, range, 0,
0, true) && !hasNestedScrollingParent()) {
}
final int scrolledDeltaY = getScrollY() - oldY;
final int unconsumedY = deltaY - scrolledDeltaY;
if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset)) {
}
}
在overScrollByCompat中有onOverScrolled方法,他直接调用super.scorllTo方法,负责将deltaY与getScrollY()计算得到新的newScrollY,也就是当前NestedScrollView自身滚动的距离。同样我只关注垂直方向
boolean overScrollByCompat(int deltaX, int deltaY,
int scrollX, int scrollY,
int scrollRangeX, int scrollRangeY,
int maxOverScrollX, int maxOverScrollY,
boolean isTouchEvent) {
............................
int newScrollY = scrollY + deltaY;
if (!overScrollVertical) {
maxOverScrollY = 0;
}
// Clamp values if at the limits and record
final int top = -maxOverScrollY;
final int bottom = maxOverScrollY + scrollRangeY;
boolean clampedY = false;
if (newScrollY > bottom) {
newScrollY = bottom;
clampedY = true;
} else if (newScrollY < top) {
newScrollY = top;
clampedY = true;
}
if (clampedY) {
mScroller.springBack(newScrollX, newScrollY, 0, 0, 0, getScrollRange());
}
onOverScrolled(newScrollX, newScrollY, clampedX, clampedY);
return clampedX || clampedY;
}
所以经过overScrollByCompat方法之后,NestedScrollView就已经发生了滚动。这样unconsumedY的值就明朗了,是新旧两个getScrollY()的差值,也就是NestedScrollView自身消耗值的剩余部分。所以默认条件下,如果NestedScrollView仅仅是自身滑动,deltaY与scrolledDeltaY两个值是相同的,就是unconsumedY为0,滚多少就消耗多少;如果不是自身滚动的话,就会产生unconsumedY不为0的情况,出现未消耗部分,留给NestedScroll方法自行处理。这样就得到之前我们所说的结论了
当dyConsumed>0 && dyUnconsumed==0,代表target自身向上滚动
当dyConsumed==0 && dyUnconsumed>0,代表target自身向上滚动已经结束了,交由Behavior去处理后续向上滚动事件
展望未来
经过刚才的回顾,你应该彻底明白了这个滑动嵌套的流程,现在我们来开始动手了。
- 下拉刷新
之前我们说过,Behavior是由CoordinatorLayout去触发的。如果我们要绕过CoordinatorLayout,就需要实现NestedScrollingParent接口。先看看我们的布局吧
.................................
这个没啥好说的,开始撸吧。排排坐吃果果,把头尾先隐藏起来。我直接写死宽高是为了方便演示,实际使用过程中需要自行计算
public class PullToRefreshView extends LinearLayout implements NestedScrollingParent {
Context context;
public PullToRefreshView(Context context) {
this(context, null);
}
public PullToRefreshView(Context context, AttributeSet attrs) {
super(context, attrs);
this.context=context;
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
//初始化head、foot、本体
getChildAt(0).layout(0, dp2Px(context, -200), getMeasuredWidth(), 0);
getChildAt(1).layout(0, 0, getMeasuredWidth(), getMeasuredHeight());
getChildAt(2).layout(0, getMeasuredHeight(), getMeasuredWidth(), getMeasuredHeight()+dp2Px(context, 200));
}
}
三个好基友登场
@Overridepublic boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
return super.onStartNestedScroll(child, target, nestedScrollAxes);
}
@Overridepublic void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
super.onNestedPreScroll(target, dx, dy, consumed);
}
@Overridepublic void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed);
}
我们仅考虑垂直方向
@Overridepublic boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL)!=0;
}
onNestedPreScroll与onNestedScroll自然要成对的说。
1)不产生偏移
我们的需求肯定是NestedScrollView不到底部或者顶部是不会把Head或者Footer显示出来,所以这时候我们希望dyUnconsumed为0,也就是NestedScrollView完全自主滑动,那么此时就不需要onNestedPreScroll做什么事情,同样onNestedPreScroll也不要处理什么。
2)产生偏移
一般情况下NestedScrollView也不会自己平白无故的移动起来,只有它到达边界之后才会在dyUnconsumed体现出来,这个我们刚才也说过了。所以这里如果dyUnconsumed小于0,那肯定是你向下滑动NestedScrollView产生的。我们知道onNestedPreScroll与onNestedScroll之间的关系,所以第一次触发的位移的那个点肯定是onNestedScroll触发的,因为NestedScrollView的getTop最初值肯定是0。后续真的发生移动之后,肯定操作要交给onNestedPreScroll的,因为他才是决定dyUnconsumed值的关键。试想一下如果你不处理onNestedPreScroll,那么在上拉的过程中事件永远被NestedScrollView吞掉了,head就回不了头了。onNestedPreScroll将事件全部消费,不给NestedScrollView留有消费的余地,这样NestedScrollView就不会自己滚动了,达到了位移的目的
@Override
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
//下拉刷新
if (getChildAt(1).getTop()>0) {
int max=dp2Px(context, 200);
int min=0;
consumed[1]=scrollDown(this, dy, min, max);
}
}
@Override
public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
//下拉刷新
if (dyUnconsumed<0) {
//滑动范围
int max=dp2Px(context, 200);
int min=0;
scrollDown(this, dyUnconsumed, min, max);
}
}
真正滚动的方法在scrollDown中,可以自行体会一下滑动的范围,我也稍微加了一点呢阻尼
private int scrollDown(LinearLayout childView, int y, int min, int max) {
canScrollExtra=true;
if (childView.getChildAt(1).getTop()-y>max) {
childView.getChildAt(0).offsetTopAndBottom(max-childView.getChildAt(1).getTop());
childView.getChildAt(1).offsetTopAndBottom(max-childView.getChildAt(1).getTop());
}
else if (childView.getChildAt(1).getTop()-y
当滑动事件结束执行Action_Up时候,执行onStopNestedScroll回调。由于onStopNestedScroll在手势开始时候就会被执行一次,我也查不出原因,就只能使用canScrollExtra控制一下。以head的总高度的一半作为临界点,用来区分到底是刷新还是回弹,分别做不同的动画效果。如果是刷新状态,则isIntercept作为标志位给InterceptTouchEvent作为参考。滑动结束之后,对现有的视图进行复原
@Override
public void onStopNestedScroll(View child) {
if (canScrollExtra) {
ValueAnimator valueAnimator=null;
if (getChildAt(1).getTop()>0) {
if (Math.abs(getChildAt(1).getTop())==0) {
return;
}
//下拉未到临界点
if (getChildAt(1).getTop()0) {
valueAnimator=ValueAnimator.ofInt(0, -dp2Px(context, 200)-getChildAt(0).getTop());
}
//下拉已过临界点,模拟刷新过程
else if (getChildAt(1).getTop()>dp2Px(context, 200)/2) {
valueAnimator=ValueAnimator.ofInt(0, -dp2Px(context, 200)/2-getChildAt(0).getTop());
isIntercept=true;
}
}
if (valueAnimator==null) {
return;
}
valueAnimator.setDuration(400);
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
if (getChildAt(1).getTop()>0) {
getChildAt(0).offsetTopAndBottom((Integer.parseInt(animation.getAnimatedValue().toString())-deltaY));
getChildAt(1).offsetTopAndBottom((Integer.parseInt(animation.getAnimatedValue().toString())-deltaY));
deltaY=Integer.parseInt(animation.getAnimatedValue().toString());
}
}
});
valueAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
deltaY=0;
if (isIntercept) {
changeText();
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
if (getChildAt(1).getTop()>0) {
resetRefreshDown();
}
}
}, 2000);
}
}
});
valueAnimator.start();
}
canScrollExtra=false;
}
这里还有一个关键点,需要重写requestLayout。无论你在使用setImageReource还是setText的时候,都会根据情况触发requestLayout方法,而且是getParent()一层层的去触发。那么这里就尴尬了,他会重新执行onLayout,这样就造成视图先复原,然后属性动画搞的错乱
@Override
public void requestLayout() {
}
- 上拉加载更多
这个其实没啥好说的,就是将下拉刷新反过来处理即可。
后记
这个项目仅仅作为滑动嵌套使用发散的demo,并不适合项目中直接使用,由于能力不足还有很多问题我暂时无法解决。希望有大神能够在此想法上有更多的创新,创建出更好的刷新加载库