下拉刷新、上拉加载实战:带你理解自定义 View 整个过程

4,671 阅读13分钟

下拉刷新、上拉加载实战:带你理解自定义View整个过程

@(Android)

参考文章

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

写在前面的话

这篇文章主要是对以前学习的自定义View的一个小总结,拿这个例子来做再合适不过了。简单介绍一下,主要内容是参照 自个儿写Android的下拉刷新/上拉加载控件 这篇文章里面的内容(不是自定义ListView,而是ViewGroup,更有难度),但是我还是略有改动,感谢作者无私分享。前面也看了一些关于自定义View,事件分发,滑动冲突等内容,特别是郭神的书,让我受益匪浅。我的目的就是想带大家从实际的例子,来认识自定义View中几个关键的步骤,以及怎样与动画相结合,希望对一些童鞋能有所帮助。

效果图

这里写图片描述

Github地址

建议直接下载整个例子代码,然后跟着下面的步骤来理解

github.com/yixiaoming/…

正式开始

如果自定义View还不熟悉的,可以看看这篇基础知识,能对你有帮助 自定义View应该明白的基础知识

首先明确任务,我们要做的是自定义一个ViewGroup,然后你可以在这个ViewGroup中放入 ListView,RecyclerView,ScrollView只能的可滑动的view,然后给它们添加下拉刷新和上拉加载更多的功能。这和直接自定义ListView还是有一定的区别,后者可以直接使用 addHeader() ,addFooter() 添加头和尾,而我们需要自己测量,布局,处理滑动冲突等。来看一个图:

这里写图片描述

下面的代码不建议边看边贴,主要是理清思路,然后看完整项目再写

第一步:添加Header和Footer,并隐藏

我们定义一个PullRefreshLayout类,继承ViewGroup,需要重写构造方法(如果有自定义属性),onFinishInflate(),onMeasure(),onLayout(),如果你在这4个函数里面分别加上Log的话,你会发现它们的调用顺序就是前面的出现顺序,但是 onMeasure 和 onLayout 都会被多次调用。

下面展示的是主要过程,便于理解,具体代码可以看Github上完整源码。

onFinishInflate

Called after a view and all of its children has been inflated from XML.

public class PullRefreshLayout extends ViewGroup {
  //...

  //保持原样  
  public PullRefreshLayout(Context context, AttributeSet attrs) {
    super(context, attrs);
  }

  // 当view的所有child从xml中被初始化后调用
  @Override
  protected void onFinishInflate() {
    super.onFinishInflate();

    lastChildIndex = getChildCount() - 1;

    addHeader();
    addFooter();
  }
}

这个函数会在View的所有child从xml中被初始化后调用,紧接着构造函数。lastChildIndex记录xml中配置的最后一个child的索引,下面这样写,就可以获得 listview的索引,后面我们将用这个 索引获取到View,来判断footer是否显示。

<org.yxm.pullrefreshlayout.PullRefreshLayout
    android:id="@+id/main_pullrefresh_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ListView
      android:id="@+id/main_listview"
      android:layout_width="match_parent"
      android:layout_height="match_parent">
    </ListView>
</org.yxm.pullrefreshlayout.PullRefreshLayout>

然后还有 addHeader 和 addFooter,就是为 整个layout添加 Header和 Footer,以及初始化 header和footer中的 textview等。

  private void addHeader() {
    mHeader = LayoutInflater.from(getContext()).inflate(R.layout.pull_header, null, false);
    RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(
        LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
    addView(mHeader, params);

    mHeaderText = (TextView) findViewById(R.id.header_text);
    mHeaderProgressBar = (ProgressBar) findViewById(R.id.header_progressbar);
  }

  private void addFooter() {
    mFooter = LayoutInflater.from(getContext()).inflate(R.layout.pull_footer, null, false);
    RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(
        LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
    addView(mFooter, params);
    mFooterText = (TextView) findViewById(R.id.footer_text);
    mFooterProgressBar = (ProgressBar) findViewById(R.id.footer_progressbar);
  }

onMeasure

Called to determine the size requirements for this view and all of its children.

我们都知道 onMeasure 的作用是计算自己和所有孩子所需要的尺寸,上面我们提到 onMeasure 和 onLayout 都会被多次调用,就是因为我们定义的View中还有child,所以会被调用多次。所以我们还需要在里面计算所有child的尺寸。

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

onLayout

Called when this view should assign a size and position to all of its children.

onLayout在自己或child,的大小和位置发生变化时会被调用。它个主要的作用还是决定这个View应该放在那儿,怎么放。

  @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);
        mEffectiveHeaderHeight = child.getHeight();
      } else if (child == mFooter) {
        child.layout(0, mLayoutContentHeight, child.getMeasuredWidth(), mLayoutContentHeight + child.getMeasuredHeight());
        mEffictiveFooterHeight = child.getHeight();
      } else {
        child.layout(0, mLayoutContentHeight, child.getMeasuredWidth(), mLayoutContentHeight + child.getMeasuredHeight());
        if (i < getChildCount()) {
          if (child instanceof ScrollView) {
            mLayoutContentHeight += getMeasuredHeight();
            continue;
          }
          mLayoutContentHeight += child.getMeasuredHeight();
        }
      }
    }
  }

