Android打造通用的下拉刷新组件

还记得上一篇 blog 的内容吗?如果不记得建议先去了解一下,Android 事件处理全面剖析 ,因为下拉刷新需要用到手势的处理,而上一篇文章中,对事件处理做了很详细的说明,了解了事件的处理机制,对理解本篇文章有很大的帮助。好了,这里就当大家都已经对事件处理有了一定的了解,开始我们的下拉刷新征程。

还是老规矩,先上效果图,再根据效果图来分析实现的原理;
技术分享

一 、分析原理
我们都知道,listView 控件为我们提供了 addHeaderView、和 addFootView 的方法,我们通过此方法可以很方便的实现下拉刷新效果;但不是所有的控件都有 addHeaderView 方法,比如,scrollView、TextView 等都没有addHeaderView 方法,所以这些控件就需要我们自己通过其他方式实现下拉刷新的效果,一个项目中,为了通用性和复用性,往往也不会把 listView 控件单独分离出来实现下拉刷新的效果,这时,就需要一个能对所有的控件达到通用的下拉刷新效果。
这里很容易想到用自定义 ViewGroup 来实现,让自定义的 ViewGroup 包含两个控件,一个是下拉刷新的headerView、 另一个是需要展示数据的控件contentView,contentView可以是任何控件;headerView 和 contentView 垂直布局,并且初始状态让 headerView 滚动到看不到的位置。基本的思路就是这样,接下来就是对事件处理。

二、代码实现
代码实现可以分为四个小点:
1、自定义 ViewGroup 的实现
在前面我的 blog 中有一篇写的是关于自定义 View 的内容,Android自定义View,你必须知道的几点 如果对这篇 blog 了解的同学相信对你来说,自定义 ViewGroup 也没什么难度,自定义 ViewGroup 相对自定义 View 还是较容易的。
自定义 ViewGroup 需要重写的两个方法是

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
protected void onLayout(boolean changed, int l, int t, int r, int b)

onMeasure方法相对较简单,只需要对子 View 进行测量即可,这里贴出onMeasure的代码,注解也比较详细。

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        /*获取 ViewGroup 的宽度*/
        int width = MeasureSpec.getSize(widthMeasureSpec) ;
        /*获取 ViewGroup 的高度*/
        int height = MeasureSpec.getSize(heightMeasureSpec) ;
        /*这里不懂的同学可以去参考我前面写的一篇blog 自定义View*/
        /*测量 refreshView 的宽高,这里把高度设为固定值*/
        measureChild(mHeaderView,widthMeasureSpec,MeasureSpec.makeMeasureSpec(mHeaderHeight ,MeasureSpec.EXACTLY));
        Log.v("zgy","==========mHeaderView============"+mHeaderView.getMeasuredHeight()) ;
        /*测量 mContentView 的宽高,高度为最大值只能为ViewGroup 的高度*/
        measureChild(mContentView, widthMeasureSpec, MeasureSpec.makeMeasureSpec(height, MeasureSpec.AT_MOST));
        mRefreshHeight = mTextView.getMeasuredHeight() ;
        /*千万别忘记调用测量方法*/
        setMeasuredDimension(width,height);
    }

onLayout方法就是根据我们测量子 View 的宽高,来布局子 View,前面我们分析原理的时候说到了,这里需要用到垂直布局,也就是先布局 headerVeiw,再在 headerView 下面布局 contentView,这里 headerView 的隐藏操作也放在 onLayout 方法中,所以就得判断是否是第一次,防止重复隐藏。具体实现代码如下

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        /*布局刷新的头部 headerView*/
mHeaderView.layout(0,0,mHeaderView.getMeasuredWidth(),mHeaderView.getMeasuredHeight());
        /*布局内容区域 contentView*/   mContentView.layout(0,mHeaderView.getMeasuredHeight(),mContentView.getMeasuredWidth(),             mHeaderView.getMeasuredHeight()+mContentView.getMeasuredHeight());
        if (isFirst){ 
            /*第一次把 headerView隐藏*/
            scrollTo(0,mHeaderView.getMeasuredHeight());
        }
        isFirst = false ;
    }

