Android学习笔记——自定义下拉刷新

标签: Android

前言

学习Android的时候,总是对自定义View心心念念,但作为一个小白,奈何实力有限。最近又学习了一些与View知识体系相关的事情,心血来潮,想自己自定义一个下拉刷新的控件,也当做对近日理论知识的学习做一次实践,但是限于实力这里只做主体功能。

计划

第一步:我们都知道Android原生有一套下拉刷新的控件(SwipeRefreshLayout),它继承自ViewGroup,所以我们自定义的控件继承自ViewGroup。

第二步:我们知道ViewGroup的onLayout()方法是需要自己覆写的,同样也考虑测量的问题,我们覆写onMeasure()方法和onLayout()完成自定义控件及其子控件的测量和定位

第三步:这里假设我们的子View是个RecyclerView,所以要考虑滑动冲突,覆写onIntercepetEvent()处理滑动冲突

第四步:我觉得加个刷新头部(Header)效果会更好,所以我们添加一个刷新头部,但是为了方(tou)便(lan),这里只用一个TextView。

第五步:我们需要解决触控事件,所以这里我们覆写onTouchEvent()方法解决具体的触控事件。

ps:这里的步骤不一定按顺序,实力有限,有错误还请同志指点指点

创建Header的xml文件

这里我创建了一个xml文件用于写刷新的布局:header.xml。只包含一个简单的TextView

<LinearLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="wrap_content"
  android:orientation="horizontal">

  <TextView
      android:id="@+id/state"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:textSize="18dp"
      android:layout_gravity="center_horizontal"
      android:gravity="center_horizontal"/>

</LinearLayout>

自定义下拉刷新文件

  1. 这里创建RefreshWithHeader继承ViewGroup,然后创建它的类构造器,具体代码如下:
   public RefreshWithHeader(Context context) {
        super(context);
    }

    public RefreshWithHeader(Context context, AttributeSet attrs) {
        this(context,attrs,0);
    }

    public RefreshWithHeader(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        //这里创建的ViewGroup不需要绘制,调用这个方法提升性能
        setWillNotDraw(true);
        //填充View拿到头布局
        mHeader = LayoutInflater.from(this.getContext()).inflate(R.layout.header,this,false);
        mState = mHeader.findViewById(R.id.state);
        mState.setText("下拉刷新");
        //将头布局添加到当前View中,并设置为第一个子View
        addView(mHeader,0);
        touchSlop = ViewConfiguration.getTouchSlop();
        mRefreshListeners = new ArrayList<>();
    }
  1. 接下来,我们覆写ViewGroup的onMeasure()方法,重新定义测量过程:
    我们自定义的控件作用是下拉刷新,所以我们在测量的时候着重点在于高度(Height)的测量
    为了简便这里没有对当前ViewGroup的padding属性做处理,也没有对子View的layout_margin做处理,但是实际项目中应该做处理。
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int measureWidth = 0;
        int measureHeight = 0;
        final int childrenCount = getChildCount();
        //首先通知子View去测量自己的尺寸。
        measureChildren(widthMeasureSpec,heightMeasureSpec);

        // 获取自己的测量宽度
        int widthSpaceSize = MeasureSpec.getSize(widthMeasureSpec);
        // 获取自己测量宽度用的测量模式
        int widthSpaceMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpaceSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightSpaceMode = MeasureSpec.getMode(heightMeasureSpec);

        if(childrenCount == 0){
        //若当前ViewGroup没有子View,则设置自己宽高的默认值为0
            setMeasuredDimension(0,0);
        //若有子ViewGroup,且子View的宽高都是wrap_content
        }else if(widthSpaceMode == MeasureSpec.AT_MOST && heightSpaceMode == MeasureSpec.AT_MOST){
            //遍历子View
            for(int i = 0;i<childrenCount;i++){
                if(getChildAt(i)!=null) {
                    //我么设置当前ViewGroup的高度为所有子View的高度之和
                    measureHeight += getChildAt(i).getMeasuredHeight();
                    //我们设置当前ViewGroup的宽度为所有子View中最宽的
                    measureWidth = Math.max(measureWidth, getChildAt(i).getMeasuredWidth());
                }
            }
            //这里把我们的宽度和高度设置成默认尺寸
            setMeasuredDimension(measureWidth,measureHeight);
            //如果只有宽度为wrap_content
        }else if(widthSpaceMode == MeasureSpec.AT_MOST){
            for (int i = 0;i < childrenCount;i++){
                if(getChildAt(i)!=null)
                measureWidth = Math.max(measureWidth,getChildAt(i).getMeasuredWidth());
            }
            setMeasuredDimension(measureWidth,heightSpaceSize);
            //如果只有高度为wrap_content
        }else if(heightSpaceMode == MeasureSpec.AT_MOST){
            for(int i = 0; i<childrenCount;i++){
                if(getChildAt(i)!=null)
                measureHeight += getChildAt(i).getMeasuredHeight();
            }
            setMeasuredDimension(widthSpaceSize,measureHeight);
        }
    }

