项目地址:https://github.com/landscapeside/DragRefresh
先说下ViewGroup的触摸事件的传递,在控件实现中需要用到:
如果是action_down,可以直接扔给内层处理,重点是action_move,如果达到某些条件就需要交给viewgroup自己处理以便下拉刷新上拉加载什么的。不过这里需要注意,一旦截获了action_move,除非把action_up或者cancel处理了,否则后续的事件都不会再经过onInterceptTouchEvent了,是直接走viewGroup的onTouchEvent的。
接着是整个控件的思路:
在onInterceptTouchEvent中判断边界条件,如果是达到上下边界并且是对应的滑动方向,或者本身已经是正在刷新或者正在加载的状态,那action_move应该由viewgroup来处理,也就是直接委托给viewdraghelper来处理,否则应该由内层视图来消费action_move
工具类,辅助类以及枚举量
- 枚举方向类
Direction
,用来判断拖动的方向,STATIC
(静止),UP
(手指↑),DOWN
(手指↓) - 枚举状态类
ScrollStatus
,用来标识当前的状态,IDLE
(既没上拉也没下拉),DRAGGING
(拖动中),REFRESHING
(下拉刷新中),LOADING
(上拉加载中) - 边界判断类
ScrollViewCompat
,canSmoothDown
判断该视图控件还能否向下拉动,canSmoothUp
该视图控件还能否向上拉动。这里和Direction
里面的方向是一个意思,如果某个列表或者scrollView本身已经滑到顶部,那么手指从上往下拉是拉不动的,所以canSmoothDown
返回false
,canSmoothUp
正好相反 -
Range
:常量,定义了拉动的最大范围以及动画区域高度。MotionEventUtil
:里面只有一个getMotionEventY
,用兼容的方式获取motionEvent的Y
值
捕获触摸事件进行边界判断的代理类
DragDelegate
:为了不至于刷新控件的代码逻辑过于臃肿,也为了以后方便替换边界判断逻辑,专门将边界判断这部分提取出来,委托DragDelegate
来处理。
刷新控件类
DragRefreshLayout
:主要定义viewdragHelper的逻辑,比如滑动边界,弹回等,还定义了刷新加载事件的回调
先来边界判断
首先发生down事件的时候其实还是要交给内层消化的,毕竟这时候还是不知道后面到底是上拉还是下拉,以及内层自己能不能滑动,当然如果后面在move的时候判断到确实到了边界,那么直接给内层甩一个cancel就可以了。
case MotionEvent.ACTION_DOWN:
mActivePointerId = MotionEventCompat.getPointerId(event, 0);
initY = (int) MotionEventUtil.getMotionEventY(event, mActivePointerId);
consignor.dragHelper().shouldInterceptTouchEvent(event);
break;
这里要说明一下,必须调用一下viewDragHelper.shouldInterceptTouchEvent(event)
,因为它内部需要做某些处理,比如记录滑动状态,x,y值等等,如果此处没有调用,则处理后续的move等事件的时候viewDragHelper就会失灵
case MotionEvent.ACTION_MOVE:
//如果达到边界,则扔给内层一个cancel
MotionEvent cancelEvent = MotionEvent.obtain(event);
cancelEvent.setAction(MotionEvent.ACTION_CANCEL);
target.dispatchTouchEvent(cancelEvent);
break;
包装一下:
private boolean handleMotionEvent(MotionEvent event) {
// 这里要判断是不是正在拖动中,如果已经拖动了,说明之前已经传了一个cancel给内层了
if (!ScrollStatus.isDragging(consignor.scrollStatus())) {
MotionEvent cancelEvent = MotionEvent.obtain(event);
cancelEvent.setAction(MotionEvent.ACTION_CANCEL);
consignor.target().dispatchTouchEvent(cancelEvent);
}
return true;
}
接着是move事件,如上所说,我们判断是否到达边界,如果到了则直接把move事件拦了交给viewgroup自己处理,当然先给内层甩一个cancel。如何判断边界条件呢?这里就要对手势方向区别处理,如果是下拉,就应该判断内层能不能下拉,不能就表示已经到了边界,上拉是一样的道理。
direction = Direction.getDirection((int) (MotionEventUtil.getMotionEvent(event,mActivePointerId) - initY));
if (direction == Direction.DOWN) {
if (consignor.isRefreshAble() ||ScrollStatus.isLoading(consignor.scrollStatus())) {
if (!ScrollStatus.isDragging(consignor.scrollStatus()) && !ScrollStatus.isRefreshing(consignor.scrollStatus()) && ScrollViewCompat.canSmoothDown(consignor.target())) {
if (ScrollStatus.isLoading(consignor.scrollStatus())) {
return handleMotionEvent(event);
} else {
return false;
}
} else {
return handleMotionEvent(event);
}
} else {
return false;
}
} else if (direction == Direction.UP) {
if (consignor.isLoadAble() || ScrollStatus.isRefreshing(consignor.scrollStatus())) {
if (!ScrollStatus.isDragging(consignor.scrollStatus()) && !ScrollStatus.isLoading(consignor.scrollStatus()) && ScrollViewCompat.canSmoothUp(consignor.target())) {
if (ScrollStatus.isRefreshing(consignor.scrollStatus())) {
return handleMotionEvent(event);
} else {
return false;
}
} else {
if (ScrollViewCompat.canSmoothDown(consignor.target()) || ScrollStatus.isRefreshing(consignor.scrollStatus())) {
return handleMotionEvent(event);
}
}
} else {
return false;
}
}
先拿down来说,在手指下拉的时候有这么几种情况:
- 静止状态(后面就用静止状态来形容没有上拉没有下拉)并且内层已经到顶部
- 静止状态(后面就用静止状态来形容没有上拉没有下拉)并且内层没到顶部
- 刷新状态
- 加载状态
up事件应该是差不多的,方向相反而已
这里的isRefreshAble()
和isLoadAble()
是刷新控件的一个功能,比如总有时候需要禁用刷新或者加载什么的,所以如果禁用了刷新那么down事件就不捕获了直接扔给内层处理,禁用加载也是一样的。
而后面的ScrollStatus.isLoading(consignor.scrollStatus())
是这么一种情况,就算禁用了下拉刷新,但是没有禁用上拉加载,那么就会出现当前状态是加载中的情况,这时候的Move肯定还是由刷新控件自己处理,后面的上拉操作就不赘述了。
接着是下面的
if (!ScrollStatus.isDragging(consignor.scrollStatus())
&& !ScrollStatus.isRefreshing(consignor.scrollStatus())
&& ScrollViewCompat.canSmoothDown(consignor.target())) {
if (ScrollStatus.isLoading(consignor.scrollStatus())) {
return handleMotionEvent(event);
} else {
return false;
}
} else {
return handleMotionEvent(event);
}
如果我们没有禁用刷新,或者当前是加载中,那么什么情况不需要刷新控件处理,而是直接扔给内层呢?
- 不是加载中,并且内层没有到达顶部
这里如果本身就是拖动中,move就还是交给刷新控件继续处理,然后如果是刷新中,这时候不管是上拉(取消刷新)还是下拉(再次拉一下)也还是交给刷新控件处理。所以滤过了这两种情况,实际上只需要判断ScrollViewCompat.canSmoothDown(consignor.target())
,如果当前状态是加载中,那么不管是上拉(再次加载)还是下拉(取消加载)也是要由刷新控件来处理的
然后再来说说up事件,我们将要在viewDragHelper中处理onViewReleased
,比如拉到某个位置,松手让刷新控件自动弹回去,所以在UP事件出现的时候我们要做某些处理。
那么什么时候应该把up交给刷新控件处理呢?当前状态是正在被拖动的时候
case MotionEvent.ACTION_UP:
mActivePointerId = -1;
dragDirection = Direction.STATIC;
if (ScrollStatus.isDragging(consignor.scrollStatus())) {
return handleMotionEvent(event);
} else {
return false;
}
onInterceptTouchEvent
就结束了,那么onTouchEvent
呢?就直接consignor.dragHelper().processTouchEvent(event);
即可
这里还有一个需要完善的地方,在拉动的时候还是希望只有在纵向拉动的距离大于横向拉动距离的时候我们才去处理(毕竟是上拉下拉控件),很简单,用GestureDetectorCompat
就可以了
gestureDetector = new GestureDetectorCompat(((ViewGroup) consignor).getContext(), new YScrollDetector());
class YScrollDetector extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float dx, float dy) {
return Math.abs(dx) <= Math.abs(dy);
}
}
把handleMotionEvent
改一改
private boolean handleMotionEvent(MotionEvent event) {
if (!ScrollStatus.isDragging(consignor.scrollStatus())) {
MotionEvent cancelEvent = MotionEvent.obtain(event);
cancelEvent.setAction(MotionEvent.ACTION_CANCEL);
consignor.target().dispatchTouchEvent(cancelEvent);
}
return consignor.dragHelper().shouldInterceptTouchEvent(event) && gestureDetector.onTouchEvent(event);
}
接着是刷新控件,主要是ViewDragHelper
ViewDragHelper中我们需要用到这么几个方法:
-
clampViewPositionVertical
,在每次拖动的时候由它计算出位置 -
onViewReleased
,放手了会调用它 -
onViewPositionChanged
,位置真正改变的时候回调
在VDH要处理的时候内层已经是达到了边界,所以这时候就不要再被内层所影响,剩下的就是一个容器,上面的是一个刷新进度条,中间是一个高度填满控件的内容块(也是一个视图),下面是一个加载进度条
控件直接用frameLayout或者直接viewgroup都可以,两个进度条一个在上面一个在下面,那么直接通过view.layout
设置它们的位置即可
refreshView.layout(
paddingLeft,
contentTop - refreshView.getMeasuredHeight() + paddingTop,
width - paddingRight,
contentTop + paddingTop);
loadView.layout(
paddingLeft,
contentTop + height - paddingBottom,
width - paddingRight,
contentTop + height + loadView.getMeasuredHeight() - paddingBottom);
所以只需要知道content块的top位置,自然就能算出refreshView
和loadView
的位置
那么在我们拖动的时候计算位置时有没有什么要注意的?还是说参数传给我们要拖动的top我们就直接原样返回top?
一般我们见过比较好的刷新控件都有这么一个特性,在上拉或者下拉的时候总是有个拉动的边界,到那个边界就不能继续拉动了,要不那个进度条就会拉到很远的位置,用户体验不佳。
那么就需要对clampViewPositionVertical
被调用时传进来的top参数进行一些过滤,即拉动后最终的contentTop
不能超出某个最大值,或者小于某个最小值
if (contentTop + dy > DRAG_MAX_RANGE) {
return DRAG_MAX_RANGE;
} else if (contentTop + dy < -DRAG_MAX_RANGE) {
return -DRAG_MAX_RANGE;
} else {
return top;
}
可是这个只是拖动的视图是content的时候,如果我们拖动的是refreshView或者loadView,我们就应该在这基础上加上或者减去它们的高度
if (child == mTarget) {
status = ScrollStatus.DRAGGING;
if (contentTop + dy > DRAG_MAX_RANGE) {
return DRAG_MAX_RANGE;
} else if (contentTop + dy < -DRAG_MAX_RANGE) {
return -DRAG_MAX_RANGE;
} else {
return top;
}
} else {
status = ScrollStatus.DRAGGING;
if (contentTop + dy > DRAG_MAX_RANGE) {
return DRAG_MAX_RANGE - refreshView.getMeasuredHeight();
} else if (contentTop + dy < -DRAG_MAX_RANGE) {
return getMeasuredHeight() - getPaddingBottom() - DRAG_MAX_RANGE;
} else {
return top;
}
}
当然,上面在拖动的时候不要忘了将状态设置为ScrollStatus.DRAGGING
毕竟onInterceptTouchEvent
里面要判断它
那么我们放手的时候又怎么处理呢?
非刷新状态下拉刷新,但只下拉了一点点(没有到达某个阈值),放手的时候直接弹回去
非刷新状态下拉刷新,超过阈值,放手的时候开始刷新动画
本身已经是刷新状态,下拉,放手的时候还是继续刷新动画
加载状态下拉,取消加载状态
非加载状态上拉,但没超过阈值,放手也是弹回去
非加载状态上拉,超过阈值,放手开始加载动画
加载状态上拉,放手继续加载动画
刷新状态上拉,取消刷新状态
-
上拉的时候直接拖到顶部,下拉的时候直接拖到底部
if (contentTop > dp2px(DRAW_VIEW_MAX_HEIGHT)) { setRefreshing(true); } else if (contentTop < -dp2px(DRAW_VIEW_MAX_HEIGHT)) { setLoading(true); } else if (contentTop > 0) { setRefreshing(false); } else if (contentTop == 0) { if (!ScrollViewCompat.canSmoothDown(mTarget)) { setRefreshing(false); } else if (!ScrollViewCompat.canSmoothUp(mTarget)) { setLoading(false); } } else { setLoading(false); }
这里注意一下中间的代码段
if (contentTop == 0) {
if (!ScrollViewCompat.canSmoothDown(mTarget)) {
setRefreshing(false);
} else if (!ScrollViewCompat.canSmoothUp(mTarget)) {
setLoading(false);
}
}
为什么要分开处理呢,因为后面需要处理一下,以响应refreshCancel
和loadCancel
最后是onViewPositionChanged
,这里先要根据被改变的view来做区分,因为我们现在包含三个view:content,refreshView和loadView
-
如果是content,那么它的移动我们倒是不需要关心的,VDH已经为我们做好了,但是上面的refreshView和下面的loadView必须跟着移动,我们通过
offsetTopAndBottom
方法即可refreshView.offsetTopAndBottom(dy); loadView.offsetTopAndBottom(dy); contentTop = top;
-
如果我们拖动的是refreshView或者loadView呢?那么它们的移动VDH已经处理好了,我们只需要跟上content的移动即可
contentTop += dy; layoutViews();
这里我们通过重新layoutViews
就可以移动content
但是现在问题来了,我们会发现,从刷新状态往上拉,会出现拉过头的情况,加载也是如此,拉过头直接就把下面的loadView给拉出来了。。。所以也必须要进行边界判断
- 从刷新或者静止状态开始拉,往上拉的时候,不能拉过
contentTop < 0
- 从加载或者静止状态开始拉,往下拉,不能
contentTop > 0
于是改进后应该这样:
if (changedView == mTarget) {
if (!ScrollViewCompat.canSmoothDown(mTarget)
&& top < 0) {
contentTop = 0;
layoutViews();
} else if (ScrollViewCompat.canSmoothDown(mTarget)
&& !ScrollViewCompat.canSmoothUp(mTarget)
&& top > 0) {
contentTop = 0;
layoutViews();
} else {
refreshView.offsetTopAndBottom(dy);
loadView.offsetTopAndBottom(dy);
contentTop = top;
layoutViews();
}
} else {
if (!ScrollViewCompat.canSmoothDown(mTarget)
&& (top + refreshView.getMeasuredHeight() - getPaddingTop()) < 0) {
contentTop = 0;
layoutViews();
} else if (ScrollViewCompat.canSmoothDown(mTarget)
&& !ScrollViewCompat.canSmoothUp(mTarget)
&& (top - getMeasuredHeight() + getPaddingBottom()) > 0) {
contentTop = 0;
layoutViews();
} else {
contentTop += dy;
layoutViews();
}
}
刷新&加载
好了,VDH关键的几个方法说完了,我们遗漏了什么?setRefreshing
和setLoading
!这两个不但是由onViewReleased
来调用,而且我们使用这个刷新控件的时候也会去调用它们,根据名字理解,就是让刷新控件去开启/关闭刷新,去开启/关闭加载
那么如果我们要开启/关闭刷新的时候其实是要做什么?
- 设置状态,如果是刷新状态就是
ScrollStatus.REFRESHING
,如果是关闭刷新状态就统一用ScrollStatus.IDLE
(关闭加载状态我们也用ScrollStatus.IDLE
,这个状态就表示既没刷新也没加载) - 让代码动态的去移动
content
,refreshView
和loadView
那么怎么移动呢?这里要用到VDH的smoothSlideViewTo
方法,它将某个view移动到目标位置(通过传top
和left
),其实它内部是调用scroller
的,所以这样会触发computeScroll
,而且它不光是使用scroller
来移动,还会在每次移动后调用我们前面写的onViewPositionChanged
所以我们其实只用控制content来移动就可以了
if (refreshing) {
if (dragHelper.smoothSlideViewTo(mTarget, 0, dp2px(DRAW_VIEW_MAX_HEIGHT))) {
ViewCompat.postInvalidateOnAnimation(this);
status = ScrollStatus.REFRESHING;
}
} else {
if (dragHelper.smoothSlideViewTo(mTarget, 0, 0)) {
ViewCompat.postInvalidateOnAnimation(this);
status = ScrollStatus.IDLE;
}
}
computeScroll
:
@Override
public void computeScroll() {
animContinue = dragHelper.continueSettling(true);
if (animContinue) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
但是这里有一个问题,我们只处理了smoothSlideViewTo
为true
的情况,如果是false
呢?
一般只有在VDH判断content当前位置离要移动到的位置确实有距离,就是说确实要移动,smoothSlideViewTo
就会为true
,反之直接返回false
了,所以我们在onViewReleased
中处理contentTop == 0
的时候也是调用的setRefreshing
和setLoading
,这时候我们还是要去处理false
的
那么该怎么处理?简单,直接设置对应的状态即可
if (refreshing) {
if (dragHelper.smoothSlideViewTo(mTarget, 0, dp2px(DRAW_VIEW_MAX_HEIGHT))) {
ViewCompat.postInvalidateOnAnimation(this);
status = ScrollStatus.REFRESHING;
} else {
status = ScrollStatus.REFRESHING;
}
} else {
if (dragHelper.smoothSlideViewTo(mTarget, 0, 0)) {
ViewCompat.postInvalidateOnAnimation(this);
status = ScrollStatus.IDLE;
} else {
status = ScrollStatus.IDLE;
}
}
加载部分的代码与这个差不多,就不讲了
刷新控件先暂时到这里,后面再来将动画drawable
加进去