Android教程網
  1. 首頁
  2. Android 技術
  3. Android 手機
  4. Android 系統教程
  5. Android 游戲
 Android教程網 >> Android技術 >> 關於Android編程 >> Android 怎麼實現支持所有View的通用的下拉刷新控件

Android 怎麼實現支持所有View的通用的下拉刷新控件

編輯:關於Android編程

下拉刷新對於一個app來說是必不可少的一個功能,在早期大多數使用的是chrisbanes的PullToRefresh,或是修改自該框架的其他庫。而到現在已經有了更多的選擇,github上還是有很多體驗不錯的下拉刷新。

而下拉刷新主要有兩種實現方式:
1. 在ListView中添加header和footer,監聽ListView的滑動事件,動態設置header/footer的高度,但是這種方式只適用於ListView,RecyclerView。
2. 第二種方式則是繼承ViewGroup或其子類,監聽事件,通過scroll或Layout的方式移動child。如圖(又分兩種情況)

Layout時將header放到屏幕外面,target則填充滿屏幕。這個也是SwipeRefreshLayout的實現原理(第二種,只下拉header)
這裡寫圖片描述vcq91vfSqtPQ0tTPwsf4sfA8L3A+DQo8dGFibGU+DQo8dGhlYWQ+DQoJPHRyPg0KCTx0aD4NCgkJJm5ic3A7PC90aD4NCgk8dGg+DQoJCbzMs9BMaXN0Vmlldy9SZWN5Y2xlclZpZXc8L3RoPg0KCTx0aD4NCgkJvMyz0FZpZXdHcm91cLvyxuTX08DgPC90aD4NCgk8L3RyPg0KPC90aGVhZD4NCjx0Ym9keT4NCgk8dHI+DQoJPHRkPsrK08O3ts6nPC90ZD4NCgk8dGQ+TGlzdFZpZXcvUmVjeWNsZXI8L3RkPg0KCTx0ZD7A7cLb1qez1sv509BWaWV3us1WaWV3R3JvdXA8L3RkPg0KCTwvdHI+DQoJPHRyPg0KCTx0ZD6809TYuPy24DwvdGQ+DQoJPHRkPsq1z9a88rWlo6zM5dHpusM8L3RkPg0KCTx0ZD6/ydLUyrXP1qOsv7TQ6MfzwcujrNf2sruz9kxpc3RWaWV3xMfW1rzT1NjQp7n7tcQszOXR6bHIvc/Su7DjPC90ZD4NCgk8L3RyPg0KCTx0cj4NCgk8dGQ+tuC147Slv9g8L3RkPg0KCTx0ZD6/ydLUzerDwNans9Y8L3RkPg0KCTx0ZD5oZWFkZXLPwsCt17TMrNbQysfN6sPA1qez1rXEo6y1q8rHu9jIpdauuvOjrLrcxNG9q7bgteO0pb/YysK8/rSrtd24+GNoaWxkPC90ZD4NCgk8L3RyPg0KCTx0cj4NCgk8dGQ+sLjA/TwvdGQ+DQoJPHRkPlFRusPT0cHQse08L3RkPg0KCTx0ZD7DwM3FoaK+qbartcg8L3RkPg0KCTwvdHI+DQo8L3Rib2R5Pg0KPC90YWJsZT4NCjxwPjxiciAvPg0Ktvi98czso6zO0rTyy+PPyL2ytdq2/tbWt73Kvcq1z9a3vcq9o6y8zLPQVmlld0dyb3Vwo6y0+sLrv8nS1NaxvdOyzr+8U3dpcGVSZWZyZXNoTGF5b3V0o6y78tXfcHVsbFRvUmVmcmVzaKOsu/LV33VsdHJhLXB1bGwtdG8tcmVmcmVzaDwvcD4NCjxoMiBpZD0="一思考和需求">一、思考和需求

下拉刷新需要幾個狀態:Reset–> Pull – > Refreshing – >Completed –>Reset

為了應對各式各樣的下拉刷新設計,我們應該提供設置自定義的Header,開發者可以通過實現接口從而自定義自己的header。

而且header可以有兩種顯示方式,一種是只下拉header,另外一種則是header和target一起下拉。

二、著手實現代碼

2.1 定義Header的接口,創建自定義Layout

/**
 * Created by AItsuki on 2016/6/13.
 * 
 */
