自定義一個下拉重新整理控制元件

cyixlq發表於2019-02-19

第一次嘗試寫一個下拉重新整理控制元件,一開始的目的只是想了解dispatchTouchEvent,onInterceptTouchEvent和onTouchEvent這幾個事件的分別,沒想到最後竟然寫了一個重新整理控制元件。好的,廢話不多說,先來看看效果圖:

效果圖.gif

首先,我們還是需要搞清楚我上面說的這三個事件:

  1. 先來看dispatchTouchEvent,這個事件是用來分發touch事件的,從Activity中的視窗開始進行事件分發,首先到根View,再到根View的子View依次傳遞。在我的測試中,無論是返回true還是false,都無法將touch事件分發到下一級,也就是子View。這個問題也困擾了我半天。看了別人自定義的下拉重新整理控制元件,好像都沒有重寫這個方法。因此,個人的做法還是將此事件交給父類處理。對於這個事件我覺得我個人還得多瞭解瞭解!
  2. 其次看看onInterceptTouchEvent事件,這個方法就是攔截touch事件,你可以根據touch的事件型別分別進行攔截,本例中下拉重新整理控制元件就需要利用此特性。返回false代表不攔截,返回true代表攔截。
  3. onTouchEvent事件的作用就是處理touch事件,如果此View中沒有下一級並且上一級沒有對touch事件進行攔截或者此View中對touch事件進行了攔截,touch事件最終就會在此事件中處理,如果不處理的話就返回false,就會返回到上一級處理。如果處理了則返回true,這樣的話就不會返回到上一級處理。
開始我們的自定義下拉重新整理View

我們先來理一理下拉重新整理的邏輯。首先如果使用者手指向上滑動,我們不需要進行事件的攔截,交給子View處理。如果使用者手指是向下滑動的時候就要進行處理了。首先要看看重新整理控制元件中的子View能不能繼續向上滾動,也就是說子View有沒有滾動到頂,如果到頂了,使用者繼續向下滑動的話就開始顯示頭部的重新整理檢視。至於是到頂後,拿開手指後再下拉顯示重新整理檢視還是到頂後直接繼續下拉就可以顯示重新整理檢視就看專案需要了。我是實現的前者。然後就是顯示重新整理檢視後,使用者下拉多少,頂部就有多少留白,然後提示文字始終在留白的最中間位置。下拉到一定位置提示鬆手開始重新整理。當下拉到最大距離,留白不再加大。最後鬆手,留白減少,並且提示正在重新整理,最後提示重新整理結果。

首先我定義了一些變數來記錄一些需要用到的值,變數說明都在註釋中:

// 每次觸控事件中第一次接觸螢幕的Y座標
private float downY;
// 手指在Y軸的滑動距離
private float dY;
// 在重新整理佈局中的子View
private View mTarget;
// 最大Y軸滑動距離
private float maxDY = 300;
// 頭部View
private View headerView;
// 下拉開始時顯示的文字
private String readyText = "下拉開始重新整理";
// 下拉到觸發重新整理的下拉距離之後的提示文字
private String refreshOkText = "鬆開開始重新整理";
// 正在重新整理時候提醒的文字
private String refreshingText = "正在重新整理";
// 重新整理成功的提示文字
private String refreshSuc = "重新整理成功!";
// 重新整理失敗提醒的文字
private String refreshFail = "重新整理失敗!";
// 觸發重新整理的距離
private float refreshDist = maxDY / 2;
// 滑動多少距離才算是滑動,否則有時候是點選也會誤觸發滑動
private int minDist;
// 是否觸發了重新整理
private boolean canRefresh = false;
// 正在重新整理?
private boolean isRefreshing = false;
// 控制元件狀態監聽
private RefreshStateListender listener;
// 狀態表示程式碼
private final int READY_REFRESH = 0; // 剛開始下拉時候的狀態
private final int CAN_REFRESH = 1; // 已經可以觸發重新整理的狀態
private final int ON_REFRESH = 2; // 正在重新整理的狀態
private final int ON_FINISH = 3; // 重新整理完成的狀態
複製程式碼

接著我們就要來處理一下事件的攔截,從我們上面理好的邏輯中知道,我們主要處理使用者下拉手勢。首先我們就應當知道使用者到底是在上拉還是在下拉。我的做法是,當使用者第一次觸控到螢幕的時候,我記錄下這個點的Y軸位置為初始Y軸位置,然後在使用者的滑動過程中,獲取滑動的點的Y軸位置減去初始Y軸位置。如果結果為負數,代表是上拉,如果是正數就代表下拉然後對事件進行攔截。但是,我在實現過程中發現不能通過判斷正負攔截,因為點選也是屬於touch事件的一種,但是你不能確保在使用者的點選過程中會發生一點點的滑動,這樣就會造成子View的點選事件也可能會被攔截。因此Android提供了一個值,滑動距離小於這個值會被系統認為是點選,大於這個值系統會認為這是滑動。根據ROM的不同,這個值也會不同。就像上面程式碼中,我用minDist這個變數將值儲存下來,獲取這個值的方法是:minDist = ViewConfiguration.get(context).getScaledTouchSlop()。然後我們通過判斷滑動的點的Y軸位置減去初始Y軸位置是否大於minDist來判斷上拉還是下拉。最後說一下,Android好像提供了判斷使用者是上拉還是下拉的方法,我還沒去研究,暫時先這樣處理。還一個問題,我們怎麼知道子View是否滑動到了頂部呢?我為此特意看了一下Android中SwipeRefreshLayout的原始碼,其中有一串程式碼如下:

private boolean canChildScrollUp() {
    return this.mTarget instanceof ListView ? ListViewCompat.canScrollList((ListView)this.mTarget, -1) : this.mTarget.canScrollVertically(-1);
}
複製程式碼

這串程式碼就是判斷子View是否能向上滾動,原始碼中還有一層我沒摘錄下來,我就覺得這段對我有用。 然後,我們還需要把子View儲存下來,不然this.mTarget就是空指標,程式碼如下(SwipeRefreshLayout也是類似做法):

private void ensureTarget() {
    if (this.mTarget == null) {
        final int count = this.getChildCount();
        for (int i = 0; i < count; i++) {
            View childView = getChildAt(i);
            if (!headerView.equals(childView)) {
                this.mTarget = childView;
                if (mTarget.getBackground() == null) {
                    mTarget.setBackgroundColor(Color.WHITE);
                }
            }
        }
    }
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    this.ensureTarget();
}

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    super.onLayout(changed, left, top, right, bottom);
    this.ensureTarget();
}
複製程式碼

截下來攔截下來的事件的處理了,從這一段開頭理的邏輯中知道,下拉到一定位置才能觸發重新整理,如果處於重新整理狀態再下拉什麼的就全部由子View處理:

public boolean onTouchEvent(MotionEvent event) {
    int action = event.getAction();
    if (action == MotionEvent.ACTION_MOVE) {
        dY = event.getY() - downY;
        if ((dY >= minDist && dY <= maxDY) && !isRefreshing) { // 如果沒有正在重新整理並且是下拉狀態,並且沒有超過最大下拉距離
            mTarget.setTranslationY(dY);
            if (dY > headerView.getMeasuredHeight()) { // 下拉距離超過headerView的高度,headerView在Y軸就要開始移動
                headerView.setTranslationY((dY - headerView.getMeasuredHeight()) / 2);
            }
            if (dY > refreshDist) { // 已經到了可以觸發重新整理的下拉距離
                configHeaderView(refreshOkText, CAN_REFRESH);
                canRefresh = true;
            } else { // 已經下拉但是還沒到可以觸發重新整理的距離
                configHeaderView(readyText, READY_REFRESH);
                canRefresh = false;
            }
        }
    } else if (action == MotionEvent.ACTION_UP) { // 鬆手觸發重新整理
        if (dY > maxDY) {
            dY = maxDY;
        }
        if (dY > minDist) {
            if (!canRefresh && !isRefreshing) { // 如果還不能觸發重新整理並且沒有正在重新整理,鬆手的話就回彈回去
                ObjectAnimator.ofFloat(mTarget, "translationY", dY, 0).setDuration(500).start();
                ObjectAnimator.ofFloat(headerView, "translationY",
                        (dY - headerView.getMeasuredHeight()) / 2, 0)
                        .setDuration(500).start();
            } else if (!isRefreshing){ // 如果已經能觸發重新整理並且沒有正在重新整理,鬆手的話就回彈到最大距離的一半並且提示正在重新整理
                ObjectAnimator.ofFloat(mTarget, "translationY", dY, refreshDist).setDuration(500).start();
                ObjectAnimator animator = ObjectAnimator.ofFloat(headerView, "translationY",
                        (dY - headerView.getMeasuredHeight()) / 2, (refreshDist - headerView.getMeasuredHeight()) / 2)
                        .setDuration(500);
                animator.addListener(new AnimatorListenerAdapter() {
                            @Override
                            public void onAnimationEnd(Animator animation) {
                                configHeaderView(refreshingText, ON_REFRESH);
                                isRefreshing = true;
                                canRefresh = false;
                            }
                        });
                animator.start();
            }
        }
        dY = 0;
    }
    return true;
}
複製程式碼

最後,對外提供重新整理完成的介面:

public void refreshFinish(boolean suc) {
    if (suc) {
        configHeaderView(refreshSuc, ON_FINISH, suc);
    } else {
        configHeaderView(refreshFail, ON_FINISH, suc);
    }
    // 重新整理完成,提示重新整理結果後
    ObjectAnimator animator1 = ObjectAnimator.ofFloat(mTarget, "translationY", refreshDist, 0);
    animator1.setDuration(200).setStartDelay(500);
    animator1.start();
    ObjectAnimator animator2 = ObjectAnimator.ofFloat(headerView, "translationY",
            (refreshDist - headerView.getMeasuredHeight()) / 2, 0);
    animator2.setDuration(200).setStartDelay(500);
    animator2.addListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationEnd(Animator animation) {
            isRefreshing = false;
            canRefresh = false;
        }
    });
    animator2.start();
}
複製程式碼

還有一些設定重新整理監聽的程式碼並沒有放到本文中講解,這一部分感覺很簡單,可以到原始碼中檢視更多詳細內容,註釋也都很詳細。原始碼地址:github.com/cyixlq/View…

至此,一個簡單的下拉重新整理控制元件就完成了!這些程式碼肯定還是很繁瑣,有很多有用的API我還沒熟悉,希望以後能更進一步,把程式碼寫的更精煉。

相關文章