自个儿写Android的下拉刷新/上拉加载控件

前段时间自己写了一个能够“通用”的,支持下拉刷新和上拉加载的自定义控件。可能现如今这已经不新鲜了,但有兴趣的朋友还是可以一起来看看的。

  • 与通常的View配合使用(比如ImageView)
ImageView下拉刷新
ImageView下拉刷新
  • 与ListView配合使用
ListView下拉刷新、上拉加载
ListView下拉刷新、上拉加载
  • 与RecyclerView配合使用
RecyclerView下拉刷新、上拉加载
RecyclerView下拉刷新、上拉加载
  • 与SrcollView配合使用
SrcollView下拉刷新
SrcollView下拉刷新
  • 局部刷新(但想必这种需要实际应该还是不多的....)
作为局部View刷新
作为局部View刷新

好啦,效果大概就是这样。如果您看后觉得有一点兴趣。那么,以下是相关的信息:


好了,闲话就到这里了。现在正式切入正题,于此逐步简单的记录和总结一下实现这个自定义View的思路以及实现过程。

首先,我们分析一下:假设我们现在的需求是需要让ListView支持下拉刷新和上拉加载,那么其实我们选择去扩展系统自身的ListView是最好的。
但我们这里的初衷是创造一个通用的Pullable的控件,也就是说它可以配合Android中各种View使用。所以,显然我们需要的是一个ViewGroup。
那么,既然有了思路就可以开动了:第一步我们先去创建我们自己的View,并让其继承自ViewGroup。例如就像下面这样:

public class PullableLayout extends ViewGroup{

    public PullableLayout(Context context) {
        super(context);
    }

    public PullableLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
}

接下来,我们静静的思考一下所谓的下拉刷新,上拉加载的本质何如。就会发现,其实归根结底原理仍旧是“视图的滚动”而已。
那么,我们来分析下我们为什么会这么说呢?假设现在先在脑海中简单构画一下如下所示的这样一个ViewGroup的结构图:

假设上图中蓝色的部分就是屏幕区域,也就是我们想要呈现内容的区域(比如我们在这里放一个ListView)。而我们的ViewGroup所需要做的工作就是:
为Content部分加上一个Header(头视图)与Footer(尾视图),并且显然Header的位置应该位于Content之上,同理Footer则位于其之下。

那么,在这个基础上,如果我们让整个Viewgroup支持滚动,那么就得以实现一种效果了,即:初始情况下,屏幕上将正常呈现我们的Content视图。
与此同时:当我们上下滑动屏幕,那么当滑动到Content视图的顶部时,就会出现Header视图;当滑动到Content的底部时,则会出现Footer视图。

当然,这种纸上谈兵式的原理性的东西,永远都让人感到无聊。所以,现在我们实际的来“兑换”一下我们目前为止谈到的这种效果。看以下布局文件:

左边的布局非常简单和熟悉,就是显示一个宽高填满父窗口的ImageView。而在右边我们则是把父布局替换成了我们自定义的PullableLayout。

好的,现在我们就一起来看看,我们应该怎么样逐步完善PullableLayout让它实现我们说到的效果。
首先,既然我们说到需要一个Header与Footer。那么,我们就先来定义好这两个东东的布局。比如说,我们定义一个如下的Header布局:

这个布局还是非常简单明了的。同样的,Footer布局的定义其实与Header是非常类似的,所以就不再贴一次代码了。
准备好Header与Footer布局后,我们应该考虑的工作,就是怎么把它们按照我们的需要给“放进”我们自己的PullableLayout当中了,其实这并不难。