public enum  State {
    RESET, PULL, LOADING, COMPLETE
}
/**
 * Created by AItsuki on 2016/6/13.
 *
 */
public interface RefreshHeader {

    /**
     * 松手,頭部隱藏後會回調這個方法
     */
    void reset();

    /**
     * 下拉出頭部的一瞬間調用
     */
    void pull();

    /**
     * 正在刷新的時候調用
     */
    void refreshing();

    /**
     * 頭部滾動的時候持續調用
     * @param currentPos target當前偏移高度
     * @param lastPos   target上一次的偏移高度
     * @param refreshPos 可以松手刷新的高度
     * @param isTouch   手指是否按下狀態(通過scroll自動滾動時需要判斷)
     * @param state     當前狀態
     */
    void onPositionChange(float currentPos, float lastPos, float refreshPos, boolean isTouch, State state);

    /**
     * 刷新成功的時候調用
     */
    void complete();
}
package com.aitsuki.custompulltorefresh;

import android.content.Context;
import android.graphics.Color;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.widget.ImageView;

/**
 * Created by AItsuki on 2016/6/13.
 * -
 */
public class RefreshLayout extends ViewGroup {

    private View refreshHeader;
    private View target;
    private int currentTargetOffsetTop; // target偏移距離
    private boolean hasMeasureHeader;   // 是否已經計算頭部高度
    private int touchSlop;         
    private int headerHeight;       // header高度
    private int totalDragDistance;  // 需要下拉這個距離才進入松手刷新狀態,默認和header高度一致

    public RefreshLayout(Context context) {
        this(context, null);
    }

    public RefreshLayout(Context context, AttributeSet attrs) {
        super(context, attrs);

        touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();

        // 添加默認的頭部,先簡單的用一個ImageView代替頭部
        ImageView imageView = new ImageView(context);
        imageView.setImageResource(R.drawable.one_piece);
        imageView.setBackgroundColor(Color.BLACK);
        setRefreshHeader(imageView);
    }

    /**
     * 設置自定義header
     */
    public void setRefreshHeader(View view) {
        if (view != null && view != refreshHeader) {
            removeView(refreshHeader);

            // 為header添加默認的layoutParams
            ViewGroup.LayoutParams layoutParams = view.getLayoutParams();
            if (layoutParams == null) {
                layoutParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
                view.setLayoutParams(layoutParams);
            }
            refreshHeader = view;
            addView(refreshHeader);
        }
    }



    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        if (target == null) {
            ensureTarget();
        }

        if (target == null) {
            return;
        }

        // ----- measure target -----
        // target占滿整屏
        target.measure(MeasureSpec.makeMeasureSpec(
                getMeasuredWidth() - getPaddingLeft() - getPaddingRight(),
                MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(
                getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY));

        // ----- measure refreshView-----
        measureChild(refreshHeader, widthMeasureSpec, heightMeasureSpec);
        if (!hasMeasureHeader) { // 防止header重復測量
            hasMeasureHeader = true;
            headerHeight = refreshHeader.getMeasuredHeight(); // header高度
            totalDragDistance = headerHeight;   // 需要pull這個距離才進入松手刷新狀態
        }
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        final int width = getMeasuredWidth();
        final int height = getMeasuredHeight();
        if (getChildCount() == 0) {
            return;
        }

        if (target == null) {
            ensureTarget();
        }
        if (target == null) {
            return;
        }

        // onLayout執行的時候,要讓target和header加上偏移距離(初始0),因為有可能在滾動它們的時候,child請求重新布局,從而導致target和header瞬間回到原位。

        // target鋪滿屏幕
        final View child = target;
        final int childLeft = getPaddingLeft();
        final int childTop = getPaddingTop() + currentTargetOffsetTop; 
        final int childWidth = width - getPaddingLeft() - getPaddingRight();
        final int childHeight = height - getPaddingTop() - getPaddingBottom();
        child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight);

        // header放到target的上方,水平居中
        int refreshViewWidth = refreshHeader.getMeasuredWidth();
        refreshHeader.layout((width / 2 - refreshViewWidth / 2),
                -headerHeight + currentTargetOffsetTop,
                (width / 2 + refreshViewWidth / 2),
                currentTargetOffsetTop);
    }

    /**
     * 將第一個Child作為target
     */
    private void ensureTarget() {
        // Don't bother getting the parent height if the parent hasn't been laid
        // out yet.
        if (target == null) {
            for (int i = 0; i < getChildCount(); i++) {
                View child = getChildAt(i);
                if (!child.equals(refreshHeader)) {
                    target = child;
                    break;
                }
            }
        }
    }
}

