源代码地址:https://github.com/RainbleNi/PullToRefresh
先上图吧
做一个下拉刷新控件并不难,把用户体验做好却不容易,因为里面有很多用户响应的细节需要进行处理。逻辑上简单易懂,代码简洁,主体代码300行左右,在根节点不是ListView但是内容中包涵一个ListView的情况下也有优秀的体验。
项目工程介绍
PullToRefreshLayout 负责UI显示以及用户响应的类
ScrollHandler 负责管理滑动逻辑的类
DefaultHeaderView 一个默认的刷新头,UI和响应上仿手机QQ,可供大多数App直接使用
把滑动逻辑和主体UI分开便于自己理清开发的逻辑,也便于大家学习。
技术细节
PullToRefreshLayout继承自ViewGroup,自定义View最重要的两个方法就是onMeasure和onLayout。在原则上既然继承了ViewGroup就要保证实现ViewGroup的特性,比如说ViewGroup.LayoutParams
onMeasure
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
MarginLayoutParams clp = (MarginLayoutParams) mContentView.getLayoutParams();
measureChildWithMargins(mContentView, widthMeasureSpec, 0, heightMeasureSpec, 0);
int width = mContentView.getMeasuredWidth() + clp.leftMargin + clp.rightMargin + getPaddingLeft() + getPaddingRight();
int height = mContentView.getMeasuredHeight() + clp.topMargin + clp.bottomMargin + getPaddingTop() + getPaddingBottom();
setMeasuredDimension(resolveSize(width, widthMeasureSpec), resolveSize(height, heightMeasureSpec));
measureChildWithMargins(mHeaderView, MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.EXACTLY)
, 0,
MeasureSpec.makeMeasureSpec(getMeasuredHeight(), MeasureSpec.EXACTLY), 0);
MarginLayoutParams hlp = (MarginLayoutParams) mHeaderView.getLayoutParams();
...
}
其中mContentView是下拉刷新页面里的内容,mHeaderView是下拉之后展示的刷新头。
measureChildWithMargins是在ViewGroup中去measure子View的标准方法,里面考虑了ViewGroup的padding、LayoutParams和子View的margin、LayoutParams.用这个方法measure出来的子View,设置的LayoutParams会是有效的。
从onMeasure的代码中可以看出ViewGroup只参考了mContent的大小,而mHeaderView参考了ViewGroup的大小。
measure了mContentView的大小之后,加上mContentView的margin和ViewGroup的padding就是ViewGroup的期望大小。
resolveSize(int size, int measureSpec)
这个函数计算出View的最终大小,参数size是View本身的所需大小,measureSpec是Parent根据View的LayoutParams计算出的推荐大小。一般来说,如果这个View的layout是wrap_content的,size就是它的最终大小,如果是match_parent的,measureSpec中的specSize就是它的最终大小。一般来说View measure自己的时候,需要这个函数来进行衡量。
注意
需要重写下面这个函数,让子Viewandroid:layout_margin
这些参数生效,否则会在measureChildWithMargins中发生类型转换的crash。
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new MarginLayoutParams(getContext(), attrs);
}
touch响应事件
在查看其它开源代码的时候,发现拦截touch事件,有的重写onInterceptTouchEvent+onTouchEvent,有的重写dispachTouchEvent.个人觉得既然重写dispachTouchEvent一个方法能解决的问题,就不用重写两个方法了,复杂的东西容易让人犯错。
private boolean mLastDoSuper = true;
private MotionEvent mLastEvent;
private boolean mDispatchToScrollView = false;
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
int action = ev.getAction();
boolean doSuper = true;
switch (action) {
case MotionEvent.ACTION_DOWN:
mScrollHandler.downAtY((int) ev.getY());
Rect rect = new Rect();
if (mScrollView != null && mScrollView.getVisibility() == VISIBLE && mScrollView.getGlobalVisibleRect
(rect) && rect.contains((int) ev.getRawX(), (int) ev.getRawY())) {
mDispatchToScrollView = true;
} else {
mDispatchToScrollView = false;
}
break;
case MotionEvent.ACTION_MOVE:
doSuper = !mScrollHandler.moveToY((int) ev.getY());
if (mLastDoSuper) {
if (!doSuper) {
sendCancelEvent();
}
} else {
if (doSuper) {
sendDownEvent();
}
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mScrollHandler.upOrCancel();
doSuper = mLastDoSuper;
break;
}
mLastEvent = ev;
mLastDoSuper = doSuper;
if (doSuper) {
super.dispatchTouchEvent(ev);
}
return true;
}
上面三个成员变量是这个函数的精髓所在
mLastDoSuper
指的是上个MotionEvent有没有被派发下去.派发事件要有整体性,任何一个View接收事件都要有一个以ACTION_DOWN开始,以ACTION_UP或者ACTION_CANCEL结尾,否则是不符合规则的,将造成无法预期的后果。这个变量就是用来记录上个MotionEvent是否被派发,如果上个MotionEvent被派发到了mContent而这个MotionEvent被ViewGroup拦截,就要给mContent发送一个ACTION_CANCEL。反之要先给mContent发送ACTION_DOWN,才能让mContent正确处理接下来的ACTION_MOVE。
if (mLastDoSuper) {
if (!doSuper) {
sendCancelEvent();
}
} else {
if (doSuper) {
sendDownEvent();
}
}
mLastEvent
记录着上一个MotionEvent,然后在doSuper状态变化的时候被发送出去。至于为什么发送的是上一个MotionEvent,是为了让每个ACTION_MOVE都有交互上的响应。
private void sendCancelEvent() {
MotionEvent e = MotionEvent.obtain(mLastEvent.getDownTime(), mLastEvent.getEventTime(), MotionEvent
.ACTION_CANCEL, mLastEvent.getX(), mLastEvent.getY(), mLastEvent.getMetaState());
super.dispatchTouchEvent(e);
}
上面的这么多工作都是为了让事件的派发目标转移的时候依旧保持有序,为何会有派发目标的突然转移。从产品的体验上来讲,用户在一个按下-拖拽-松开的连贯动作中,是有目标转移的,就像前面的Gif动态图中,用户在ListView中进行这一串动作,在ListView没有滑到顶部时,用户更倾向于滑动ListView中的内容,在用户把ListView滑到顶部之后,用户的下拉操作更倾向于下拉刷新的目的。用户在这串操作的不同阶段有不同的目的,所以我们在实现中要根据用户不同的目的把事件派发给不同的对象。
如果在mContentView中包涵一个可滑动的View(例如ListView,GridView,ScrollView),为了精准分发MotionEvent,需要在xml中特别指定一下可滑动的View。
ptf:scroll_id="@+id/listview"
上面讲到的,如果用户作用在ListView上,优先响应ListView的滑动,才轮到下拉刷新的响应。首先我们得判断用户是否作用于ListView上,保存于mDispatchToScrollView
Rect rect = new Rect();
if (mScrollView != null && mScrollView.getVisibility() == VISIBLE && mScrollView.getGlobalVisibleRect
(rect) && rect.contains((int) ev.getRawX(), (int) ev.getRawY())) {
mDispatchToScrollView = true;
} else {
mDispatchToScrollView = false;
}
这个判断放在ACTION_DOWN中,也是从用户体验上考虑,用户按下的位置往往是用户想要作用的对象。原生ListView也是如此,在滑动原生的ListView时,即使滑到了ListView外面,响应的依旧是ListView里的滑动。mDispatchToScrollView 用于判断mContentView是否可以继续向下滚动。
@Override
public boolean canContentScrollUp() {
if (mDispatchToScrollView) {
return mScrollView.canScrollVertically(-1);
} else {
return mContentView.canScrollVertically(-1);
}
}
处理滑动的逻辑就比较简单了,用Scroller来计算松开手时滑动的位置,来做动画。
private void scrollToRefreshPosition() {
mScroller.startScroll(0, mCurrentOffsetY, 0, mRefreshingPosition - mCurrentOffsetY, mScrollAnimationDuration);
mHandler.removeCallbacks(this);
mHandler.post(this);
}
每隔30ms做一次动画,在视觉上感觉动画能很平滑
@Override
public void run() {
int newOffsetY;
if (mScroller.isFinished() || !mScroller.computeScrollOffset()) {
newOffsetY = mScroller.getFinalY();
} else {
newOffsetY = mScroller.getCurrY();
mHandler.postDelayed(this, 30);
}
mScrollHandlerCallback.onOffsetChange(newOffsetY - mCurrentOffsetY);
mCurrentOffsetY = newOffsetY;
}
用offsetTopAndBottom
来做动画,经测试和setTranslationY
效率差不多
@Override
public void onOffsetChange(int offset) {
mHeaderView.offsetTopAndBottom(offset);
mContentView.offsetTopAndBottom(offset);
}
使用方法
xml中注册
<com.pulltorefresh.rainbow.pull_to_refresh.PullToRefreshLayout
android:id="@+id/ptf_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<GridView
android:id="@+id/gridview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:numColumns="2"/>
</com.pulltorefresh.rainbow.pull_to_refresh.PullToRefreshLayout>
包涵1个View会把其识别为ContentView,并为其添上默认的HeaderView.
包涵两个View会将第一个childView识别成HeaderView,第二个识别成ContentView
同样可以通过下面的方式注册ContentView和HeaderView
<?xml version="1.0" encoding="utf-8"?>
<com.pulltorefresh.rainbow.pull_to_refresh.PullToRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:ptf="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
ptf:header_layout="@layout/header"
ptf:content_layout="@layout/content"/>
如果用户没有定义HeaderView,则会为你添加默认的仿手机QQ的下拉刷新头,效果相当不错哟
注册刷新事件
ptfLayout.setRefreshCallback(new PullToRefreshLayout.RefreshCallback() {
@Override
public void onRefresh() {
//do refresh
}
});
可选功能
监听Header状态的变化来改变HeaderView的UI
ptfLayout.setHeaderUICallback(new PullToRefreshLayout.HeaderUICallback() {
@Override
public void onStatePullToRefresh() {
headView.setText("Pull to refresh");
}
@Override
public void onStateReleaseToRefresh() {
headView.setText("Release to refresh");
}
@Override
public void onStateRefreshing() {
headView.setText("In refreshing");
}
@Override
public void onStateComplete() {
headView.setText("Refresh completed");
}
});
自动刷新(无需用户操作,带动画)
ptflayout.autoRefresh()
设置Scroller动画duration,默认为300ms
mPtfLayout.setScrollAnimationDuration(2000);
设置刷新临界线,默认临界线为HeaderView的高度,设置的ratio是和HeaderView的比例
public void setRefreshingLine(float ratio)
设置摩擦系数,就是ViewGroup的响应滑动距离与用户ACTION_MOVE的滑动距离的比值,默认为0.5
public void setCoefficientOfFriction(float coefficientOfFriction)
有问题欢迎指正,进行技术交流
微博:http://weibo.com/nirui666