里面有几个重要的地方: layout 函数 的参数是 :(left,top,right,bottom)

如果是header,应该摆放在:
(0,- header height,header width,0)

footer应该摆放在:
(0,content height, footer width,content height + footer height)

如果是 ViewGroup 里面的内容,应该摆放在:
(0,content height,content width,content height + 当前加进来的child height)

需要注意的是,mLayoutContentHeight 是指所有content的高度,就是所有child加起来的高度,是一个不断累加的值,添加一个child就添加一些,但是不包括header和footer。

将内容摆放好,那么我们的第一步就完成了,并且header隐藏在上面,footer隐藏在下面。

第二步:处理滑动事件

处理滑动事件,我们需要注意两个函数:onTouchEventonInterceptTouchEvent,onTouchEvent处理touch事件,如按下,滑动,松开等。onInterceptTouchEvent 会在 onTouchEvent 前面执行,在这里需要判断是否应该拦截这个事件,然后交由我的 onTouchEvent 处理。一旦 onInterceptTouchEvent 返回 true 表示拦截,后续事件都会交给 onTouchEvent 处理,onInterceptTouchEvent 都不会再执行,下一次按下事件。不知道这样描述有没有问题,如果不清楚,你可以在两个函数里面添加 Log ,然后试一试。

onInterceptTouchEvent

我们需要在这个函数中判断是否应该拦截滑动事件,例如child是一个ListView,那么它没有滑到头或者没有滑到尾的时候,我们都不应该拦截,ACTION_DOWN和ACTION_UP和不需要拦截,当事件为 ACTION_MOVE 时,如果是向下滑动,判断第一个child是否滑倒最上面,如果是,则更新状态为 TRY_REFRESH;如果是向上滑动,则判断最后一个child是否滑动最底部,如果是,则更新状态为TRY_LOADMORE。然后返回 intercept = true。这样接下来的滑动事件就会传给本类的 onTouchEvent 处理。

  @Override
  public boolean onInterceptTouchEvent(MotionEvent event) {
    boolean intercept = false;
    int y = (int) event.getY();

    if (mStatus == Status.REFRESHING || mStatus == Status.LOADING) {
      return false;
    }

    switch (event.getAction()) {
      case MotionEvent.ACTION_DOWN: {
        // 拦截时需要记录点击位置,不然下一次滑动会出错
        mlastMoveY = y;
        intercept = false;
        break;
      }
      case MotionEvent.ACTION_MOVE: {

        //向下滑动
        if (y > mLastYIntercept) {
          View child = getChildAt(0);
          intercept = getRefreshIntercept(child);

          if (intercept) {
            updateStatus(mStatus.TRY_REFRESH);
          }
        } 
        //向上滑动
        else if (y < mLastYIntercept) {
          View child = getChildAt(lastChildIndex);
          intercept = getLoadMoreIntercept(child);

          if (intercept) {
            updateStatus(mStatus.TRY_LOADMORE);
          }
        } else {
          intercept = false;
        }
        break;
      }
      case MotionEvent.ACTION_UP: {
        intercept = false;
        break;
      }
    }

    mLastYIntercept = y;
    return intercept;
  }

至于怎么判断是否应该拦截,这里不同的ViewGroup判断方法不一样,主要分为 ScrollView,ListView,RecyclerView,这里的内容要繁琐一点,可以直接跳过。