MainActivity中的布局如下,先用一個TextView作為Target


<framelayout android:layout_height="match_parent" android:layout_width="match_parent" tools:context="com.aitsuki.custompulltorefresh.MainActivity" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">

    
        
    
</framelayout>

運行後結果如圖如下,但是我們還沒有監聽事件,所以此時還無法滑動。
這裡寫圖片描述

2.2 處理事件分發

控件已經測量布局好了,現在就開始處理事件分發,對於事件分發還不了解的應該先去復習下……

對於多點觸控的處理:
記錄活動手指的id(activePointerId),通過此ID獲取move事件的坐標。

在手指按下的時候,記錄下activePointerId 第二根手指按下的時候,更新activePointerId。(我們讓第二根手指作為活動手指,忽略第一個手指的move) 當其中一根手指抬起時,如果是第一根手指,那麼不做處理,如果是第二根手指抬起,也就是活動手指抬起的話,將活動手指改回第一根。

對於事件分發一般有兩種處理方式
1. 在onIntercept + onTouchEvnet中處理
2. 在dispatchTouchEvent中處理
在這裡我選擇了第二種方式

首先了解DispatchTouchEvent返回值的含義
重寫dispatchTouchEvent的時候,無論你是return true,亦或是return false都會導致child接受不到事件。
return true : 告訴parent,這個事件我消費了。如果這個是down事件,那麼我就會作為一個target或者說handle(事件持有者),後續的move事件或者up事件等,都會直接分發到我這裡,不繼續往下分發。
return false:告訴parent,這個事件我不需要,那麼會交回給parent的onTouchEvnet處理
只有return super.dispatchTouchEvent的時候才會將事件繼續往下傳遞。

上面只說了最簡單的一點,如果對事件分發不了解的話需要看看,真的很重要。

分析
在dispatch中,即使child響應了事件,我們也能拿到所有事件。
這樣我們就可以很簡單的控制頭部是否能下拉,那麼如何攔截child的事件呢?
可以在合適的時候分發一個cancel事件給child,那麼就相當於攔截了!

雖然我們一直都響應著事件,但肯定是不能所有事件都接收的,以下情況是需要我們處理的

如果是下拉,並且child不能往上滾動 如果上劃,並且target不在頂部的時候 如果是這些時候,我們攔截child的事件(派發cancel事件)

代碼如下

@Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        if (!isEnabled() || target == null) {
            return super.dispatchTouchEvent(ev);
        }

        final int actionMasked = ev.getActionMasked(); // support Multi-touch
        switch (actionMasked) {
            case MotionEvent.ACTION_DOWN:
                Log.e(TAG, "ACTION_DOWN");
                activePointerId = ev.getPointerId(0);
                isTouch = true;  // 手指是否按下
                hasSendCancelEvent = false;
                mIsBeginDragged = false;  // 是否開始下拉
                lastTargetOffsetTop = currentTargetOffsetTop; // 上一次target的偏移高度
                currentTargetOffsetTop = target.getTop(); // 當前target偏移高度
                initDownX = lastMotionX = ev.getX(0); // 手指按下時的坐標
                initDownY = lastMotionY = ev.getY(0);
                super.dispatchTouchEvent(ev);
                return true;    // return true,否則可能接收不到move和up事件
            case MotionEvent.ACTION_MOVE:
                if (activePointerId == INVALID_POINTER) {
                    Log.e(TAG, "Got ACTION_MOVE event but don't have an active pointer id.");
                    return super.dispatchTouchEvent(ev);
                }
                lastEvent = ev; // 最後一次move事件
                float x = ev.getX(MotionEventCompat.findPointerIndex(ev,activePointerId));
                float y = ev.getY(MotionEventCompat.findPointerIndex(ev,activePointerId));
                float xDiff = x - lastMotionX;
                float yDiff = y - lastMotionY;
                float offsetY = yDiff * DRAG_RATE;
                lastMotionX = x;
                lastMotionY = y;

                if(!mIsBeginDragged && Math.abs(y - initDownY) > touchSlop) {
                    mIsBeginDragged = true;
                }

                if (mIsBeginDragged) {
                    boolean moveDown = offsetY > 0; // ↓
                    boolean canMoveDown = canChildScrollUp();
                    boolean moveUp = !moveDown;     // ↑
                    boolean canMoveUp = currentTargetOffsetTop > START_POSITION;

                    // 判斷是否攔截事件
                    if ((moveDown && !canMoveDown) || (moveUp && canMoveUp)) {
                        moveSpinner(offsetY);
                        return true;
                    }
                }
                break;

            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                isTouch = false;
                activePointerId = INVALID_POINTER;
                break;

            case MotionEvent.ACTION_POINTER_DOWN:
                int pointerIndex = MotionEventCompat.getActionIndex(ev);
                if (pointerIndex < 0) {
                    Log.e(TAG, "Got ACTION_POINTER_DOWN event but have an invalid action index.");
                    return super.dispatchTouchEvent(ev);
                }
                lastMotionX = ev.getX(pointerIndex);
                lastMotionY = ev.getY(pointerIndex);
                activePointerId = MotionEventCompat.getPointerId(ev, pointerIndex);
                break;

            case MotionEvent.ACTION_POINTER_UP:
                onSecondaryPointerUp(ev);
                lastMotionY = ev.getY(ev.findPointerIndex(activePointerId));
                lastMotionX = ev.getX(ev.findPointerIndex(activePointerId));
                break;
        }
        return super.dispatchTouchEvent(ev);
    }