MeasureSpec是一个32位的int值,前两位对应View的测量模式,后三十位对应View的测量大小。测量模式和测量大小分别可由MeasureSpec的静态方法getSize()getMode()获得。

  1. 测量完了之后,我们要确定当前ViewGroup的位置,这里简称定位,这里我们要关注的点是:我们的刷新头是默认隐藏的。接下来我们看看定位的详细代码:
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        final int childrenCount = getChildCount();
        //我们要隐藏刷新头,所以把第一个子View的顶部设置为负的高度
        int childrenTop = -mHeader.getMeasuredHeight();

        //遍历子View,一个一个定位子View
        for(int i = 0;i < childrenCount;i++){
            final View child = getChildAt(i);
            //确认子View是可见的
            if(child.getVisibility() != GONE){
                child.layout(0,childrenTop,child.getMeasuredWidth(),
                        childrenTop+child.getMeasuredHeight());
                childrenTop += child.getMeasuredHeight();
            }
        }
            //我们在这里拿到他的会引起滑动冲突的子View
            mChild = (RecyclerView) getChildAt(childrenCount-1);
    }
  1. 定位完成了之后我们开始解决滑动冲突,我们尽量列出我们能想到的会引起滑动冲突的点:
  • RecyclerView滑到顶部item且还在继续往上滑的时候,需要当且的ViewGroup拦截事件
  • RecyclerView往下滑,但是刷新头还没有被隐藏时,依旧需要当前ViewGroup拦截事件
  • 其余情况可以交给子RecyclerView处理事件,当前ViewGrouop不拦截事件

接下来看看详细代码:

    public boolean onInterceptTouchEvent(MotionEvent ev) {
        //设置一个是否拦截的标志位
        boolean intercepted = false;
        //计算竖直方向上的距离差
        float deltaY = ev.getY() - mLastInterceptY;
       switch (ev.getAction()){
           case MotionEvent.ACTION_DOWN:
               //down事件默认不拦截
               intercepted = false;
               break;
           case MotionEvent.ACTION_MOVE:
               //判断竖直方向滑动大于默认最小滑动且子RecyclerView到达顶部
               if (deltaY>touchSlop && !mChild.canScrollVertically(-1)){
                   //上面讨论的第一种情况,需要拦截
                   intercepted = true;
                   //判断正在往下滑且刷新有还没有被隐藏
               }else if(deltaY < 0 && mHeader.getY()>-mHeader.getHeight()) {
                   //上面讨论的第二种情况,需要拦截
                   intercepted = true;
               }else {
                   intercepted = false;
               }
               break;
           case MotionEvent.ACTION_UP:
               // up事件默认不拦截
               intercepted = false;
               break;
       }
        mLastInterceptY = ev.getY();
       return intercepted;
    }
  1. 接下来我们来接觉触控事件,覆写onTouchEvent()方法。详细代码如下:
 public boolean onTouchEvent(MotionEvent event) {
        //我们不做多点触控
        if(event.getPointerCount()>1){
        //还原位置,及标志
           mFlags = -1;
           mLastY = 0;
           mHeader.layout(0, -mHeader.getHeight(), mHeader.getWidth(), 0);
                   mChild.layout(0, 0, getWidth(), mChild.getHeight());
            return false;
        }
        float y = event.getY();
        //计算竖直方向的高度差
        float deltaX = y - mLastY;
        //设置一个标志位决定是否消费事件
        boolean result = false;
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                mLastY = y;
                result = false;
                break;
            case MotionEvent.ACTION_MOVE:
                //判断微小滑动或者往下滑
                if(mFlags==REFRESHING){
                    return false;
                }
                if(deltaX<touchSlop){
                //刷新头隐藏或者没有完全拉出来
                    if(mHeader.getY() < 0){
                        mFlags = PULL_TO_REFRESH;
                        //设置刷新头的标题
                        updateHeaderTitle("下拉刷新");
                        result = false;
                    }
                       result = false;
                //判断当前没有处于正在刷新状态且刷新头完全显示出来了
                }else  if(mFlags != REFRESHING&& mHeader.getY()>0) {
                    //改变刷新头状态
                    updateHeaderTitle("松手刷新");
                    //设置释放刷新标志
                    mFlags = RELEASE_TO_REFRESH;
                }
                if(mLastY!=0&&event.getPointerCount()==1) {
                //通过layout()方法让view随着用户的滑动而移动
                    mHeader.layout(0, (int) mHeader.getY() + (int) deltaX/2, mHeader.getWidth()
                            , (int) mHeader.getY() + (int) deltaX/2 + mHeader.getHeight());
                    mChild.layout(0, (int) mChild.getY() + (int) deltaX/2, mChild.getWidth()
                            , (int) mChild.getY() + (int) deltaX/2 + mChild.getHeight());
                }
                mLastY = y;
                result = false;
                break;
            case MotionEvent.ACTION_UP:
               //判断刷新标志符
               if (mFlags == RELEASE_TO_REFRESH){
                   updateHeaderTitle("松手刷新");
                   //松手时处于RELEASE_TO_REFRESH状态就去刷新
                    refresh();
                   result = true;
                }
               if(!result) {
                   mHeader.layout(0, -mHeader.getHeight(), mHeader.getWidth(), 0);
                   mChild.layout(0, 0, getWidth(), mChild.getHeight());
               }
               //松手了就初始化标志符
                mFlags = -1;
                mLastY = 0;
               break;
        }
        return result;
    }