/*汇总判断 刷新和加载是否拦截*/
  private boolean getRefreshIntercept(View child) {
    boolean intercept = false;

    if (child instanceof AdapterView) {
      intercept = adapterViewRefreshIntercept(child);
    } else if (child instanceof ScrollView) {
      intercept = scrollViewRefreshIntercept(child);
    } else if (child instanceof RecyclerView) {
      intercept = recyclerViewRefreshIntercept(child);
    }
    return intercept;
  }


  private boolean getLoadMoreIntercept(View child) {
    boolean intercept = false;

    if (child instanceof AdapterView) {
      intercept = adapterViewLoadMoreIntercept(child);
    } else if (child instanceof ScrollView) {
      intercept = scrollViewLoadMoreIntercept(child);
    } else if (child instanceof RecyclerView) {
      intercept = recyclerViewLoadMoreIntercept(child);
    }
    return intercept;
  }
  /*汇总判断 刷新和加载是否拦截*/

  /*具体判断各种View是否应该拦截*/
  // 判断AdapterView下拉刷新是否拦截
  private boolean adapterViewRefreshIntercept(View child) {
    boolean intercept = true;
    AdapterView adapterChild = (AdapterView) child;
    if (adapterChild.getFirstVisiblePosition() != 0
        || adapterChild.getChildAt(0).getTop() != 0) {
      intercept = false;
    }
    return intercept;
  }

  // 判断AdapterView加载更多是否拦截
  private boolean adapterViewLoadMoreIntercept(View child) {
    boolean intercept = false;
    AdapterView adapterChild = (AdapterView) child;
    if (adapterChild.getLastVisiblePosition() == adapterChild.getCount() - 1 &&
        (adapterChild.getChildAt(adapterChild.getChildCount() - 1).getBottom() >= getMeasuredHeight())) {
      intercept = true;
    }
    return intercept;
  }

  // 判断ScrollView刷新是否拦截
  private boolean scrollViewRefreshIntercept(View child) {
    boolean intercept = false;
    if (child.getScrollY() <= 0) {
      intercept = true;
    }
    return intercept;
  }

  // 判断ScrollView加载更多是否拦截
  private boolean scrollViewLoadMoreIntercept(View child) {
    boolean intercept = false;
    ScrollView scrollView = (ScrollView) child;
    View scrollChild = scrollView.getChildAt(0);

    if (scrollView.getScrollY() >= (scrollChild.getHeight() - scrollView.getHeight())) {
      intercept = true;
    }
    return intercept;
  }

  // 判断RecyclerView刷新是否拦截
  private boolean recyclerViewRefreshIntercept(View child) {
    boolean intercept = false;

    RecyclerView recyclerView = (RecyclerView) child;
    if (recyclerView.computeVerticalScrollOffset() <= 0) {
      intercept = true;
    }
    return intercept;
  }

  // 判断RecyclerView加载更多是否拦截
  private boolean recyclerViewLoadMoreIntercept(View child) {
    boolean intercept = false;

    RecyclerView recyclerView = (RecyclerView) child;
    if (recyclerView.computeVerticalScrollExtent() + recyclerView.computeVerticalScrollOffset()
        >= recyclerView.computeVerticalScrollRange()) {
      intercept = true;
    }

    return intercept;
  }
  /*具体判断各种View是否应该拦截*/

onTouchEvent

这里面就是处理拦截后的touch事件,我们主要根据滑动的位置来做状态的修改,和属性动画的控制。

下面的代码我们先没有加动画,先理清楚思路。

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

    // 正在刷新或加载更多,避免重复
    if (mStatus == Status.REFRESHING || mStatus == Status.LOADING) {
      return true;
    }

    switch (event.getAction()) {
      case MotionEvent.ACTION_DOWN:
        mlastMoveY = y;
        break;
      case MotionEvent.ACTION_MOVE:
        int dy = mlastMoveY - y;

        // 一直在下拉
        if (getScrollY() <= 0 && dy <= 0) {
          if (mStatus == Status.TRY_LOADMORE) {
            scrollBy(0, dy / 100);
          } else {
            scrollBy(0, dy / 3);
          }
        }
        // 一直在上拉
        else if (getScrollY() >= 0 && dy >= 0) {
          if (mStatus == Status.TRY_REFRESH) {
            scrollBy(0, dy / 100);
          } else {
            scrollBy(0, dy / 3);
          }
        } else {
          scrollBy(0, dy / 3);
        }

        beforeRefreshing();
        beforeLoadMore();

        break;
      case MotionEvent.ACTION_UP:
        // 下拉刷新,并且到达有效长度
        if (getScrollY() <= -mEffectiveHeaderHeight) {
          releaseWithStatusRefresh();
          if (mRefreshListener != null) {
            mRefreshListener.refreshFinished();
          }
        }
        // 上拉加载更多,达到有效长度
        else if (getScrollY() >= mEffictiveFooterHeight) {
          releaseWithStatusLoadMore();
          if (mRefreshListener != null) {
            mRefreshListener.loadMoreFinished();
          }
        } else {
          releaseWithStatusTryRefresh();
          releaseWithStatusTryLoadMore();
        }
        break;
    }

    mlastMoveY = y;
    return super.onTouchEvent(event);
  }