private void onSecondaryPointerUp(MotionEvent ev) {
        final int pointerIndex = MotionEventCompat.getActionIndex(ev);
        final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex);
        if (pointerId == activePointerId) {
            // This was our active pointer going up. Choose a new
            // active pointer and adjust accordingly.
            final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
            lastMotionY = ev.getY(newPointerIndex);
            lastMotionX = ev.getX(newPointerIndex);
            activePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex);
        }
    }
public boolean canChildScrollUp() {
        if (android.os.Build.VERSION.SDK_INT < 14) {
            if (target instanceof AbsListView) {
                final AbsListView absListView = (AbsListView) target;
                return absListView.getChildCount() > 0
                        && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0)
                        .getTop() < absListView.getPaddingTop());
            } else {
                return ViewCompat.canScrollVertically(target, -1) || target.getScrollY() > 0;
            }
        } else {
            return ViewCompat.canScrollVertically(target, -1);
        }
    }

以上就是事件的處理,我們還需要在header下拉之前發送cancel事件給child

private void moveSpinner(float diff) {
        int offset = Math.round(diff);
        if (offset == 0) {
            return;
        }
        // 發送cancel事件給child
        if (!hasSendCancelEvent && isTouch && currentTargetOffsetTop > START_POSITION) {
            sendCancelEvent();
            hasSendCancelEvent = true;
        }

        int targetY = Math.max(0, currentTargetOffsetTop + offset); // target不能移動到小於0的位置……
        offset = targetY - currentTargetOffsetTop;
        setTargetOffsetTopAndBottom(offset);
    }
    private void setTargetOffsetTopAndBottom(int offset) {
        if (offset == 0) {
            return;
        }
        target.offsetTopAndBottom(offset);
        refreshHeader.offsetTopAndBottom(offset);
        lastTargetOffsetTop = currentTargetOffsetTop;
        currentTargetOffsetTop = target.getTop();
        invalidate();
    }
 private void sendCancelEvent() {
        if (lastEvent == null) {
            return;
        }
        MotionEvent ev = MotionEvent.obtain(lastEvent);
        ev.setAction(MotionEvent.ACTION_CANCEL);
        super.dispatchTouchEvent(ev);
    }

代碼有點多,不過沒關系,其實很多都是從SwipeRefreshLayout中復制過來的。
我們來看看代碼運行後的效果,很不錯,就是模擬器錄屏有點卡=。=
這裡寫圖片描述
換成ListView試試, 也沒有問題。
這裡寫圖片描述
多點觸控也是可以的,但是模擬器我沒法演示了。

2.3 添加自動滾動

頭雖然可以下拉了, 但是拉下來後就不會回去了啊,我們需要在手指松開讓頭部自動回到原位。
可以使用動畫,可以使用ValueAnimator計算距離移動,也可以使用Scroller計算距離移動。

