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

RobEZB 8年前
   <h2>写在前面的话</h2>    <p>这篇文章主要是对以前学习的自定义View的一个小总结,拿这个例子来做再合适不过了。简单介绍一下,主要内容是参照 <a href="/misc/goto?guid=4959734115995215203" rel="nofollow,noindex">自个儿写Android的下拉刷新/上拉加载控件</a> 这篇文章里面的内容(不是自定义ListView,而是ViewGroup,更有难度),但是我还是略有改动,感谢作者无私分享。前面也看了一些关于自定义View,事件分发,滑动冲突等内容,特别是郭神的书,让我受益匪浅。我的目的就是想带大家从实际的例子,来认识自定义View中几个关键的步骤,以及怎样与动画相结合,希望对一些童鞋能有所帮助。</p>    <h2>效果图</h2>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/dc270a79e0e680a2a6a7c16beeb84610.gif"></p>    <h2>Github地址</h2>    <p>建议直接下载整个例子代码,然后跟着下面的步骤来理解</p>    <p><a href="/misc/goto?guid=4959739239467105808" rel="nofollow,noindex">https://github.com/yixiaoming/PullRefreshLayout</a></p>    <h2>正式开始</h2>    <p>如果自定义View还不熟悉的,可以看看这篇基础知识,能对你有帮助 自定义View应该明白的基础知识 。</p>    <p>首先明确任务,我们要做的是自定义一个ViewGroup,然后你可以在这个ViewGroup中放入 ListView,RecyclerView,ScrollView只能的可滑动的view,然后给它们添加下拉刷新和上拉加载更多的功能。这和直接自定义ListView还是有一定的区别,后者可以直接使用 addHeader() ,addFooter() 添加头和尾,而我们需要自己测量,布局,处理滑动冲突等。来看一个图:</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/b88ae69785dbccddcd317a254b0eeeaf.png"></p>    <p>下面的代码不建议边看边贴,主要是理清思路,然后看完整项目再写</p>    <h2>第一步:添加Header和Footer,并隐藏</h2>    <p>我们定义一个PullRefreshLayout类,继承ViewGroup,需要重写构造方法(如果有自定义属性),onFinishInflate(),onMeasure(),onLayout(),如果你在这4个函数里面分别加上Log的话,你会发现它们的调用顺序就是前面的出现顺序,但是 onMeasure 和 onLayout 都会被多次调用。</p>    <p>下面展示的是主要过程,便于理解,具体代码可以看Github上完整源码。</p>    <h3>onFinishInflate</h3>    <p>Called after a view and all of its children has been inflated from XML.</p>    <pre>  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();    }  }</pre>    <p>这个函数会在View的所有child从xml中被初始化后调用,紧接着构造函数。 <strong>lastChildIndex</strong> 记录xml中配置的最后一个child的索引,下面这样写,就可以获得 listview的索引,后面我们将用这个 索引获取到View,来判断footer是否显示。</p>    <pre>  <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></pre>    <p>然后还有 addHeader 和 addFooter,就是为 整个layout添加 Header和 Footer,以及初始化 header和footer中的 textview等。</p>    <pre>  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);    }</pre>    <h3>onMeasure</h3>    <p>Called to determine the size requirements for this view and all of its children.</p>    <p>我们都知道 onMeasure 的作用是计算自己和 <strong>所有孩子</strong> 所需要的尺寸,上面我们提到 onMeasure 和 onLayout 都会被多次调用,就是因为我们定义的View中还有child,所以会被调用多次。所以我们还需要在里面计算所有child的尺寸。</p>    <pre>  @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);      }    }</pre>    <h3>onLayout</h3>    <p>Called when this view should assign a size and position to all of its children.</p>    <p>onLayout在自己或child,的大小和位置发生变化时会被调用。它个主要的作用还是决定这个View应该放在那儿,怎么放。</p>    <pre>  @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();          }        }      }    }</pre>    <p>里面有几个重要的地方: layout 函数 的参数是 :(left,top,right,bottom)</p>    <p>如果是header,应该摆放在:</p>    <p>(0,- header height,header width,0)</p>    <p>footer应该摆放在:</p>    <p>(0,content height, footer width,content height + footer height)</p>    <p>如果是 ViewGroup 里面的内容,应该摆放在:</p>    <p>(0,content height,content width,content height + 当前加进来的child height)</p>    <p>需要注意的是, <strong>mLayoutContentHeight</strong> 是指所有content的高度,就是所有child加起来的高度,是一个不断累加的值,添加一个child就添加一些,但是不包括header和footer。</p>    <p>将内容摆放好,那么我们的第一步就完成了,并且header隐藏在上面,footer隐藏在下面。</p>    <h2>第二步:处理滑动事件</h2>    <p>处理滑动事件,我们需要注意两个函数: <strong>onTouchEvent</strong> 和 <strong>onInterceptTouchEvent</strong> ,onTouchEvent处理touch事件,如按下,滑动,松开等。onInterceptTouchEvent 会在 onTouchEvent 前面执行,在这里需要判断是否应该拦截这个事件,然后交由我的 onTouchEvent 处理。一旦 onInterceptTouchEvent 返回 true 表示拦截,后续事件都会交给 onTouchEvent 处理,onInterceptTouchEvent 都不会再执行,下一次按下事件。不知道这样描述有没有问题,如果不清楚,你可以在两个函数里面添加 Log ,然后试一试。</p>    <h3>onInterceptTouchEvent</h3>    <p>我们需要在这个函数中判断是否应该拦截滑动事件,例如child是一个ListView,那么 <strong>它没有滑到头或者没有滑到尾</strong> 的时候,我们都不应该拦截,ACTION_DOWN和ACTION_UP和不需要拦截,当事件为 ACTION_MOVE 时,如果是向下滑动,判断第一个child是否滑倒最上面,如果是,则更新状态为 TRY_REFRESH;如果是向上滑动,则判断最后一个child是否滑动最底部,如果是,则更新状态为TRY_LOADMORE。然后返回 intercept = true。这样接下来的滑动事件就会传给本类的 onTouchEvent 处理。</p>    <pre>  @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;    }</pre>    <p>至于怎么判断是否应该拦截,这里不同的ViewGroup判断方法不一样,主要分为 ScrollView,ListView,RecyclerView,这里的内容要繁琐一点,可以直接跳过。</p>    <pre>  /*汇总判断 刷新和加载是否拦截*/    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是否应该拦截*/</pre>    <h3>onTouchEvent</h3>    <p>这里面就是处理拦截后的touch事件,我们主要根据滑动的位置来做状态的修改,和属性动画的控制。</p>    <p>下面的代码我们先没有加动画,先理清楚思路。</p>    <pre>  @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);    }</pre>    <p>第一个: <strong>mlastMoveY</strong> ,这里采取的是 <strong>scrollBy</strong> 相对滑动的方式,每向下移动一点,就会触发 onTouchEvent,用当前event的y 减去 上一次记录的y,就是我刚刚滑动的一点点距离,然后使用 scrollBy 将整个view 向下滑动一点点,如果动作连贯就形成了滑动的效果。</p>    <p>第二个:ACTION_MOVE 时的状态变化,注意这里的两个距离: <strong>getScrollY()</strong> 获得的是整体,在我松开之前,整体的View在Y轴上滑动的距离,为负值表示整体往下滑动。 <strong>dy = mLastY - y</strong> ,表示刚刚 <strong>scrollBy</strong> 滑动的一小段距离是向上还是向下,如果为负,表示向下滑动一点点。</p>    <p>这里情况稍微复杂一点,这里举下拉的例子,记住我们实在 <strong>onIntercetpTouchEvent</strong> 中做得事件拦截,并且如果是下拉就将 mStatus = Status.TRY_REFRESH。拦截之后知道你松开手指,所有事件都直接传递个 onTouchEvent ,而不会再经过地方。</p>    <p>滑动的距离分为下面几种情况,假设有效距离20:</p>    <ol>     <li>如果我们一直下拉,拉到20松开就可以更新,这是最好的情况。</li>     <li>如果一直下拉,拉了20。然后又慢慢向上移动滑上去到10松开,不应该更新。但是整体效果也是向下拉的,不会有问题。</li>     <li>如果一直下拉,拉了10,这时反向向上滑动,返回到原来位置,甚至负数,那么这个时候layout整体向上移动,导致下面的加载更多出现,这种情况是不对的。应该是在返回到原来位置时,将拦截设置为false,交给child去处理,但是我们刚刚说了,直到松开手指,onInterceptTouchEvent 都不会被调用。所以这里做了这种判断,如果前面记录了是想下拉,但是又反向超过了原来位置,则使反向拉特别费力 <strong>dy / 100</strong> ,让下半部无法出现,迫使用户松开手指。这种处理不是太好,但是我也没有想到更好的方法。</li>    </ol>    <p>其他的情况都好处理,直接滑动就好, <strong>scrollBy</strong> 的距离是 <strong>实际距离/3</strong> 是想造成简单的阻尼运动的效果。</p>    <pre>  if (getScrollY() >= 0 && dy >= 0) {    if (mStatus == Status.TRY_REFRESH) {      scrollBy(0, dy / 100);    } else {      scrollBy(0, dy / 3);    }  }  else {      scrollBy(0, dy / 3);  }</pre>    <p>然后 <strong>beforeRefreshing</strong> 和 <strong>beforeLoadMore</strong> 就是和用户交互所需要做的事情。比如滑动达到有效距离,更新文字,出现图标。然后又滑回去,又修改文字,消失图标,这里先做简单的处理,后面需要和动画相结合。</p>    <pre>  public void beforeRefreshing() {      if (getScrollY() <= -mEffectiveHeaderHeight) {        mHeaderText.setText("松开刷新");      } else {        mHeaderText.setText("下拉刷新");      }    }      public void beforeLoadMore() {      if (getScrollY() >= mEffectiveHeaderHeight) {        mFooterText.setText("松开加载更多");      } else {        mFooterText.setText("上拉加载更多");      }    }</pre>    <p>第三个:当手指抬起的时候,会相应 <strong>ACTION_UP</strong> 事件,这时我们我们需要根据是否达到有效距离,做后续的工作,这里直接看代码就可以理解。</p>    <pre>  // 下拉刷新,并且到达有效长度          if (getScrollY() <= -mEffectiveHeaderHeight) {            releaseWithStatusRefresh();            if (mRefreshListener != null) {              mRefreshListener.refreshFinished();            }          }          // 上拉加载更多,达到有效长度          else if (getScrollY() >= mEffictiveFooterHeight) {            releaseWithStatusLoadMore();            if (mRefreshListener != null) {              mRefreshListener.loadMoreFinished();            }          } else {            releaseWithStatusTryRefresh();            releaseWithStatusTryLoadMore();          }</pre>    <p>具体实现</p>    <pre>  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);    }</pre>    <p>到这里主要的逻辑已经走完了,下面我们来看看和用户的交互动画怎么添加。</p>    <h2>第三部:交互动画</h2>    <p>如果在这个过程中只使用文字,用户体验是很差的,所以我们需要用一些动画效果来提示用户应该怎么做,增强用户体验。一般下拉刷新都会有一个小图标,指示下拉的程度,然后提示用户松开, 我们这里用一个小箭头来做指示,根据用户拉下的距离计算小箭头应该旋转的角度 ,做一个小交互。</p>    <p>首先看一下 header的xml文件: <strong>pull_header.xml</strong></p>    <pre>  <?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></pre>    <h3>计算旋转角度</h3>    <p>逻辑理清楚,3个控件:1. 文字提示,2.运行进度条在刷新时显示,3.箭头图标根据滑动距离旋转角度,刷新时隐藏。</p>    <p>首先解决旋转问题, <strong>根据滑动距离计算旋转角度</strong> ,首先我们应该想到在 onTouchEvent 中的ACTION_MOVE 中解决,还记得我们前面下了一个 <strong>beforeRefreshing</strong> 函数,专门用来处理文字的改变和动画的处理,这里我们就直接在这个函数中添加交互动画:</p>    <pre>  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("下拉刷新");      }    }</pre>    <p>首先根据滑动的距离,最大是header的高度,然后计算旋转角度比例*180,就得到了旋转的角度,然后直接将ImageView的rotation设置旋转角度,就完成了,就是这么简单。在做之前我还想用属性动画来做,尝试了一下,各种问题,呵呵,只怪自己没有经验,像这种瞬时的动画,还是直接设置属性来的简单。</p>    <p>然后就是在松开手时隐藏箭头,显示进度条。</p>    <pre>  private void releaseWithStatusRefresh() {      scrollTo(0, -mEffectiveHeaderHeight);      mHeaderProgressBar.setVisibility(VISIBLE);      mHeaderText.setText("正在刷新");      // 新加      mHeaderArrow.setVisibility(GONE);        updateStatus(Status.REFRESHING);    }</pre>    <p>加载完成隐藏进度条,显示箭头。</p>    <pre>  private void refreshFinished() {      scrollTo(0, 0);      mHeaderText.setText("下拉刷新");      mHeaderProgressBar.setVisibility(GONE);      // 新加      mHeaderArrow.setVisibility(VISIBLE);        updateStatus(Status.NORMAL);    }</pre>    <p>这样整个简单的交互动画也完成了。</p>    <h2>写在最后的话</h2>    <p>到这里,3个步骤已经分析得很详细,自定义View到底应该怎么做,并且将交互动画也添加了进来,结合Github上的整个代码,希望你能理解。自定义View也是有很多的套路的,自己可以琢磨琢磨。再次感谢参考文章的作者,从他的文章中我理解很多细节上的内容。</p>    <p> </p>    <p>来自:http://blog.csdn.net/u013647382/article/details/58092102</p>    <p> </p>