6.触控事件处理完了,接下来看看如何刷新的,在刷新的时候,用户会做一些其他的事情,我们需要给他们提供接口,监听若是ViewGroup处于刷新的状态下,则调用用户想做的事情。下面是详细代码:

 private void refresh(){
        //刷新的动画
        mFlags = REFRESHING;
        refreshAnimation();
        //处于刷新状态则调用用户要做的事情(回调)
        if(mRefreshListeners!=null&&mRefreshListeners.size()>0){
            for(RefreshListener refreshListener :mRefreshListeners){
                refreshListener.onRefresh();
            }
        }
        updateHeaderTitle("正在刷新");
        //这是为了显示效果,用handler做一个延时操作
        @SuppressLint("HandlerLeak") Handler handler = new Handler(){
            @Override
            public void handleMessage(Message msg) {
                if (msg.what == 1){
               initState();
                    Log.d(TAG,"refresh 正在刷新:" +distance);
                }
            }
        };
        handler.sendEmptyMessageDelayed(1,2000);
    }

    private void refreshAnimation(){
        // 这里用属性动画做一个平滑的过渡
        ObjectAnimator animator = ObjectAnimator.ofFloat(mHeader,"Y",
                mHeader.getY(),0);
        ObjectAnimator animator2 = ObjectAnimator.ofFloat(mChild,"Y",
                mChild.getY(),mHeader.getHeight());
        AnimatorSet set = new AnimatorSet();
        set.play(animator).with(animator2);
        set.setDuration(500);
        set.start();
        // 属性动画没有真正改变View的位置,所以我们再手动调整一次位置
        mHeader.layout(0,0, mHeader.getWidth(), mHeader.getHeight());
        mChild.layout(0, mHeader.getHeight(),getWidth(),mChild.getHeight()+ mHeader.getHeight());
    }

   /**
   *初始化View的状态和一些标志符
   **/
    private void initState(){
        mFlags = -1;
        mLastY = 0;
        ObjectAnimator animator = ObjectAnimator.ofFloat(mHeader,"Y",
                mHeader.getY(),-mHeader.getHeight());
        ObjectAnimator animator2 = ObjectAnimator.ofFloat(mChild,"Y",
                mChild.getY(),0);
        AnimatorSet set = new AnimatorSet();
        set.play(animator).with(animator2);
        set.setDuration(500);
        set.start();
        mHeader.layout(0,-mHeader.getHeight(), mHeader.getWidth(),0);
        mChild.layout(0,0,getWidth(),mChild.getHeight());
    }

到这里自定义的下拉刷新控件的主题内容基本就结束了。
我们来看看展示效果(虽然很简陋,还是展示一下)

刷新

作者是初学者,如有错误希望大神指点,还有谢谢能看到这的同学。

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