上面讲到了两个 View,一个是 headerView ,另一个是 contentView,讲了这么久,相信大家都会问,这两个 View 从何而来?先来分析 headerView, headerView 它是一个固定的、不会变的。所以这里我们可以直接通过 xml 来定义,然后再代码中通过 addView 方法把 headerView 添加进去。
headerView xml 中的代码

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
                android:layout_width="match_parent"
                android:layout_height="130dp"
                android:background="@mipmap/refresh_bg">

    <TextView
        android:id="@+id/id_txt_header"
        android:gravity="center"
        android:text="下拉可以刷新"
        android:layout_width="match_parent"
        android:layout_marginTop="70dp"
        android:layout_height="60dp"/>

    <ImageView
        android:id="@+id/id_anim_header"
        android:layout_width="match_parent"
        android:scaleType="centerCrop"
        android:layout_height="60dp"
        android:layout_marginTop="70dp"
        android:src="@drawable/refresh_anim"
        />
</RelativeLayout>

在初始化 ViewGroup 的时候调用 addView

        mHeaderView = mInflater.inflate(R.layout.refresh_header_view,null) ;
        mTextView = mHeaderView.findViewById(R.id.id_txt_header) ;
        mAnimView = (ImageView) mHeaderView.findViewById(R.id.id_anim_header);
        mAnimDrawable = (AnimationDrawable) mAnimView.getDrawable();
        mHeaderHeight = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,130,
                getResources().getDisplayMetrics()) ;
        addView(mHeaderView);

再来分析 contentView,我们知道 contentView 是变化的,根据不同的界面展示不同的 contentView,所以可以在界面的 xml 中通过 把需要展示的 View 放入自定义的容器 ViewGroup 中。

    <moon.pullrefresh.RefreshView
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <ListView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:id="@+id/id_listview"></ListView>
    </moon.pullrefresh.RefreshView>

那么问题来了,怎么在自定义的 ViewGroup 中获取 contentView 呢?可以自己先考虑下,我们都知道,ViewGroup 有这样一个方法 addView;

    public void addView(View child) {
        addView(child, -1);
    }

    public void addView(View child, int index) {
        LayoutParams params = child.getLayoutParams();
        if (params == null) {
            params = generateDefaultLayoutParams();
            if (params == null) {
                throw new IllegalArgumentException("generateDefaultLayoutParams() cannot return null");
            }
        }
        addView(child, index, params);
    }

    public void addView(View child, int width, int height) {
        final LayoutParams params = generateDefaultLayoutParams();
        params.width = width;
        params.height = height;
        addView(child, -1, params);
    }

    public void addView(View child, LayoutParams params) {
        addView(child, -1, params);
    }

    public void addView(View child, int index, LayoutParams params) {
        requestLayout();
        invalidate(true);
        addViewInner(child, index, params, false);
    }

所以我们可以在 addView 的时候,获取 contentView,但又有一个问题,参数个数不同,addView 的调用也不同,我们在add content的时候已经先 add 了一个 headerView,所以这里肯定是调用含有一个 int index 参数的方法,再看 xml 中 viewGroup 包含

android:layout_width="match_parent"
android:layout_height="wrap_content"

所以可以断定这里调用的是

public void addView(View child, int index, LayoutParams params) {
        requestLayout();
        invalidate(true);
        addViewInner(child, index, params, false);
    }

所以我们可以通过重写带有三个参数的 addView 方法来获取 contentView

    @Override
    public void addView(View child, int index, LayoutParams params) {
        mContentView = child ;
        /*
        * 这里判断是否是 listView 的 AdapterView
        * 如果是 scrollView,也需要在此判断,
        * 这里可以扩展任意的contentView
        *  这也是关键代码之一
        * */
        if (mContentView instanceof AdapterView){
            mAdapter = (AdapterView)  mContentView;
        }
        super.addView(child, index, params);
    }