但是選擇第三種是比較好的,為什麼呢。
首先如果使用動畫,在回去的過程中我們無法下拉,我們想做的是一個可以在任何時候都能上下拉的,就像ListView添加頭的哪種效果。
valueAnimator也是,不好停止。
但是scroller卻可以使用forceFinish強行停止計算。

松開手指時,我們通過scroller計算每次移動的offset,然後調用moveSpinner即可。
在手指按下的時候,需要停止scroller。

我們先寫一個內部類,封裝一下滾動功能

private class AutoScroll implements Runnable {
        private Scroller scroller;
        private int lastY;

        public AutoScroll() {
            scroller = new Scroller(getContext());
        }

        @Override
        public void run() {
            boolean finished = !scroller.computeScrollOffset() || scroller.isFinished();
            if (!finished) {
                int currY = scroller.getCurrY(); 
                int offset = currY - lastY;
                lastY = currY;
                moveSpinner(offset); // 調用此方法移動header和target
                post(this);
                onScrollFinish(false);
            } else {
                stop();
                onScrollFinish(true);
            }
        }

        public void scrollTo(int to, int duration) {
            int from = currentTargetOffsetTop;
            int distance = to - from;
            stop();
            if (distance == 0) {
                return;
            }
            scroller.startScroll(0, 0, 0, distance, duration);
            post(this);
        }

        private void stop() {
            removeCallbacks(this);
            if (!scroller.isFinished()) {
                scroller.forceFinished(true);
            }
            lastY = 0;
        }
    }

然後這個是回調,暫時用戶不上,但還是先寫好吧。

/**
     * 在scroll結束的時候會回調這個方法
     * @param isForceFinish 是否是強制結束的
     */
    private void onScrollFinish(boolean isForceFinish) {

    }

我們在構造中初始化AutoScroll,然後分別在ActionDown和ActionUp中分別調用stop和scrollto即可,如下

case MotionEvent.ACTION_DOWN:
    //...
    autoScroll.stop();
    //...
    break
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
    //...
    if(currentTargetOffsetTop > START_POSITION) {
        autoScroll.scrollTo(START_POSITION, SCROLL_TO_TOP_DURATION);
    }
    //...

運行效果如下圖
這裡寫圖片描述

2.4 添加刷新狀態

最開始的時候我們也新建了一個枚舉,設置了幾種狀態,分別是 RESET, PULL, LOADING, COMPLETE
而我們的初始狀態應該為RESET
private State state = State.RESET;

再分析一下,這幾種狀態什麼時候互相切換:
1. 在RESET狀態時,第一次下拉出現header的時候,設置狀態變成PULL
2. 在PULL或者COMPLETE狀態時,header回到頂部的時候,狀態變回RESET
3. 如果是從底部回到頂部的過程(往上滾動),並且手指是松開狀態, 並且當前是PULL狀態,狀態變成LOADING,這時候我們需要強制停止autoScroll。並且正在刷新中的偵聽器也在這裡調用(onRefresh())
4. 在LOADING狀態中,想變成其他狀態,需要提供公共方法給外部調用

首先,我們先寫一個改變狀態的方法,在狀態改變的同時要回調給header。

private void changeState(State state) {
        this.state = state;

        RefreshHeader refreshHeader = this.refreshHeader instanceof RefreshHeader ? ((RefreshHeader) this.refreshHeader) : null;
        if (refreshHeader != null) {
            switch (state) {
                case RESET:
                    refreshHeader.reset();
                    break;
                case PULL:
                    refreshHeader.pull();
                    break;
                case LOADING:
                    refreshHeader.refreshing();
                    break;
                case COMPLETE:
                    refreshHeader.complete();
                    break;
            }
        }
    }

還有,提供外部設置刷新成功的方法。
因為刷新成功後需要將header滾動回原位,所以需要做以下判斷
1. 如果已經在原位,那麼直接將狀態改成Reset
2. 如果不在原位,延時500毫秒後自動滾動回原位。這裡延時500毫秒是為了展示刷新成功的提示,否則在網速很快的情況下,刷新成功後header立即回到原位體驗性不好,感覺就像是下拉後立即就自動回去了。
3. 在自動回滾時還需要判斷當前手指是否在觸摸狀態,如果正在觸摸,代表用戶可能並不想header回去,所以這時候我們不能讓頭部滾動。
4. 再者就是,如果在延時的500內,用戶按下了手指,我們需要將這個runnable取消,在ActionDown中RemoveCallBack即可。總的來說一句話就是,用戶必須持有header的絕對控制權,在手指按下時,header不應該出現自動滾動的情況。

