下拉刷新、上拉加载实战:带你理解自定义 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>