2、事件拦截的实现
通过上一篇blog我们知道了事件传递的顺序,所以想要在 ViewGroup 中相应 onTouchEvent 事件则需要在 onInterceptTouchEvent中对事件进行拦截。 具体拦截代码如下

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        Log.v("zgy","====onInterceptTouchEvent====");
        /*是否已经拖拽,也就是是否已经拦截的意思,如果还在拦截中,继续拦截*/
        if (mIsBeginDrag){
            return true ;
        }
        if(ev.getAction() == MotionEvent.ACTION_DOWN){
            mDownY = (int) ev.getY();
        }
        if (ev.getAction() == MotionEvent.ACTION_MOVE){
            int currentY = (int) ev.getY();
            if (isIntercept(currentY-mDownY)){
                ev.setAction(MotionEvent.ACTION_DOWN);
                onTouchEvent(ev) ;
                requestDisallowInterceptTouchEvent(true);
                mIsBeginDrag = true ;
                return true ;
            }
        }
        return super.onInterceptTouchEvent(ev);
    }

具体判断拦截操作是在isIntercept方法中,进入此方法看看

    private boolean isIntercept(int distance){
        if(distance > 0){
            Log.v("zgy","====mAdapter===="+mAdapter);
            if(mAdapter != null){
                Log.v("zgy","====mAdapter===="+mAdapter);
                View firstChild =  mAdapter.getChildAt(0);
                if(firstChild != null){
                    if (firstChild.getTop() == 0){
                        return true ;
                    }
                }
            }
        }
        return false ;
    }

代码也很简单,因为这里只处理了一种控件,为了达到通用,则需要在此方法中加入判断,判断 contentView是否是 scrollView、TextView 等,根据不同的控件设置不同的拦截条件。

3、事件处理的实现
这里为了方便起见,我把事件转化成了GestureDetector的 onTouchEvent 来处理,这里面只要对一下几个方法操作即可

    @Override
    public boolean onDown(MotionEvent e) {
        return false;
    }
    @Override
    public void onShowPress(MotionEvent e) {
    }
    @Override
    public boolean onSingleTapUp(MotionEvent e) {
        return false;
    }
    @Override
    public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
        return false;
    }
    @Override
    public void onLongPress(MotionEvent e) {
    }
    @Override
    public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
        return false;
    }

还是根据上一篇 blog 的知识可以知道,在onDown方法中,必须方法 true

    @Override
    public boolean onDown(MotionEvent e) {
         /*根据我前面所讲的Android事件处理全面剖析可知,这里应该返回true*/
        return true;
    }

然后就是对onScroll的处理

    @Override
    public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
        /*这里是让下拉的View 越拉越紧,给人的感觉时越要用力*/
        distanceY = distanceY *  (0.8f * (getScrollY() * 1.0f / mHeaderHeight));
        /*设置界限,滚动的距离不能低于0,也不能高于 headerView 的高度*/
        int scrollY = cling(0, mHeaderHeight, getScrollY()+(int) distanceY) ;
        Log.v("zgy","=======onScroll===="+distanceY+",scrollY=="+scrollY+",getScrollY()="+getScrollY());
        scrollTo(0,scrollY);
        /*如果达到了下拉刷新的界限,值改变下拉刷新的状态*/
        if (scrollY < mHeaderHeight-mRefreshHeight){
            ((TextView)mTextView).setText("松开可以刷新");
            STATUS = STATUS_REFRESH ;
        }else{
            ((TextView)mTextView).setText("下拉可以刷新");
            STATUS = STATUS_HIDE ;
        }
        return true;
    }

