想要親手實現一個刷新控件,你只須要掌握這些知識

如今Android陣營裏面的刷新控件不少,稂莠不齊。筆者試圖從不同的角度,在它的個性化和滾動上下一些功夫。筆者指望,這個刷新控件能像Google的SwipeRefreshLayout同樣,支持大多數列表控件,有加載更多功能,最好是要很方便的支持個性化,滾動中可以越界是否是也會帶來比普通的刷新控件更好的交互體驗。開源庫在這,TwinklingRefreshLayout,若是喜歡請star,筆者的文章也是圍繞着這個控件的實現來講的。
爲了方便,筆者將TwinklingRefreshLayout直接繼承自FrameLayout而不是ViewGroup,能夠省去onMeasure、onLayout等一些麻煩,Header和Footer則是經過LayoutParams來設置View的Gravity屬性來作的。java

1. View的onAttachedToWindow()方法

首先View沒有明顯的生命週期,咱們又不能再構造函數裏面addView()給控件添加頭部和底部,所以這個操做比較合適的時機就是在onDraw()以前——onAttachedToWindow()方法中。android

此時View被添加到了窗體上,View有了一個用於顯示的Surface,將開始繪製。所以其保證了在onDraw()以前調用,但可能在調用 onDraw(Canvas) 以前的任什麼時候刻,包括調用 onMeasure(int, int) 以前或以後。
比較適合去執行一些初始化操做。(此外在屏蔽Home鍵的時候也會回調這個方法)git

  • onDetachedFromWindow()與onAttachedToWindow()方法相對應。github

  • ViewGroup先是調用本身的onAttachedToWindow()方法,再調用其每一個child的onAttachedToWindow()方法,這樣此方法就在整個view樹中遍及開了,而visibility並不會對這個方法產生影響。less

  • onAttachedToWindow方法是在Activity resume的時候被調用的,也就是act對應的window被添加的時候,且每一個view只會被調用一次,父view的調用在前,不論view的visibility狀態都會被調用,適合作些view特定的初始化操做;ide

  • onDetachedFromWindow方法是在Activity destroy的時候被調用的,也就是act對應的window被刪除的時候,且每一個view只會被調用一次,父view的調用在後,也不論view的visibility狀態都會被調用,適合作最後的清理操做;函數

就TwinklingRefreshLayout來講,Header和Footer須要及時顯示出來,View又沒有明顯的生命週期,所以在onAttachedToWindow()中進行設置能夠保證在onDraw()以前添加了刷新控件。工具

@Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();

        //添加頭部
        FrameLayout headViewLayout = new FrameLayout(getContext());
        LayoutParams layoutParams = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
        layoutParams.gravity = Gravity.TOP;
        headViewLayout.setLayoutParams(layoutParams);

        mHeadLayout = headViewLayout;
        this.addView(mHeadLayout);//addView(view,-1)添加到-1的位置

        //添加底部
        FrameLayout bottomViewLayout = new FrameLayout(getContext());
        LayoutParams layoutParams2 = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
        layoutParams2.gravity = Gravity.BOTTOM;
        bottomViewLayout.setLayoutParams(layoutParams2);

        mBottomLayout = bottomViewLayout;
        this.addView(mBottomLayout);
        //...其它步驟
    }

可是當TwinklingRefreshLayout應用在Activity或Fragment中時,可能會由於執行onResume從新觸發了onAttachedToWindow()方法而致使重複建立Header和Footer擋住原先添加的View,所以須要加上判斷:佈局

@Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        System.out.println("onAttachedToWindow綁定窗口");

        //添加頭部
        if (mHeadLayout == null) {
            FrameLayout headViewLayout = new FrameLayout(getContext());
            LayoutParams layoutParams = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
            layoutParams.gravity = Gravity.TOP;
            headViewLayout.setLayoutParams(layoutParams);

            mHeadLayout = headViewLayout;

            this.addView(mHeadLayout);//addView(view,-1)添加到-1的位置

            if (mHeadView == null) setHeaderView(new RoundDotView(getContext()));
        }
        //...
    }

2. View的事件分發機制

事件的分發過程由dispatchTouchEvent、onInterceptTouchEvent和onTouchEvent三個方法來共同完成的。因爲事件的傳遞是自頂向下的,對於ViewGroup,筆者以爲最重要的就是onInterceptTouchEvent方法了,它關係到事件是否可以繼續向下傳遞。看以下僞代碼:post