private View mHeader,mFooter;

    public PullableLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        mHeader = LayoutInflater.from(context).inflate(R.layout.header_pullable_layout,null);
        mFooter = LayoutInflater.from(context).inflate(R.layout.footer_pullable_layout,null);
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        // 看这里哦,亲
        RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams
                (RelativeLayout.LayoutParams.MATCH_PARENT,RelativeLayout.LayoutParams.MATCH_PARENT);
        mHeader.setLayoutParams(params);
        mFooter.setLayoutParams(params);
        addView(mHeader);
        addView(mFooter);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        // 测量
        for (int i = 0; i < getChildCount(); i++){
            View child = getChildAt(i);
            measureChild(child,widthMeasureSpec,heightMeasureSpec);
        }
    }

    private int mLayoutContentHeight;
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        mLayoutContentHeight = 0;
        // 置位
        for (int i = 0; i < getChildCount(); i++){
            View child = getChildAt(i);
            if (child == mHeader) { // 头视图隐藏在顶端
                child.layout(0, 0 - child.getMeasuredHeight(), child.getMeasuredWidth(), 0);
            } else if (child == mFooter) { // 尾视图隐藏在layout所有内容视图之后
                child.layout(0, mLayoutContentHeight, child.getMeasuredWidth(), mLayoutContentHeight + child.getMeasuredHeight());
            } else { // 内容视图根据定义(插入)顺序,按由上到下的顺序在垂直方向进行排列
                child.layout(0, mLayoutContentHeight, child.getMeasuredWidth(), mLayoutContentHeight + child.getMeasuredHeight());
                mLayoutContentHeight += child.getMeasuredHeight();
            }
        }
    }

以上的代码也并不复杂,核心的工作就是填充Header与Footer视图,并且按需要进行测量和置位的工作。如果作为新手来说,值得注意的可能就是:

  • Header与Footer的addView()工作:如果放在Constructor中,那么因为此时布局文件中的内容都还未进行装载和填充,就可能会在后续的代码中因为某些代码逻辑出现意料之外的异常错误;而如果放在onMeasure,则会因为onMeasure的内部机制造成重复add。所以放在onFinishInflate算是一个比较合适的选择。

  • 个人在这里定义了一个变量mLayoutContentHeight用来记录内容视图部分的实际总高度。需要注意的是,要在onLayout开头的地方将其置零,否则同样会因为重复累加得到错误的结果。

现在,当我们运行程序,就会在屏幕上呈现一个宽高占满屏幕的图片。目前看起来是与把ImageView放在其它常用的Layout中的效果是没有区别的。

所以,显然我们接下来要做的工作就是让视图能够跟随着我们的手指滚动起来。那么,还有什么好想的呢?自然就是覆写onTouchEvent了。

  private int mLastMoveY;
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int y = (int) event.getY();

        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                mLastMoveY = y;
                break;
            case MotionEvent.ACTION_MOVE:
                int dy = mLastMoveY - y;
                scrollBy(0, dy);
                break;
        }

        mLastMoveY = y;
        return true;
    }

我们看到现在似乎已经有点意思了,但其实显然是远远不够的。现在说穿了就只是一个支持滚动的视图而已,看上去非常呆板,更别提下拉刷新此类了。

那么,我们想一下应该怎么改进呢?有了,我们可以给每次的拉动设置一些相关信息,比如“最大滚动距离,有效距离”等等。这是什么意思呢?
打个比方:当拉动的距离超过了最大距离,我们就不允许视图继续滚动了;而当此次拉动的距离超过有效距离我们就认为这是一次有效的行为。
那么现在我们先做点小改进,当拉动的距离超过有效距离,我们就将文字信息改为“松开刷新”,以提示用户你现在松开手指就会执行刷新的行为了。

            case MotionEvent.ACTION_MOVE:
                int dy = mLastMoveY - y;
                // dy < 0代表是针对下拉刷新的操作
                if(dy < 0) {
                    if(Math.abs(getScrollY()) <= mHeader.getMeasuredHeight() / 2) {
                        scrollBy(0, dy);
                        if(Math.abs(getScrollY()) >= effectiveScrollY){
                            tvPullHeader.setText("松开刷新");
                        }
                    }
                }
                break;

这里我们所做的改动实际就是:当进行下拉操作的时候,如果下拉距离已经达到header的一半高度,就不允许继续下拉了。
同时来说,如果当我们的拉动行为超过了有效距离effectiveScrollY,就提示用户可以“松开刷新”了。同样的,看看效果如何:

显然,我们又向前迈进了小小的一步。但最终的效果依旧有些呆板。因为虽然提示了可以“松开刷新”,但现在即使我们松开,也不会有任何效果。
松开手指却没有对应效果,显然是因为我们还没有在Action_Up的时候做对应的操作,那么现在就来进一步的修改吧:

            case MotionEvent.ACTION_UP:
                if(Math.abs(getScrollY()) >= effectiveScrollY){
                    mLayoutScroller.startScroll(0, getScrollY(), 0, -(getScrollY() + effectiveScrollY));
                    tvPullHeader.setVisibility(View.GONE);
                    pbPullHeader.setVisibility(View.VISIBLE);
                }else{
                    mLayoutScroller.startScroll(0, getScrollY(), 0, -getScrollY());
                }
                break;

因为仅仅是为了说明原理,所以这一步的改动代码也非常的简单。简单来说就是:如果松开手指时,滑动的距离并未超过有效距离,我们就认为这并不是一次成功有效的刷新行为,那么让view的位置变动恢复就行了。而如果手指离开时,已经滑动超过了有效驱离,则将view滑动到刚好能够让Header显示出有效距离的部分的位置,来提示用户正处于刷新的状态下。对应下面的效果图就更容易理解我们所做的工作是什么了:

让人高兴的是,到了这里看上去效果就很不错了。但虽然效果是有了,看上去像是在刷新,实际却没有执行任何实际用于刷新的操作。
所以说,显然我们还需要提供一个回调接口,让client端在使用的时候能够顺利在合适的时机执行需要的操作(刷新/加载)。

 public interface onRefreshListener{
        void onRefresh();
    }

    private onRefreshListener mRefreshListener;

    public void setRefreshListener(onRefreshListener listener){
        mRefreshListener = listener;
    }

    public void refreshDone(){
        mLayoutScroller.startScroll(0, getScrollY(), 0, -getScrollY());
        pbPullHeader.setVisibility(View.GONE);
        tvPullHeader.setText("继续向下拉");
        tvPullHeader.setVisibility(View.VISIBLE);
    }

case MotionEvent.ACTION_UP:
if(Math.abs(getScrollY()) >= effectiveScrollY){
   // 省略之前的代码......
                    
   // 执行回调
   mRefreshListener.onRefresh();
}else{
   mLayoutScroller.startScroll(0, getScrollY(), 0, -getScrollY());
}
break;

public class MainActivity extends AppCompatActivity {
    private PullableLayout plMain;
    private ImageView iv;

    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            iv.setBackgroundResource(R.drawable.ace);
            plMain.refreshDone();
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        iv = (ImageView) findViewById(R.id.iv);
        plMain = (PullableLayout) findViewById(R.id.pl_main);
        plMain.setRefreshListener(new PullableLayout.onRefreshListener() {
            @Override
            public void onRefresh() {
                 new Thread(new Runnable() {
                     @Override
                     public void run() {
                         try {
                             Thread.sleep(3000);
                         } catch (InterruptedException e) {
                             e.printStackTrace();
                         }

                         mHandler.sendEmptyMessage(0);
                     }
                 }).start();
            }
        });
    }
}

OK,大功告成,现在我们在来看一看效果如何:

可以看到,到这里我们就已经完全实现了“下拉刷新”这一功能了。当然这里只是为了演示原理的demo,所以很多代码都没有那么的追求严谨。
当然,这里要总结的重点其实也只是个人的思路和实现原理而已。所以同理,只要理解了这种思路,“上拉加载”也同样就能够实现了,故不再赘述。

那么,是不是到了这里,我们就可以结束了呢?当然不是,因为之前我们说过需要让我们的PullableLayout是通用的。而以目前来说:
我们绝大多数普通的常用控件,是能够通用的。但是呢?对另一类以ListView,GridView,RecyclerView,ScrollView为代表的控件就不灵了。
显然,这类控件与普通的View相比,最大的特点就是:它们自身就是支持滚动的。所以无法避免的,就会与我们的控件出现“滑动冲突”。

