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());
    }

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

刷新

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。