public boolean dispatchTouchEvent(MotionEvenet ev){
    boolean consume = false;
    if (onInterceptTouchEvent(ev)) {
        consume = onTouchEvent(ev);
    }else{
        consume = child.dispatchTouchEvent(ev);
    }
    return consume;
}

如代碼所示,若是ViewGroup攔截了(onInterceptTouchEvent返回true)事件,則事件會在ViewGroup的onTouchEvent方法中消費,而不會傳到子View;不然事件將交給子View去分發。

咱們須要作的就是在子View滾動到頂部或者底部時及時的攔截事件,讓ViewGroup的onTouchEvent來交接處理滑動事件。

3. 判斷子View滾動達到邊界

在何時對事件進行攔截呢?對於Header,當手指向下滑動也就是 dy>0 且子View已經滾動到頂部(不能再向上滾動)時攔截;對於bottom則是 dy<0 且子View已經滾動到底部(不能再向下滾動)時攔截:

@Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mTouchY = ev.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                float dy = ev.getY() - mTouchY;

                if (dy > 0 && !canChildScrollUp()) {
                    state = PULL_DOWN_REFRESH;
                    return true;
                } else if (dy < 0 && !canChildScrollDown() && enableLoadmore) {
                    state = PULL_UP_LOAD;
                    return true;
                }
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }

判斷View能不能繼續向上滾動,對於sdk14以上版本,v4包裏提供了方法:

public boolean canChildScrollUp() {
    return ViewCompat.canScrollVertically(mChildView, -1);
}

其它狀況,直接交給子View了,ViewGroup這裏也管不着。

4. ViewGroup 的 onTouchEvent 方法

走到這一步,子View的滾動已經交給子View本身去搞了,ViewGroup須要處理的事件只有兩個臨界狀態,也就是用戶在下拉可能想要刷新的狀態和用戶在上拉可能想要加載更多的狀態。也就是上面state記錄的狀態。接下來的事情就簡單咯,監聽一下ACTION_MOVE和ACTION_UP就行了。

首先在ACTION_DOWN時須要記錄下最原先的手指按下的位置 mTouchY,而後在一系列ACTION_MOVE過程當中,獲取當前位移(ev.getY()-mTouchY),而後經過 某種計算方式 不斷計算當前的子View應該位移的距離offsetY,調用mChildView.setTranslationY(offsetY)來不斷設置子View的位移,同時須要給HeadLayout申請佈局高度來完成頂部控件的顯示。這其中筆者使用的計算方式就是插值器(Interpolator)。

在ACTION_UP時,須要判斷子View的位移有沒有達到進入刷新或者是加載更多狀態的要求,即mChildView.getTranslationY() >= mHeadHeight - mTouchSlop,mTouchSlop是爲了防止發生抖動而存在。判斷進入了刷新狀態時,當前子View的位移在HeadHeight和maxHeadHeight之間,因此須要讓子View的位移回到HeadHeight處,不然就直接回到0處。

5. Interpolator插值器

Interpolator用於動畫中的時間插值,其做用就是把0到1的浮點值變化映射到另外一個浮點值變化。上面提到的計算方式以下:

float offsetY = decelerateInterpolator.getInterpolation(dy / mWaveHeight / 2) * dy / 2;

其中(dy / mWaveHeight / 2)是一個0~1之間的浮點值,隨着下拉高度的增長,這個值愈來愈大,經過decelerateInterpolator獲取到的插值也愈來愈大,只不過這些值的變化量是愈來愈小(decelerate效果)。dy表示的是手指移動的距離。這只是筆者爲了滑動的柔和性使用的一種計算方式,頭部位移的最大距離是mWaveHeight = dy/2,這樣看的話能夠發現 dy / mWaveHeight / 2 會從0到1變化。Interpolator繼承自TimeInterpolator接口,源碼以下:

public interface TimeInterpolator {
    /**
     * Maps a value representing the elapsed fraction of an animation to a value that represents
     * the interpolated fraction. This interpolated value is then multiplied by the change in
     * value of an animation to derive the animated value at the current elapsed animation time.
     *
     * @param input A value between 0 and 1.0 indicating our current point
     *        in the animation where 0 represents the start and 1.0 represents
     *        the end
     * @return The interpolation value. This value can be more than 1.0 for
     *         interpolators which overshoot their targets, or less than 0 for
     *         interpolators that undershoot their targets.
     */
    float getInterpolation(float input);
}