第一个: mlastMoveY,这里采取的是 scrollBy相对滑动的方式,每向下移动一点,就会触发 onTouchEvent,用当前event的y 减去 上一次记录的y,就是我刚刚滑动的一点点距离,然后使用 scrollBy 将整个view 向下滑动一点点,如果动作连贯就形成了滑动的效果。

第二个: ACTION_MOVE 时的状态变化,注意这里的两个距离:getScrollY() 获得的是整体,在我松开之前,整体的View在Y轴上滑动的距离,为负值表示整体往下滑动。dy = mLastY - y,表示刚刚 scrollBy 滑动的一小段距离是向上还是向下,如果为负,表示向下滑动一点点。

这里情况稍微复杂一点,这里举下拉的例子,记住我们实在 onIntercetpTouchEvent 中做得事件拦截,并且如果是下拉就将 mStatus = Status.TRY_REFRESH。拦截之后知道你松开手指,所有事件都直接传递个 onTouchEvent ,而不会再经过地方。

滑动的距离分为下面几种情况,假设有效距离20:

  1. 如果我们一直下拉,拉到20松开就可以更新,这是最好的情况。
  2. 如果一直下拉,拉了20。然后又慢慢向上移动滑上去到10松开,不应该更新。但是整体效果也是向下拉的,不会有问题。
  3. 如果一直下拉,拉了10,这时反向向上滑动,返回到原来位置,甚至负数,那么这个时候layout整体向上移动,导致下面的加载更多出现,这种情况是不对的。应该是在返回到原来位置时,将拦截设置为false,交给child去处理,但是我们刚刚说了,直到松开手指,onInterceptTouchEvent 都不会被调用。所以这里做了这种判断,如果前面记录了是想下拉,但是又反向超过了原来位置,则使反向拉特别费力 dy / 100,让下半部无法出现,迫使用户松开手指。这种处理不是太好,但是我也没有想到更好的方法。

其他的情况都好处理,直接滑动就好,scrollBy 的距离是 实际距离/3是想造成简单的阻尼运动的效果。

if (getScrollY() >= 0 && dy >= 0) {
  if (mStatus == Status.TRY_REFRESH) {
    scrollBy(0, dy / 100);
  } else {
    scrollBy(0, dy / 3);
  }
}
else {
    scrollBy(0, dy / 3);
}

然后 beforeRefreshingbeforeLoadMore就是和用户交互所需要做的事情。比如滑动达到有效距离,更新文字,出现图标。然后又滑回去,又修改文字,消失图标,这里先做简单的处理,后面需要和动画相结合。

  public void beforeRefreshing() {
    if (getScrollY() <= -mEffectiveHeaderHeight) {
      mHeaderText.setText("松开刷新");
    } else {
      mHeaderText.setText("下拉刷新");
    }
  }

  public void beforeLoadMore() {
    if (getScrollY() >= mEffectiveHeaderHeight) {
      mFooterText.setText("松开加载更多");
    } else {
      mFooterText.setText("上拉加载更多");
    }
  }

第三个:当手指抬起的时候,会相应 ACTION_UP 事件,这时我们我们需要根据是否达到有效距离,做后续的工作,这里直接看代码就可以理解。

        // 下拉刷新,并且到达有效长度
        if (getScrollY() <= -mEffectiveHeaderHeight) {
          releaseWithStatusRefresh();
          if (mRefreshListener != null) {
            mRefreshListener.refreshFinished();
          }
        }
        // 上拉加载更多,达到有效长度
        else if (getScrollY() >= mEffictiveFooterHeight) {
          releaseWithStatusLoadMore();
          if (mRefreshListener != null) {
            mRefreshListener.loadMoreFinished();
          }
        } else {
          releaseWithStatusTryRefresh();
          releaseWithStatusTryLoadMore();
        }