在手指抬起的时候,需要释放拦截事件,并且根据当前状态来执行相应的操作,如果可以刷新则刷新,未达到刷新的条件这回复原位

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if(event.getAction() == MotionEvent.ACTION_UP||event.getAction() == MotionEvent.ACTION_CANCEL){
            mIsBeginDrag = false ;
            scrollNormal() ;
        }
        return mGesture.onTouchEvent(event);
    }
    private void scrollNormal(){
        if (STATUS == STATUS_REFRESH){
            STATUS = STATUS_HIDE ;
            int scroll = mHeaderHeight - mRefreshHeight -getScrollY() ;
            int currentDuration = (int) (mDuration*0.6f* scroll/(mHeaderHeight - mRefreshHeight));
            mScroller.startScroll(0,getScrollY(),0,scroll,currentDuration);
            /*测试*/
            postDelayed(new Runnable() {
                @Override
                public void run() {
                    stopRefresh() ;
                }
            },1000) ;
            if(mListener != null){
                mListener.onRefresh();
            }
            mAnimView.setVisibility(VISIBLE);
            mAnimDrawable.start();
            invalidate();
        }else if(STATUS == STATUS_HIDE){
            STATUS = STATUS_NORMAL ;
            int scroll = mHeaderHeight - getScrollY() ;
            int currentDuration = mDuration* scroll/mHeaderHeight ;
            mScroller.startScroll(0,getScrollY(),0,scroll,currentDuration);
            mAnimView.setVisibility(View.INVISIBLE);
            mAnimDrawable.stop();
            invalidate();
        }
    }

这里还用到了mScroller.startScroll(0,getScrollY(),0,scroll,currentDuration);知识点,可以参考我的 blog Scroller 的运用案例(一)
4、定义刷新回调接口

    /**
     * 定义下拉刷新刷新回调接口
     */
    public interface OnRefreshListener{
        /**
         * 开始刷新
         */
        void onRefresh() ;
    }

在开始刷新的时候执行回调函数

       if (STATUS == STATUS_REFRESH){
            STATUS = STATUS_HIDE ;
            int scroll = mHeaderHeight - mRefreshHeight -getScrollY() ;
            int currentDuration = (int) (mDuration*0.6f* scroll/(mHeaderHeight - mRefreshHeight));
            mScroller.startScroll(0,getScrollY(),0,scroll,currentDuration);
            /*测试*/
            postDelayed(new Runnable() {
                @Override
                public void run() {
                    stopRefresh() ;
                }
            },1000) ;
            if(mListener != null){
                mListener.onRefresh();
            }
            mAnimView.setVisibility(VISIBLE);
            mAnimDrawable.start();
            invalidate();
        }

以上就是通用型下拉刷新的实现过程。

三、总结
我喜欢在写blog 的后面加些总结,可以说是对本篇 blog 所涉及到的知识的一次巩固、并对内容的提炼,从而对本篇 blog 有一个较深的理性认知,希望大家通过我的 blog 不单单能掌握blog 中所实现的内容,更应该掌握实现内容所用到的知识点,从而扩展到其他功能。
1、自定义 ViewGroup 的实现
a,重写 onMeasure 方法,主要是测量子 View的大小
b、重写 onLayout 方法,根据需求布局子 View
2、事件拦截onInterceptTouchEvent,请参考Android 事件处理全面剖析
3、事件处理 onTouchEvent,请参考Android 事件处理全面剖析
4、GestureDetector类事件转换
5、Scroller 的运用,请参考Scroller 的运用案例(一)

说好了是通用型的下拉刷新,但是好像没实现啊,那么再来看一张TextView 的下拉刷新效果,contentView 只是一个 TextView
先看 xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
                android:layout_width="match_parent"
                android:layout_height="match_parent">

    <moon.pullrefresh.RefreshView
    android:layout_width="match_parent"
    android:layout_height="wrap_content">
       <TextView
           android:gravity="center"
           android:layout_width="match_parent"
           android:layout_height="wrap_content"
           android:text="@string/hello_world"/>
    <!--<ListView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/id_listview"></ListView>-->
</moon.pullrefresh.RefreshView>
</RelativeLayout>

再修改 RefreshView

    private boolean isIntercept(int distance){
        if(distance > 0){
            Log.v("zgy","====mAdapter===="+mAdapter);
            if(mAdapter != null){
                Log.v("zgy","====mAdapter===="+mAdapter);
                View firstChild =  mAdapter.getChildAt(0);
                if(firstChild != null){
                    if (firstChild.getTop() == 0){
                        return true ;
                    }
                }
            }else {
                if (mContentView.getTop() == 0){
                    return true ;
                }
            }
        }
        return false ;
    }

只是在原来的基础上加了两句话

else {
      if (mContentView.getTop() == 0){
          return true ;
      }
}

效果图
技术分享

源码下载地址 Android打造通用的下拉刷新组件

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。