getInterpolation接收一個0.0~1.0之間的float參數,0.0表明動畫的開始,1.0表明動畫的結束。返回值則能夠超過1.0,也能夠小於0.0,好比OvershotInterpolator。因此getInterpolation()是用來實現輸入0~1返回0~1左右的函數值的一個函數。

6. 屬性動畫

上面說到了手指擡起的時候,mChildView的位移要麼回到mHeadHeight處,要麼回到0處。直接setTranslationY()難免太不友好,因此咱們這裏使用屬性動畫來作。原本是直接能夠用mChildView.animate()方法來完成屬性動畫的,由於須要兼容低版本並回調一些參數,因此這裏使用ObjectAnimator:

private void animChildView(float endValue, long duration) {
        ObjectAnimator oa = ObjectAnimator.ofFloat(mChildView, "translationY", mChildView.getTranslationY(), endValue);
        oa.setDuration(duration);
        oa.setInterpolator(new DecelerateInterpolator());//設置速率爲遞減
        oa.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                int height = (int) mChildView.getTranslationY();//得到mChildView當前y的位置
                height = Math.abs(height);

                mHeadLayout.getLayoutParams().height = height;
                mHeadLayout.requestLayout();
            }
        });
    oa.start();
}

傳統的補間動畫只可以實現移動、縮放、旋轉和淡入淡出這四種動畫操做,並且它只是改變了View的顯示效果,改變了畫布繪製出來的樣子,而不會真正去改變View的屬性。好比用補間動畫對一個按鈕進行了移動,只有在原位置點擊按鈕纔會發生響應,而屬性動畫則能夠真正的移動按鈕。屬性動畫最簡單的一種使用方式就是使用ValueAnimator:

ValueAnimator anim = ValueAnimator.ofFloat(0f, 1f);  
anim.start();

它能夠傳入多個參數,如ValueAnimator.ofFloat(0f, 5f, 3f, 10f),他會根據設置的插值器依次計算,好比想作一個心跳的效果,用ValueAnimator來控制心的當前縮放值大小就是個不錯的選擇。除此以外,還能夠調用setStartDelay()方法來設置動畫延遲播放的時間,調用setRepeatCount()和setRepeatMode()方法來設置動畫循環播放的次數以及循環播放的模式等。

若是想要實現View的位移,ValueAnimator顯然是比較麻煩的,咱們可使用ValueAnimator的子類ObjectAnimator,以下:

ObjectAnimator animator = ObjectAnimator.ofFloat(textview, "alpha", 1f, 0f, 1f);  
animator.setDuration(5000);  
animator.start();

傳入的第一個值是Object,不侷限於View,傳入的第二個參數爲Object的一個屬性,好比傳入"abc",ObjectAnimator會去Object裏面找有沒有 getAbc()setAbc(...) 這兩個方法,若是沒有,動畫就沒有效果,它內部應該是處理了相應的異常。另外還能夠用AnimatorSet來實現多個屬性動畫同時播放,也能夠在xml中寫屬性動畫。

7. 個性化Header和Footer的接口

要實現個性化的Header和Footer,最最重要的固然是把滑動過程當中係數都回調出來啦。在ACTION_MOVE的時候,在ACTION_UP的時候,還有在mChildView在執行屬性動畫的時候,並且mChildView當前所處的狀態都是很明確的,寫個接口就行了。

public interface IHeaderView {
    View getView();

    void onPullingDown(float fraction,float maxHeadHeight,float headHeight);

    void onPullReleasing(float fraction,float maxHeadHeight,float headHeight);

    void startAnim(float maxHeadHeight,float headHeight);

    void onFinish();
}

getView()方法保證在TwinklingRefreshLayout中能夠取到在外部設置的View,onPullingDown()是下拉過程當中ACTION_MOVE時的回調方法,onPullReleasing()是下拉狀態中ACTION_UP時的回調方法,startAnim()則是正在刷新時回調的方法。其中 fraction=mChildView.getTranslationY()/mHeadHeight,fraction=1 時,mChildView的位移剛好是HeadLayout的高度,fraction>1 時則超過了HeadLayout的高度,其最大高度能夠到達 mWaveHeight/mHeadHeight。這樣咱們只須要寫一個View來實現這個接口就能夠實現個性化了,該有的參數都有了!

8. 實現越界回彈

