用viewDragHelper来写刷新控件<一>

项目地址: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(上拉加载中)
  • 边界判断类ScrollViewCompatcanSmoothDown判断该视图控件还能否向下拉动canSmoothUp该视图控件还能否向上拉动。这里和Direction里面的方向是一个意思,如果某个列表或者scrollView本身已经滑到顶部,那么手指从上往下拉是拉不动的,所以canSmoothDown返回falsecanSmoothUp正好相反
  • 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位置,自然就能算出refreshViewloadView的位置

那么在我们拖动的时候计算位置时有没有什么要注意的?还是说参数传给我们要拖动的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);
    }
}

为什么要分开处理呢,因为后面需要处理一下,以响应refreshCancelloadCancel


最后是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关键的几个方法说完了,我们遗漏了什么?setRefreshingsetLoading!这两个不但是由onViewReleased来调用,而且我们使用这个刷新控件的时候也会去调用它们,根据名字理解,就是让刷新控件去开启/关闭刷新,去开启/关闭加载

那么如果我们要开启/关闭刷新的时候其实是要做什么?

  • 设置状态,如果是刷新状态就是ScrollStatus.REFRESHING,如果是关闭刷新状态就统一用ScrollStatus.IDLE(关闭加载状态我们也用ScrollStatus.IDLE,这个状态就表示既没刷新也没加载)
  • 让代码动态的去移动content,refreshViewloadView

那么怎么移动呢?这里要用到VDH的smoothSlideViewTo方法,它将某个view移动到目标位置(通过传topleft),其实它内部是调用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);
    }
}

但是这里有一个问题,我们只处理了smoothSlideViewTotrue的情况,如果是false呢?

一般只有在VDH判断content当前位置离要移动到的位置确实有距离,就是说确实要移动,smoothSlideViewTo就会为true,反之直接返回false了,所以我们在onViewReleased中处理contentTop == 0的时候也是调用的setRefreshingsetLoading,这时候我们还是要去处理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加进去

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容