public void refreshComplete() {
    changeState(State.COMPLETE);
    // if refresh completed and the target at top, change state to reset.
    if (currentTargetOffsetTop == START_POSITION) {
        changeState(State.RESET);
    } else {
        // waiting for a time to show refreshView completed state.
        // at next touch event, remove this runnable
        if (!isTouch) {
            postDelayed(delayToScrollTopRunnable, SHOW_COMPLETED_TIME);
        }
    }
}

// 刷新成功,顯示500ms成功狀態再滾動回頂部,這個runnalbe需要在ActionDown事件中Remove
private Runnable delayToScrollTopRunnable = new Runnable() {
    @Override
    public void run() {
        autoScroll.scrollTo(START_POSITION, SCROLL_TO_TOP_DURATION);
    }
};

提供設置正在刷新回調的方法
當用戶松開手指,進入刷新狀態時我們需要回調這個方法。

// 定義一個偵聽器
public interface OnRefreshListener {
        void onRefresh();
    }

// 提供外部設置方法
public void setRefreshListener(OnRefreshListener refreshListener) {
        this.refreshListener = refreshListener;
    }

做完以上幾部,我們算是完成了LOADING到COMPLETE的狀態切換,余下的幾個狀態我們則需要在movespinner這個方法中控制,上面也已經分析過了邏輯,那麼可以直接看代碼了。

private void moveSpinner(float diff) {
        int offset = Math.round(diff);
        if (offset == 0) {
            return;
        }
        // 發送cancel事件給child
        if (!hasSendCancelEvent && isTouch && currentTargetOffsetTop > START_POSITION) {
            sendCancelEvent();
            hasSendCancelEvent = true;
        }

        int targetY = Math.max(0, currentTargetOffsetTop + offset); // target不能移動到小於0的位置……
        offset = targetY - currentTargetOffsetTop;

        // 1. 在RESET狀態時,第一次下拉出現header的時候,設置狀態變成PULL
        if (state == State.RESET && currentTargetOffsetTop == START_POSITION && targetY > 0) {
            changeState(State.PULL);
        }

        // 2. 在PULL或者COMPLETE狀態時,header回到頂部的時候,狀態變回RESET
        if (currentTargetOffsetTop > START_POSITION && targetY <= START_POSITION) {
            if (state == State.PULL || state == State.COMPLETE) {
                changeState(State.RESET);
            }
        }

        // 3. 如果是從底部回到頂部的過程(往上滾動),並且手指是松開狀態, 並且當前是PULL狀態,狀態變成LOADING,這時候我們需要強制停止autoScroll
        if (state == State.PULL && !isTouch && currentTargetOffsetTop > totalDragDistance && targetY <= totalDragDistance) {
            autoScroll.stop();
            changeState(State.LOADING);
            if (refreshListener != null) {
                refreshListener.onRefresh();
            }
            // 因為判斷條件targetY <= totalDragDistance,會導致不能回到正確的刷新高度(有那麼一丁點偏差),調整change
            int adjustOffset = totalDragDistance - targetY;
            offset += adjustOffset;
        }

        setTargetOffsetTopAndBottom(offset);

        // 別忘了回調header的位置改變方法。
        if(refreshHeader instanceof RefreshHeader) {
            ((RefreshHeader) refreshHeader)
                    .onPositionChange(currentTargetOffsetTop, lastTargetOffsetTop, totalDragDistance, isTouch,state);
        }
    }

而ActionUp的時候也不能單純的讓header回到頂部了,而是需要通過判斷狀態,回到刷新高度亦或是回到頂部。
1. 刷新狀態,回到刷新高度
2. 否則,回到頂部
我們將原本在ActionUp中的autoScroll.scrollto(…)抽取成一個方法再調用,如下

private void finishSpinner() {
        if (state == State.LOADING) {
            if (currentTargetOffsetTop > totalDragDistance) {
                autoScroll.scrollTo(totalDragDistance, SCROLL_TO_REFRESH_DURATION);
            }
        } else {
            autoScroll.scrollTo(START_POSITION, SCROLL_TO_TOP_DURATION);
        }
    }

好了,大功告成!在changeState方法中添加Toast打印一下狀態,來運行下!