不能在手指快速滾動到頂部時對越界作出反饋,這是一個繼承及ViewGroup的刷新控件的通病。沒有繼承自具體的列表控件,它沒辦法獲取到列表控件的Scroller,不能獲取到列表控件的當前滾動速度,更是不能預知列表控件何時能滾動到頂部;同時ViewGroup除了達到臨界狀態的事件被攔截了,其它事件全都交給了子View去處理。咱們能獲取到的有關於子View的操做,只有簡簡單單的手指的觸摸事件。so, let's do it!

mChildView.setOnTouchListener(new OnTouchListener() {
    @Override
    public boolean onTouch(View v, MotionEvent event) {
        return gestureDetector.onTouchEvent(event);
    }
});

咱們把在mChildView上的觸摸事件交給了一個工具類GestureDetector去處理,它能夠輔助檢測用戶的單擊、滑動、長按、雙擊、快速滑動等行爲。咱們這裏只須要重寫onFling()方法並獲取到手指在Y方向上的速度velocityY,要是再能及時的發現mChildView滾動到了頂部就能夠解決問題了。

GestureDetector gestureDetector = new GestureDetector(getContext(), new GestureDetector.SimpleOnGestureListener() {

        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            mVelocityY = velocityY;
        }
    });

此外獲取速度還能夠用VelocityTracker,比較麻煩一些:

VelocityTracker tracker = VelocityTracker.obtain();
tracker.addMovement(ev);
//而後在恰當的位置使用以下方法獲取速度
tracker.computeCurrentVelocity(1000);
mVelocityY = (int)tracker.getYVelocity();

繼續來實現越界回彈。對於RecyclerView、AbsListView,它們提供有OnScrollListener能夠獲取一下滾動狀態:

if (mChildView instanceof RecyclerView) {
            ((RecyclerView) mChildView).addOnScrollListener(new RecyclerView.OnScrollListener() {
                @Override
                public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
                    if (!isRefreshing && !isLoadingmore && newState == RecyclerView.SCROLL_STATE_IDLE) {
                        if (mVelocityY >= 5000 && ScrollingUtil.isRecyclerViewToTop((RecyclerView) mChildView)) {
                            animOverScrollTop();
                        }
                        if (mVelocityY <= -5000 && ScrollingUtil.isRecyclerViewToBottom((RecyclerView) mChildView)) {
                            animOverScrollBottom();
                        }
                    }
                    super.onScrollStateChanged(recyclerView, newState);
                }
            });
        }

筆者選取了一個滾動速度的臨界值,Y方向的滾動速度大於5000時才容許越界回彈,RecyclerView的OnScrollListener可讓咱們獲取到滾動狀態的改變,滾動到頂部時滾動完成,狀態變爲SCROLL_STATE_IDLE,執行越界回彈動畫。這樣的策略也還有一些缺陷,不能獲取到mChildView滾動到頂部時的滾動速度,也就不能根據不一樣的滾動速度來實現更加友好的越界效果。如今的越界高度是固定的,還須要後面進行優化,好比採用加速度來計算,是否可行還待驗證。

9. 滾動的延時計算策略

上面的方法對於RecyclerView和AbsListView都好用,對於ScrollView、WebView就頭疼了,只能使用延時計算一段時間看有沒有到達頂部的方式來判斷的策略。延時策略的思想就是經過發送一系列的延時消息從而達到一種漸進式計算的效果,具體來講可使用Handler或View的postDelayed方法,也可使用線程的sleep方法。另外提一點,須要不斷循環計算一個數值,好比自定義View須要實現根據某個數值變化的動效,最好不要使用Thread + while 循環的方式計算,使用ValueAnimator會是更好的選擇。這裏筆者選擇了Handler的方式。

@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
    mVelocityY = velocityY;
    if (!(mChildView instanceof AbsListView || mChildView instanceof RecyclerView)) {
        //既不是AbsListView也不是RecyclerView,因爲這些沒有實現OnScrollListener接口,沒法回調狀態,只能採用延時策略
        if (Math.abs(mVelocityY) >= 5000) {
            mHandler.sendEmptyMessage(MSG_START_COMPUTE_SCROLL);
        } else {
            cur_delay_times = ALL_DELAY_TIMES;
        }
    }
    return false;
}

在滾動速度大於5000的時候發送一個從新計算的消息,Handler收到消息後,延時一段時間繼續給本身發送消息,直到時間用完或者mChildView滾動到頂部或者用戶又進行了一次Fling動做。

