下拉刷新、上拉加载实战:带你理解自定义View整个过程
下拉刷新、上拉加载实战:带你理解自定义View整个过程
@(Android)
- 下拉刷新上拉加载实战带你理解自定义View整个过程
- 参考文章
- 写在前面的话
- 效果图
- Github地址
- 正式开始
- 第一步添加Header和Footer并隐藏
- onFinishInflate
- onMeasure
- onLayout
- 第二步处理滑动事件
- onInterceptTouchEvent
- onTouchEvent
- 第三部交互动画
- 计算旋转角度
- 第一步添加Header和Footer并隐藏
- 写在最后的话
参考文章
自个儿写Android的下拉刷新/上拉加载控件
写在前面的话
这篇文章主要是对以前学习的自定义View的一个小总结,拿这个例子来做再合适不过了。简单介绍一下,主要内容是参照 自个儿写Android的下拉刷新/上拉加载控件 这篇文章里面的内容(不是自定义ListView,而是ViewGroup,更有难度),但是我还是略有改动,感谢作者无私分享。前面也看了一些关于自定义View,事件分发,滑动冲突等内容,特别是郭神的书,让我受益匪浅。我的目的就是想带大家从实际的例子,来认识自定义View中几个关键的步骤,以及怎样与动画相结合,希望对一些童鞋能有所帮助。
效果图
Github地址
建议直接下载整个例子代码,然后跟着下面的步骤来理解
https://github.com/yixiaoming/PullRefreshLayout
正式开始
如果自定义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是否显示。
.yxm.pullrefreshlayout.PullRefreshLayout android:id="@+id/main_pullrefresh_layout" android:layout_width="match_parent" android:layout_height="match_parent"> "@+id/main_listview" android:layout_width="match_parent" android:layout_height="match_parent"> .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隐藏在下面。
第二步:处理滑动事件
处理滑动事件,我们需要注意两个函数:onTouchEvent 和 onInterceptTouchEvent,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:
- 如果我们一直下拉,拉到20松开就可以更新,这是最好的情况。
- 如果一直下拉,拉了20。然后又慢慢向上移动滑上去到10松开,不应该更新。但是整体效果也是向下拉的,不会有问题。
- 如果一直下拉,拉了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);}
然后 beforeRefreshing 和 beforeLoadMore就是和用户交互所需要做的事情。比如滑动达到有效距离,更新文字,出现图标。然后又滑回去,又修改文字,消失图标,这里先做简单的处理,后面需要和动画相结合。
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也是有很多的套路的,自己可以琢磨琢磨。再次感谢参考文章的作者,从他的文章中我理解很多细节上的内容。
更多相关文章
- 2014-7-23 Android(安卓)非常好用的组件或框架
- Android(安卓)彻底征服 ListView 一 (实用篇)
- 手势滑动销毁Activity
- android scrollview嵌套scrollview,gridview,listview的方法和深
- Android(安卓)NestedScrollView嵌套RecyclerView冲突
- Android(安卓)View 下拉刷新之头部效果自定义 [水]
- Android(安卓)studio 高级控件提示文本框与下拉框
- Android初级教程_用ExpandableListView实现类似QQ好友列表
- Android(安卓)RecyclerView刷新和加载