那么,关于“滑动冲突”的解决方案,可以参考《Android开发艺术探索》,作者针对各种常见的滑动冲突都给出了非常实用的干货方案。
OK,这里我们假设以ListView与我们自定义的Layout配合使用为例。那么出现的滑动冲突就是,双方都需要处理上下滑动的行为。
《Android开发艺术探索》中已经说过,这种冲突往往都可以从业务逻辑上找到突破口。那么,我们来思考一下这个所谓的“突破口”:
显然,如果我们的ListView需要下拉刷新或者上拉加载,那么刷新行为的发生时机就是在ListView的内容已经到达最现有的最顶部时,再继续下拉。
同理,加载的行为发生的时机就是内容已经到达最现有的最底部时,继续上拉。所以,如此一分析,这个突破口就已经出现了:
以下拉行为为例,我们就应该在ListView未到达顶部的情况下,将滑动事件交给ListView处理。而如果已经到达顶部,就将事件拦截,自己处理

现在我们的思路已经明确了,接着要做的,自然就是将思路转化到代码上面了。其实,所谓的“滑动冲突”的处理,最终实际就是回归到在ViewGroup的onInterceptTouchEvent方法上根据业务逻辑处理事件的拦截。对应我们这里的需求来说,以ListView的下拉操作为例,就可以这样做:

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {

        boolean intercept = false;
        // 记录此次触摸事件的y坐标
        int y = (int) event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                intercept = false;
                break;
            }

            case MotionEvent.ACTION_MOVE: {
                if (y > mLastMoveY) { // 下滑操作
                    View child = getChildAt(0);
                    if (child instanceof AdapterView) {
                        AdapterView adapterChild = (AdapterView) child;
                        // 判断AbsListView是否已经到达内容最顶部(如果已经到达最顶部,就拦截事件,自己处理滑动)
                        if (adapterChild.getFirstVisiblePosition() == 0
                                || adapterChild.getChildAt(0).getTop() == 0) {
                            intercept = true;
                        }
                    }
                }

                break;
            }
            // Up事件
            case MotionEvent.ACTION_UP: {
                intercept = false;
                break;
            }
        }

        mLastMoveY = y;
        return intercept;
    }

好了,差不多就是这样了。再次说明这里主要旨在总结和分享一下个人对于此类需求的实现思路。当然大家可能会有更加优秀的实现方式,请多多指教。
另外,也可能有朋友注意到在最初的演示图中,使用了两个比较有趣的Loading动画。一个是下拉时的小幽灵,一个时上拉时的吃豆子的形象。
同样再次申明:这两种效果都来自Github上一位作者开源的库:https://github.com/ldoublem/LoadingView,里面有很多有意思的Loading效果。
个人而言,对那个小幽灵的形象比较有兴趣,所以也简单研究了下作者的源码。如果您也有兴趣,那也可以看一看我之前写的:用Canvas和属性动画造一只萌蠢的“小鬼”

推荐阅读更多精彩内容

  • 本文算是对之前的一篇博文《自个儿写Android的下拉刷新/上拉加载控件》的续章,如果有兴趣了解更多的朋友可以先看...
    Machivellia阅读 3,195评论 8 70
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 158,523评论 24 688
  • 一、Android开发初体验 二、Android与MVC设计模式模型对象存储着应用的数据和业务逻辑。模型类通常用来...
    为梦想战斗阅读 530评论 0 3
  • 一级标题 二级标题 三级标题 四级标题 我很帅 很阳光 美死你 下边是有序列表 显示数字了 刚才是缺少回车键吧df...
    空闲自闭症阅读 89评论 0 1
  • 今天体检。 当然不收钱,所以从这点来说比北师的好。检的项目也比较关键,尿样血样心电图血压等。没有查视力太好了。抽血...
    GSES94阅读 120评论 0 0