private Handler mHandler = new Handler() {
    @Override
    public void handleMessage(Message msg) {
        switch (msg.what) {
            case MSG_START_COMPUTE_SCROLL:
                cur_delay_times = -1; //這裏沒有break,寫做-1方便計數
            case MSG_CONTINUE_COMPUTE_SCROLL:
                cur_delay_times++;

                if (!isRefreshing && !isLoadingmore && mVelocityY >= 5000 && childScrollToTop()) {
                    animOverScrollTop();
                    cur_delay_times = ALL_DELAY_TIMES;
                }

                if (!isRefreshing && !isLoadingmore && mVelocityY <= -5000 && childScrollToBottom()) {
                    animOverScrollBottom();
                    cur_delay_times = ALL_DELAY_TIMES;
                }

                if (cur_delay_times < ALL_DELAY_TIMES)
                    mHandler.sendEmptyMessageDelayed(MSG_CONTINUE_COMPUTE_SCROLL, 10);
                break;
            case MSG_STOP_COMPUTE_SCROLL:
                cur_delay_times = ALL_DELAY_TIMES;
                break;
        }
    }
};

ALL_DELAY_TIMES是最多能夠計算的次數,當Handler接收到MSG_START_COMPUTE_SCROLL消息時,若是mChildView沒有滾動到邊界處,則會在10ms以後向本身發送一條MSG_CONTINUE_COMPUTE_SCROLL的消息,而後繼續進行判斷。而後在合適的時候越界回彈就行了。

10. 實現個性化Header

這裏筆者來演示一下,怎麼輕輕鬆鬆的作一個個性化的Header,好比新浪微博樣式的刷新Header(以下面第1圖)。

  1. 建立 SinaRefreshView 繼承自 FrameLayout 並實現 IHeaderView 接口

  2. getView()方法中返回this

  3. 在onAttachedToWindow()方法中獲取一下須要用到的佈局(筆者寫到了xml中,也能夠直接在代碼裏面寫)

@Override
protected void onAttachedToWindow() {
    super.onAttachedToWindow();

    if (rootView == null) {
        rootView = View.inflate(getContext(), R.layout.view_sinaheader, null);
        refreshArrow = (ImageView) rootView.findViewById(R.id.iv_arrow);
        refreshTextView = (TextView) rootView.findViewById(R.id.tv);
        loadingView = (ImageView) rootView.findViewById(R.id.iv_loading);
        addView(rootView);
    }
}

4.實現其它方法

@Override
public void onPullingDown(float fraction, float maxHeadHeight, float headHeight) {
    if (fraction < 1f) refreshTextView.setText(pullDownStr);
    if (fraction > 1f) refreshTextView.setText(releaseRefreshStr);
    refreshArrow.setRotation(fraction * headHeight / maxHeadHeight * 180);
}

@Override
public void onPullReleasing(float fraction, float maxHeadHeight, float headHeight) {
    if (fraction < 1f) {
        refreshTextView.setText(pullDownStr);
        refreshArrow.setRotation(fraction * headHeight / maxHeadHeight * 180);
        if (refreshArrow.getVisibility() == GONE) {
            refreshArrow.setVisibility(VISIBLE);
            loadingView.setVisibility(GONE);
        }
    }
}

@Override
public void startAnim(float maxHeadHeight, float headHeight) {
    refreshTextView.setText(refreshingStr);
    refreshArrow.setVisibility(GONE);
    loadingView.setVisibility(VISIBLE);
}

5.佈局文件

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal" android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center">
    <ImageView
        android:id="@+id/iv_arrow"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/ic_arrow"/>

    <ImageView
        android:id="@+id/iv_loading"
        android:visibility="gone"
        android:layout_width="34dp"
        android:layout_height="34dp"
        android:src="@drawable/anim_loading_view"/>

    <TextView
        android:id="@+id/tv"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginLeft="16dp"
        android:textSize="16sp"
        android:text="下拉刷新"/>
</LinearLayout>

注意fraction的使用,好比上面的代碼 refreshArrow.setRotation(fraction headHeight / maxHeadHeight 180)fraction * headHeight表示當前頭部滑動的距離,而後算出它和最大高度的比例,而後乘以180,可使得在滑動到最大距離時Arrow剛好能旋轉180度。startAnim()方法是在onRefresh以後會自動調用的方法。

要想實現如圖2所示效果,能夠具體查看筆者的開源庫TwinklingRefreshLayout

總結

至此,筆者實現這個刷新控件的全部核心思想都講完了,其中並無用到多麼高深的技術,只是須要咱們多一點耐心,多去調試,不要逃避bug,多挑戰一下本身。

相關文章
相關標籤/搜索