最近看到Scroller的具体原理,看到书上实现下拉刷新的例子觉得很有意思,想着自己也来动手写写看,也算是理解一下Scroller,顺便也能锻炼一下自己自定义ViewGroup的技能。我会着重讲一讲实现的关键代码和实现的原理,在这次实现中我们主要锻炼了两个能力:
1、使用Scroller实现View的滑动;
2、自定义ViewGroup
1、实现原理
下拉刷新这一动作的主要实现就是利用Scroller可以把View的内容移动的能力,移动整个ViewGroup,把已经布局好的HeaderView(下拉刷新拉下来显示的部分)移动到看不见的位置,然后再监听触摸事件,当做下拉动作时,再根据下拉距离一点点地把HeaderView移动下来,这样就能够实现下拉刷新的动作。
如下图所示,蓝色区域表示屏幕可以看到的部分,这是没有利用Scroller移动之前布局的样子,是可以看到HeaderView的,也就是可以看到下拉刷新显示的部分
当初始化控件时,利用Scroller在y轴方向向上滚动HeadrView的高度的距离,这样你的HeaderView就看不见了,也就是到了最初的状态,如下图所示:
之后你再利用触摸事件监听,对下拉动作进行监听并且根据手指位移,利用Scroller在y轴向下滑动ViewGroup,这样就可以实现下拉刷新部分的显示和隐藏,从而实现下拉刷新操作,如下图所示
2、实现关键代码
首先,我们要明确一点就是我们的下拉刷新控件所包含的ContentView应该是使用的开发者关心的,我们只是要针对下拉操作去做,而不去关心到底ContentView是什么,所以ContentView肯定是一个泛型类,并且继承自View;第二个就是我们的控件既然要能够容纳HeaderView和ContentView,那么肯定是一个ViewGroup,所以控件要继承自ViewGroup,下面我们按几个过程来分析代码:
1、初始化
public abstract class RefreshLayoutBase<T extends View> extends ViewGroup
这是我们下拉刷新控件类的定义,首先它是一个抽象类,因为针对ContentView的设置和isTop函数(稍后会说到)都是使用的开发者具体去实现的,所以肯定是抽象函数,那么下拉刷新控件类一定是一个抽象类。
//Scroller
private Scroller mScroller;
//下拉显示的头部视图
private View mHeaderView;
//初始上滑距离
private int mInitScrollY;
//内容视图,用户自行设置
protected T mContentView;
//上次触摸事件Y坐标
private int mLastY;
//下拉操作的每次滑动偏移量
private int mYOffset;
//提示的文本
private TextView tipTextView;
//箭头ImageView
private ImageView arrowImageView;
//等待ImageView(有动画)
private ImageView waitImageView;
//刷新成功之后显示的ImageView
private ImageView successImageView;
//刷新失败之后显示的ImageView
private ImageView failureImageView;
/**
* 刷新回调Listener和set函数
*/
private OnRefreshListener mRefreshListener;
public void setOnRefreshListener(OnRefreshListener listener){
this.mRefreshListener = listener;
}
/**
* 刷新状态枚举:刷新中、初始状态、下拉刷新(已拉动)、释放刷新(已拉动)
*/
private enum RefreshState {
REFRESHING_STATE,
IDLE_STATE,
PULL_TO_REFRESH,
RELEASE_TO_REFRESH
}
//刷新状态,初始为初始状态
private RefreshState mState = RefreshState.IDLE_STATE;
public RefreshLayoutBase(Context context){
this(context, null);
}
public RefreshLayoutBase(Context context, AttributeSet attrs){
this(context, attrs, 0);
}
public RefreshLayoutBase(Context context, AttributeSet attrs, int defStyle){
super(context, attrs);
mScroller = new Scroller(context);
//设置内容视图
setContentView(context);
//设置头部视图
setHeaderView(context);
//添加用户设置的内容视图
addView(mContentView);
}
属性和控件我都做了注释,一目了然,我们来看一看构造函数,首先创建了滑动控制器Scroller,之后调用了setContentView(context)
,还记得我说过ContentView是使用的开发者自行去设置的吗,就是说ConentView是可变的,它可能是一个TextView,可能是一个RecyclerView等等,是未知的,留给子类去具体化,所以它是一个抽象函数:
//设置内容视图,留给子类实现
protected abstract void setContentView(Context context);
而HeaderView是固定的,通过setHeaderView(context)
函数来进行设置,我们看一看它的实现:
/**
* 初始化头部视图
* @param context context
*/
protected void setHeaderView(Context context){
mHeaderView = LayoutInflater.from(context).inflate(R.layout.pull_to_refresh_header, this, false);
addView(mHeaderView);
//find the widget of headerView
tipTextView = (TextView) mHeaderView.findViewById(R.id.pull_to_refresh_text);
arrowImageView = (ImageView) mHeaderView.findViewById(R.id.refresh_arrow_image);
waitImageView = (ImageView) mHeaderView.findViewById(R.id.wait_circuit_image);
successImageView = (ImageView) mHeaderView.findViewById(R.id.refresh_success_image);
failureImageView = (ImageView) mHeaderView.findViewById(R.id.refresh_failure_image);
}
其实就是根据设定好的布局进行HeaderView的inflate,并且调用addView
把HeaderView添加到布局中,再对一些需要用到的控件进行初始化,就完成了HeaderView的初始化工作。
2、测量和布局
在完成了初始化工作之后,就要进行一个关键步骤,就是测量和布局,这是自定义ViewGroup必备的,实际上就是实现onMeasure和onLayout两个函数,实现代码如下:
/**
* 测量工作,宽度为用户设置的宽度,高度为HeaderView和ContentView高度之和
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int width = MeasureSpec.getSize(widthMeasureSpec);
Log.d("test", "Width :" + width);
int childCount = getChildCount();
int finalHeight = 0;
for (int i = 0; i < childCount; i++){
View child = getChildAt(i);
measureChild(child, widthMeasureSpec, heightMeasureSpec);
finalHeight += child.getMeasuredHeight();
}
setMeasuredDimension(width, finalHeight);
}
/**
* 布局工作,从上到下布局
*/
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int left = getPaddingLeft();
int top = getPaddingTop();
int childCount = getChildCount();
Log.d("test", "ChildCount is:" + childCount);
for (int i = 0; i < childCount; i++){
View child = getChildAt(i);
child.layout(left, top, child.getMeasuredWidth(), child.getMeasuredHeight() + top);
top += child.getMeasuredHeight();
Log.d("test", "Child" + i + "height: " + child.getMeasuredHeight());
}
mInitScrollY = mHeaderView.getMeasuredHeight() + getPaddingTop();
scrollTo(0, mInitScrollY);
}
在测量onMeasure中,我们将宽度设置为用户设置的宽度,高度则为所有子视图的高度之和(实际上就是HeaderView和ContentView的高度)。
在布局onLayout中,我们从上到下布局子视图,也就是我在原理中讲的那样布局,之后做了一个关键操作:
mInitScrollY = mHeaderView.getMeasuredHeight() + getPaddingTop();
scrollTo(0, mInitScrollY);
这就是我们在原理中说的隐藏HeaderView的操作,首先计算HeaderView的高度,然后利用scrollTo
在y轴上上滑一段和HeaderView高度一样的(mInitScrollY)的距离,这样就起到了隐藏HeaderView的效果,也就是进入了初始状态,这样我们就完成了测量和布局,我们的下拉刷新控件也正式进入了初始状态(IDLE_STATE)。
3、下拉动作监听和刷新处理
如原理第三张图所示,当用户下拉时,我们要根据用户下拉的距离来滑动控件,以达到HeaderView慢慢显示的效果,并且在此效果的基础上还要实现文本提示改变等等控件的改变,下面来看代码:
/**
* 在下拉操作,并且ContentView位于顶端时拦截触摸事件
*/
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
final int action = ev.getAction();
if(action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP){
return false;
}
switch (action){
case MotionEvent.ACTION_DOWN:
mLastY = (int) ev.getRawY();
break;
case MotionEvent.ACTION_MOVE:
if(isTop() && ev.getRawY() - mLastY > 0){
return true;
}
break;
}
//其余情况都不会拦截
return false;
}
熟悉自定义View的应该对这个函数都不陌生,onInterceptTouchEvent
就是ViewGroup是否进行触摸事件拦截的函数,如果返回true表示ViewGroup会对此触摸事件进行拦截,那么ViewGroup就会对触摸事件进行处理,不会传递到其子视图上进行处理。那么何时应该由我们的下拉刷新控件进行处理呢?应该满足两个情况:
1、下拉动作(即触摸事件是下拉,Y轴方向位移大于0);
2、ContentView位于顶部,这是因为如果你的ContentView是一个本身可以滑动的控件,这也是下拉刷新常见的控件,比如ListView,那么只有你的ListView位于最上端的时候你才能够进行下拉刷新操作。
所以我们在DOWN事件发生时记录了坐标,在MOVE事件时判断Y轴位移的大小并且调用isTop
函数来判断ContentView是否位于顶端:isTop() && ev.getRawY() - mLastY > 0
,而isTop
由于是针对未知的ContentView的判断,自然也留给子类去进行实现:
/**
* 判断ContentView是否位于顶部,留给子类实现
*/
protected abstract boolean isTop();
在判断是否进行拦截之后,就要对拦截到的符合情况的触摸事件进行处理了,而我们都知道触摸事件的处理函数是onTouchEvent
,代码如下:
/**
* 处理符合条件的触摸事件,进行刷新逻辑和控件状态改变逻辑
*/
@Override
public boolean onTouchEvent(MotionEvent event) {
if (mState == RefreshState.REFRESHING_STATE)
return true;
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
mLastY = (int) event.getRawY();
break;
case MotionEvent.ACTION_MOVE:
int currentY = (int) event.getRawY();
mYOffset = currentY - mLastY;
changeScrollY(mYOffset);
mLastY = currentY;
break;
case MotionEvent.ACTION_UP:
int curScrollY = getScrollY();
if(curScrollY < mInitScrollY / 4){
refresh();
} else {
recoverToInitState();
}
break;
/**
* 监听取消事件,防止下拉过程中锁屏之后不复原的BUG,在锁屏时恢复初始状态
*/
case MotionEvent.ACTION_CANCEL:
recoverToInitState();
break;
}
return true;
}
首先我判断是否在刷新状态,如果进入了刷新状态,那么就应该不能做任何操作(我是这么想的,不过不知道这个逻辑有没有问题),所以如果是刷新状态就直接消耗触摸事件并返回。如果不是就要根据触摸事件的类型来进行相应的处理:
ACTION_DOWN事件
如果是DOWN事件,就记录下触摸事件Y轴的坐标,作为mLastY;
ACTION_MOVE事件
如果是MOVE事件,就计算当前触摸事件的Y轴坐标与之前的mLastY所记录的值的差值,之后调用changeScrollY
进行控件在Y轴上的滑动以达到随着手指下拉操作,HeaderView逐渐显示的效果,每次滑动之后要把mLastY设置为当前值,为下一次滑动做准备,其实就是根据手指下拉过程中每两次MOVE事件之间的坐标差进行滑动,由于坐标差非常小,所以滑动起来很流畅,mLastY记录的就是上一次的坐标值。
让我们看一下具体是如何进行滑动的:
/**
* 根据每两次MOVE之间的Y轴坐标差值,在Y轴上进行控件的移动,移动的距离就是差值
* @param distance 移动距离
*/
private void changeScrollY(int distance){
int curY = getScrollY();
Log.d("test", "Height is:" + mHeaderView.getHeight());
if (distance > 0 && curY - distance > getPaddingTop()) {
// 下拉过程
scrollBy(0, -distance);
} else if (distance < 0 && curY - distance <= mInitScrollY) {
// 上滑过程
scrollBy(0, -distance);
}
int slop = mInitScrollY / 4;
if(curY > 0 && curY < slop){
mState = RefreshState.RELEASE_TO_REFRESH;
} else if (curY > 0 && curY > slop){
mState = RefreshState.PULL_TO_REFRESH;
}
changeWidgetState();
}
滑动也有两种情况,就是下拉和上滑:
1、当你进行下拉时,触摸事件坐标减去上一次坐标为正值,也就是distance大于0的情况,这种情况的临界点在哪里呢?当你下拉时,最多不能把HeaderView上面的部分拉下来,也就是下图的这种情况就不能再向下拉了:
所以需要达到的要求就是distance > 0 && curY - distance > getPaddingTop()
,这个理解了我们在看上滑的情况;
2、当你上滑是,也就是你下拉时又可以把它推回去,也就是上滑操作,和下拉相反,这时distance是小于0的,它的临界点在哪呢?当然是初始状态了,就是我们隐藏时的状态,不能再让HeaderView上去了,也就是下图这里说明的情况,你的scrollY滑动之后不能大于mInitScrollY,这种情况下就不能再上滑了:
相信看完了滑动如何实现,应该对下拉刷新这个动作一目了然了,实现滑动还不行,我们还需要对控件的状态做一些改变,比如下拉到某个位置,就要显示成释放即可刷新等,这些逻辑全和状态有关,所以当你的curY超过或者小于临界值slop事,就要对状态进行改变,即设置mState的值,然后调用changeWidgetState()
进行控件状态的改变,让我们来看看代码,具体逻辑都很简单,就用详细说明了:
/**
* 根据当前状态设置HeaderView的子控件
*/
private void changeWidgetState(){
switch (mState){
case PULL_TO_REFRESH:
tipTextView.setText("下拉刷新");
arrowImageView.setRotation(0);
break;
case RELEASE_TO_REFRESH:
tipTextView.setText("释放立即刷新");
arrowImageView.setRotation(180);
break;
case REFRESHING_STATE:
arrowImageView.setVisibility(INVISIBLE);
waitImageView.setVisibility(VISIBLE);
startWaitAnimation();
tipTextView.setText("正在刷新...");
break;
}
}
ACTION_UP事件
让我们回到onTouchEvent
函数,说完了DOWN和MOVE事件,下面就要说一说UP事件了,当用户松开手指时,如果HeaderView显示超过3/4,就要进行刷新操作,如果没有超过,那么就取消了操作,那么控件就要回到初始状态。刷新操作会调用refresh函数:
/**
* 刷新操作
*/
private void refresh(){
mState = RefreshState.REFRESHING_STATE;
//刷新滑动到固定位置
mScroller.startScroll(getScrollX(), getScrollY(),
0, mInitScrollY / 2 - getScrollY());
invalidate();
changeWidgetState();
if(mRefreshListener != null && mState == RefreshState.REFRESHING_STATE){
mRefreshListener.onRefresh();
}
}
ACTION_CANCEL事件
这里我们多监听了一个MotionEvent.ACTION_CANCEL是为什么呢?是因为当用户下拉时,还未释放的情况下如果关闭了屏幕,这种情况如果不考虑,再打开屏幕的时候就会出现下滑到一半的BUG,所以监听此种情况并进行恢复初始状态的操作,以避免这种BUG:
/**
* 监听取消事件,防止下拉过程中锁屏之后不复原的BUG,在锁屏时恢复初始状态
*/
case MotionEvent.ACTION_CANCEL:
recoverToInitState();
break;
4、刷新完成处理
刷新完成之后,开发者需要调用completeRefresh
或者failRefresh
来告知控件刷新完成或者刷新失败,以告知使用者刷新的结果,并且将控件回归到初始状态:(我这里只贴出完成刷新的代码)
public void completeRefresh(){
tipTextView.setText("刷新成功");
waitImageView.setVisibility(INVISIBLE);
successImageView.setVisibility(VISIBLE);
/**
* A problem here to be solved!
* 当调用设置为VISIBLE的时候,其自动Scroll到了最上面的位置???理由不清楚
*/
mScroller.startScroll(getScrollX(), getScrollY(),
0, mInitScrollY / 2 - getScrollY());
invalidate();
mState = RefreshState.IDLE_STATE;
this.postDelayed(new Runnable() {
@Override
public void run() {
recoverToInitState();
}
}, 400);
}
在完成刷新这里遇到一个BUG,就是如果在这里调用setVisibility(VISIBLE)
函数,将我成功的图标显示出来,Scroller就会自动滚动到最上方的初始状态,并且不报任何错误,我也不知道为什么…只能再滑动下来解决这个问题,这个问题还要慢慢研究以判断到底是为什么,这个解决方式太丑陋了一点。
完成刷新之后利用postDelayed
延迟0.4秒左右再复原到初始状态,以让用户看到刷新的结果,recoverToInitState
在之前也使用过,就是当用户手指位置未到临界点就松开时复原,代码如下:
private void recoverToInitState(){
Log.d("test", "Scroll is:" + getScrollY() + "");
mScroller.startScroll(getScrollX(), getScrollY(),
0, mInitScrollY - getScrollY());
this.invalidate();
//successImageView.setVisibility(INVISIBLE);
this.postDelayed(new Runnable() {
@Override
public void run() {
arrowImageView.setVisibility(VISIBLE);
waitImageView.setVisibility(INVISIBLE);
successImageView.setVisibility(INVISIBLE);
failureImageView.setVisibility(INVISIBLE);
}
}, 100);
}
也是利用了一个弹性滑动,滑动到初始位置,并且对HeaderView的子控件做了一下复原操作。
3、使用
使用其实你懂了原理之后应该觉得很容易,其实就是继承RefreshLayoutBase之后实现需要子类覆写的函数即可,我们就以一个RefreshTextView为例,简单使用一下,实现代码如下:
public class MyRefreshTextView extends RefreshLayoutBase<TextView>{
public MyRefreshTextView(Context context){
super(context);
}
public MyRefreshTextView(Context context, AttributeSet attrs){
super(context, attrs);
}
@Override
protected void setContentView(Context context) {
mContentView = new TextView(context);
((TextView) mContentView).setText("TextView");
mContentView.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
}
@Override
protected boolean isTop() {
return true;
}
}
其实就是覆写两个函数而已,一个是setContentView
,用于设置你的具体内容视图;一个是isTop
,用于判断是否位于顶部,使用时只需要创建并添加RefreshTextView即可:
LinearLayout linearLayout = (LinearLayout)findViewById(R.id.id_layout_main);
refreshTextView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
refreshTextView.setOnRefreshListener(new OnRefreshListener() {
@Override
public void onRefresh() {
refreshTextView.postDelayed(new Runnable() {
@Override
public void run() {
refreshTextView.completeRefresh();
}
}, 1500);
}
});
linearLayout.addView(refreshTextView);
效果截图:
4、总结
通过自己实现下拉刷新控件,我们很好的锻炼了自定义ViewGroup和使用Scroller实现弹性滑动的能力,相似的我们还可以利用此技术实现上拉加载等多种丰富的控件。
源代码已上传Github:https://github.com/FrankLee96/MyPullRefresh/tree/master
如果觉得我的文章里有任何错误,欢迎评论指正!如果觉得写得好也欢迎大家留言或者点赞,一起进步、一起学习!