Toast.makeText(getContext(), state.toString(), Toast.LENGTH_SHORT).show();

別忘記在Activity中調用refreshComplete方法,我們延時三秒後設置刷新成功!
以下是Activity中的調用:

final RefreshLayout refreshLayout = (RefreshLayout) findViewById(R.id.refreshLayout);
if (refreshLayout != null) {
    // 刷新狀態的回調
    refreshLayout.setRefreshListener(new RefreshLayout.OnRefreshListener() {
        @Override
        public void onRefresh() {
            // 延遲3秒後刷新成功
            refreshLayout.postDelayed(new Runnable() {
                @Override
                public void run() {
                    refreshLayout.refreshComplete();
                }
            }, 3000);
        }
    });
}

運行結果:我們演示幾種情況
下拉 – >回到頂部 (pull –> reset)

這裡寫圖片描述

下拉 –>刷新 –> 刷新成功 –> 回到頂部(pull–>loading–>complete–>reset)
這裡寫圖片描述

下拉 –>刷新 –> 刷新成功 –> 回到頂部(手指按下,不讓header回到頂部)
這裡寫圖片描述

完全沒有問題,體驗還是可以的!這樣我們就完成了一個下拉刷新控件了!

三、自定義默認的Header

下拉刷新是弄好了,但是我們的header也太寒碜太敷衍了吧!
現在我們就來自定義一個header,包含一個旋轉的箭頭,還有文字提示!但是我不准備提供時間提示了~普通點,和QQ一樣的這裡寫圖片描述

首先我們需要一些圖片資源,從QQ的apk解壓獲取到 這裡寫圖片描述
這裡寫圖片描述

先來定義幾個旋轉動畫

rotate_down.xml

rotate_up.xml

rotate_infinite.xml

header代碼如下

import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.FrameLayout;
import android.widget.TextView;

/**
 * Created by AItsuki on 2016/6/15.
 *
 */
public class QQRefreshHeader extends FrameLayout implements RefreshHeader {


    private Animation rotate_up;
    private Animation rotate_down;
    private Animation rotate_infinite;
    private TextView textView;
    private View arrowIcon;
    private View successIcon;
    private View loadingIcon;

    public QQRefreshHeader(Context context) {
        this(context, null);
    }

    public QQRefreshHeader(Context context, AttributeSet attrs) {
        super(context, attrs);

        // 初始化動畫
        rotate_up = AnimationUtils.loadAnimation(context , R.anim.rotate_up);
        rotate_down = AnimationUtils.loadAnimation(context , R.anim.rotate_down);
        rotate_infinite = AnimationUtils.loadAnimation(context , R.anim.rotate_infinite);

        inflate(context, R.layout.header_qq, this);

        textView = (TextView) findViewById(R.id.text);
        arrowIcon = findViewById(R.id.arrowIcon);
        successIcon = findViewById(R.id.successIcon);
        loadingIcon = findViewById(R.id.loadingIcon);
    }

    @Override
    public void reset() {
        textView.setText(getResources().getText(R.string.qq_header_reset));
        successIcon.setVisibility(INVISIBLE);
        arrowIcon.setVisibility(VISIBLE);
        arrowIcon.clearAnimation();
        loadingIcon.setVisibility(INVISIBLE);
        loadingIcon.clearAnimation();
    }

    @Override
    public void pull() {

    }

    @Override
    public void refreshing() {
        arrowIcon.setVisibility(INVISIBLE);
        loadingIcon.setVisibility(VISIBLE);
        textView.setText(getResources().getText(R.string.qq_header_refreshing));
        arrowIcon.clearAnimation();
        loadingIcon.startAnimation(rotate_infinite);
    }

    @Override
    public void onPositionChange(float currentPos, float lastPos, float refreshPos, boolean isTouch, State state) {
        // 往上拉
        if (currentPos < refreshPos && lastPos >= refreshPos) {
            if (isTouch && state == State.PULL) {
                textView.setText(getResources().getText(R.string.qq_header_pull));
                arrowIcon.clearAnimation();
                arrowIcon.startAnimation(rotate_down);
            }
            // 往下拉
        } else if (currentPos > refreshPos && lastPos <= refreshPos) {
            if (isTouch && state == State.PULL) {
                textView.setText(getResources().getText(R.string.qq_header_pull_over));
                arrowIcon.clearAnimation();
                arrowIcon.startAnimation(rotate_up);
            }
        }
    }