具体实现

  private void releaseWithStatusTryRefresh() {
    scrollBy(0, -getScrollY());
    mHeaderText.setText("下拉刷新");
    updateStatus(Status.NORMAL);
  }

  private void releaseWithStatusTryLoadMore() {
    scrollBy(0, -getScrollY());
    mFooterText.setText("上拉加载更多");
    updateStatus(Status.NORMAL);
  }

  private void releaseWithStatusRefresh() {
    scrollTo(0, -mEffectiveHeaderHeight);
    mHeaderProgressBar.setVisibility(VISIBLE);
    mHeaderText.setText("正在刷新");
    updateStatus(Status.REFRESHING);
  }

  private void releaseWithStatusLoadMore() {
    scrollTo(0, mEffictiveFooterHeight);
    mFooterText.setText("正在加载");
    mFooterProgressBar.setVisibility(VISIBLE);
    updateStatus(Status.LOADING);
  }

  public void refreshFinished() {
    scrollTo(0, 0);
    mHeaderText.setText("下拉刷新");
    mHeaderProgressBar.setVisibility(GONE);
    updateStatus(Status.NORMAL);
  }

  public void loadMoreFinished() {
    mFooterText.setText("上拉加载");
    mFooterProgressBar.setVisibility(GONE);
    scrollTo(0, 0);
    updateStatus(Status.NORMAL);
  }

到这里主要的逻辑已经走完了,下面我们来看看和用户的交互动画怎么添加。

第三部:交互动画

如果在这个过程中只使用文字,用户体验是很差的,所以我们需要用一些动画效果来提示用户应该怎么做,增强用户体验。一般下拉刷新都会有一个小图标,指示下拉的程度,然后提示用户松开,我们这里用一个小箭头来做指示,根据用户拉下的距离计算小箭头应该旋转的角度,做一个小交互。

首先看一下 header的xml文件:pull_header.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="match_parent"
                android:padding="10dp">

  <TextView
    android:textSize="16sp"
    android:id="@+id/header_text"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_centerInParent="true"
    android:text="下拉刷新"/>

  <ProgressBar
    android:id="@+id/header_progressbar"
    android:layout_width="30dp"
    android:layout_height="30dp"
    android:layout_toLeftOf="@+id/header_text"
    android:visibility="gone"/>

  <ImageView
    android:id="@+id/header_arrow"
    android:layout_width="30dp"
    android:layout_height="30dp"
    android:layout_centerVertical="true"
    android:layout_toLeftOf="@+id/header_text"
    android:layout_toStartOf="@+id/header_text"
    android:src="@mipmap/ic_action_arrow_bottom"/>

</RelativeLayout>

计算旋转角度

逻辑理清楚,3个控件:1. 文字提示,2.运行进度条在刷新时显示,3.箭头图标根据滑动距离旋转角度,刷新时隐藏。

首先解决旋转问题,根据滑动距离计算旋转角度,首先我们应该想到在 onTouchEvent 中的ACTION_MOVE 中解决,还记得我们前面下了一个 beforeRefreshing 函数,专门用来处理文字的改变和动画的处理,这里我们就直接在这个函数中添加交互动画:

  public void beforeRefreshing(float dy) {
    //计算旋转角度
    int scrollY = Math.abs(getScrollY());
    scrollY = scrollY > mEffectiveHeaderHeight ? mEffectiveHeaderHeight : scrollY;
    float angle = (float) (scrollY * 1.0 / mEffectiveHeaderHeight * 180);
    //旋转角度
    mHeaderArrow.setRotation(angle);


    if (getScrollY() <= -mEffectiveHeaderHeight) {
      mHeaderText.setText("松开刷新");
    } else {
      mHeaderText.setText("下拉刷新");
    }
  }

首先根据滑动的距离,最大是header的高度,然后计算旋转角度比例*180,就得到了旋转的角度,然后直接将ImageView的rotation设置旋转角度,就完成了,就是这么简单。在做之前我还想用属性动画来做,尝试了一下,各种问题,呵呵,只怪自己没有经验,像这种瞬时的动画,还是直接设置属性来的简单。

然后就是在松开手时隐藏箭头,显示进度条。

  private void releaseWithStatusRefresh() {
    scrollTo(0, -mEffectiveHeaderHeight);
    mHeaderProgressBar.setVisibility(VISIBLE);
    mHeaderText.setText("正在刷新");
    // 新加
    mHeaderArrow.setVisibility(GONE);

    updateStatus(Status.REFRESHING);
  }

加载完成隐藏进度条,显示箭头。

  private void refreshFinished() {
    scrollTo(0, 0);
    mHeaderText.setText("下拉刷新");
    mHeaderProgressBar.setVisibility(GONE);
    // 新加
    mHeaderArrow.setVisibility(VISIBLE);

    updateStatus(Status.NORMAL);
  }

这样整个简单的交互动画也完成了。

写在最后的话

到这里,3个步骤已经分析得很详细,自定义View到底应该怎么做,并且将交互动画也添加了进来,结合Github上的整个代码,希望你能理解。自定义View也是有很多的套路的,自己可以琢磨琢磨。再次感谢参考文章的作者,从他的文章中我理解很多细节上的内容。