下拉刷新上拉加载实战:带你理解自定义View整个过程
Posted DakerYi
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了下拉刷新上拉加载实战:带你理解自定义View整个过程相关的知识,希望对你有一定的参考价值。
下拉刷新、上拉加载实战:带你理解自定义View整个过程
@(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是否显示。
<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隐藏在下面。
第二步:处理滑动事件
处理滑动事件,我们需要注意两个函数: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也是有很多的套路的,自己可以琢磨琢磨。再次感谢参考文章的作者,从他的文章中我理解很多细节上的内容。
以上是关于下拉刷新上拉加载实战:带你理解自定义View整个过程的主要内容,如果未能解决你的问题,请参考以下文章