下拉刷新控件技术细节

源代码地址:https://github.com/RainbleNi/PullToRefresh
先上图吧

ScreenRecord_2016-05-24-20-05-32.gif

做一个下拉刷新控件并不难,把用户体验做好却不容易,因为里面有很多用户响应的细节需要进行处理。逻辑上简单易懂,代码简洁,主体代码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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容