    @Override
    public void complete() {
        loadingIcon.setVisibility(INVISIBLE);
        loadingIcon.clearAnimation();
        successIcon.setVisibility(VISIBLE);
        textView.setText(getResources().getText(R.string.qq_header_completed));
    }
}

我們來看看運行結果,完美~
這裡寫圖片描述

四、自動下拉刷新

是不是覺得還少了點什麼?沒錯,就是自動刷新了!
很多時候,我們進入某個頁面,初始化是需要自動刷新數據,這時候就需要用到自動刷新了,不需要用戶手動。

分析:
1. 刷新狀態都是在moveSpinner中變更的,而autoScroll正好是調用moveSpinner實現滾動
2. 我們可以調用autoScroll方法,讓它滾動到刷新高度,然後再調用finishSpinner方法,讓控件進入Loading狀態
3. 自動刷新一般是在Activity的onCreate的這個生命周期執行,此時界面可能還沒有繪制完畢,可以通過postDelay方法延遲個幾百毫秒,保證界面顯示正常。
4. 而如果在postDelay的延遲時間中,用戶如果點擊了界面,我們應該將自動刷新功能移除。

首先我們定義公共方法:

    public void autoRefresh() {
        autoRefresh(500);
    }

    /**
     * 在onCreate中調用autoRefresh,此時View可能還沒有初始化好,需要延長一段時間執行。
     *
     * @param duration 延時執行的毫秒值
     */
    public void autoRefresh(long duration) {
        if (state != State.RESET) {
            return;
        }
        postDelayed(autoRefreshRunnable, duration);
    }

runnable

 // 自動刷新,需要等View初始化完畢才調用,否則頭部不會滾動出現
    private Runnable autoRefreshRunnable = new Runnable() {
        @Override
        public void run() {
         // 標記當前是自動刷新狀態,finishScroll調用時需要判斷
         // 在actionDown事件中重新標記為false
            isAutoRefresh = true;
            changeState(State.PULL);
            autoScroll.scrollTo(totalDragDistance, SCROLL_TO_REFRESH_DURATION);
        }
    };

當autoScroll滾動結束的時候,會回調這個方法,判斷如果是自動刷新,將狀態設置為Loading,並且調用finishSpinner方法。

/**
     * 滾動結束回調
     *
     * @param isForceFinish 是否強制停止
     */
    private void onScrollFinish(boolean isForceFinish) {
        if (isAutoRefresh && !isForceFinish) {
            isAutoRefresh = false;
            changeState(State.LOADING);
            if (refreshListener != null) {
                refreshListener.onRefresh();
            }
            finishSpinner();
        }
    }

搞定,在Activity中調用
refreshLayout.autoRefresh();

這裡寫圖片描述

五、添加滑動阻力

目前還有個問題,控件可以無限下拉(多點觸控),我們應該讓阻力隨著滑動距離的增大而逐漸增加,直到劃不動為止。

我們可以用到這個方程
這裡寫圖片描述

y是阻力,控制在0~1。
x是target偏移量超出刷新高度的百分比,控制在0~2。

代碼如下,寫在moveSpinnner中。

// y = x - (x/2)^2
float extraOS = targetY - totalDragDistance;
float slingshotDist = totalDragDistance;
float tensionSlingshotPercent = Math.max(0, Math.min(extraOS, slingshotDist * 2) / slingshotDist);
float tensionPercent = (float) (tensionSlingshotPercent  - Math.pow(tensionSlingshotPercent / 2, 2));

if(offset > 0) { // 下拉的時候才添加阻力
    offset = (int) (offset * (1f - tensionPercent));
    targetY = Math.max(0, currentTargetOffsetTop + offset);
}

這裡寫圖片描述

那麼,一個體驗還算不錯的下拉刷新控件就這麼完成了
部分代碼參考自SwipeRefreshLayout和UltraPullToRefresh
這是Demo下載地址:
https://github.com/AItsuki/CustomPullToRefresh

下一篇博文不出意外應該會實現ListView和Recycler的下拉刷新和加載更多的功能,主要特點就是,他們都可以直接使用本篇博文中實現的QQheader。

  1. 上一頁:
  2. 下一頁:
熱門文章
閱讀排行版
Copyright © Android教程網 All Rights Reserved