Android 開源項目源碼解析 中 android-Ultra-Pull-To-Refresh 部分

本文爲 Android 開源項目源碼解析 中 android-Ultra-Pull-To-Refresh 部分
項目地址:android-Ultra-Pull-To-Refresh,分析的版本:508c632,Demo 地址:android-Ultra-Pull-To-Refresh Demo
分析者:Grumoon,校對者:lightSky,校對狀態:已完成

1. 功能介紹

下拉刷新,幾乎是每個 Android 應用都會需要的功能。 android-Ultra-Pull-To-Refresh (以下簡稱 UltraPTR )便是一個強大的 Andriod 下拉刷新框架。
主要特點:
(1).繼承於 ViewGroup , Content 可以包含任何 View 。
(2).簡潔完善的 Header 抽象,方便進行拓展,構建符合需求的頭部。

對比 Android-PullToRefresh 項目,UltraPTR 沒有實現 加載更多 的功能,但我認爲 下拉刷新 和 加載更多 不是同一層次的功能, 下拉刷新 有更廣泛的需求,可以適用於任何頁面。而 加載更多 的功能應該交由具體的 Content 自己去實現。這應該是和 Google 官方推出 SwipeRefreshLayout 是相同的設計思路,但對比 SwipeRefreshLayout , UltraPTR 更靈活,更容易拓展。

2. 總體設計

UltraPTR 總體設計比較簡單清晰。
首先抽象出了兩個接口,功能接口和 UI 接口。
PtrHandler 代表下拉刷新的功能接口,包含刷新功能回調方法以及判斷是否可以下拉的方法。用戶實現此接口來進行數據刷新工作。
PtrUIHandler 代表下拉刷新的 UI 接口,包含準備下拉,下拉中,下拉完成,重置以及下拉過程中的位置變化等回調方法。通常情況下, Header 需要實現此接口,來處理下拉刷新過程中頭部 UI 的變化。
整個項目圍繞核心類 PtrFrameLayout 。 PtrFrameLayout 代表了一個下拉刷新的自定義控件。
PtrFrameLayout 繼承自 ViewGroup ,有且只能有兩個子 View ,頭部 Header 和內容 Content 。通常情況下 Header 會實現 PtrUIHandler 接口, Content 可以爲任意的 View 。
和所有的自定義控件一樣, PtrFrameLayout 通過重寫 onFinishInflate , onMeasure , onLayout 來確定控件大小和位置。通過重寫 dispatchTouchEvent 來確定控件的下拉行爲。

3. 流程圖

請參照4.1.5 PtrFrameLayout 事件攔截流程圖

4. 詳細設計

4.1 核心類功能介紹

4.1.1 PtrHandler.java

下拉刷新功能接口,對下拉刷新功能的抽象,包含以下兩個方法。

?
1
public void onRefreshBegin(final PtrFrameLayout frame)

刷新回調函數,用戶在這裏寫自己的刷新功能實現,處理業務數據的刷新。

?
1
public boolean checkCanDoRefresh(final PtrFrameLayout frame, final View content, final View header)

判斷是否可以下拉刷新。 UltraPTR 的 Content 可以包含任何內容,用戶在這裏判斷決定是否可以下拉。
例如,如果 Content 是 TextView ,則可以直接返回 true ,表示可以下拉刷新。
如果 Content 是 ListView ,當第一條在頂部時返回 true ,表示可以下拉刷新。
如果 Content 是 ScrollView ,當滑動到頂部時返回 true ,表示可以刷新。

4.1.2 PtrDefaultHandler.java

抽象類,實現了 PtrHandler.java 接口,給出了checkCanDoRefresh的默認實現,給出了常見 View 是否可以下拉的判斷方法。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
@Override
public boolean checkCanDoRefresh(PtrFrameLayout frame, View content, View header) {
    return checkContentCanBePulledDown(frame, content, header);
}
public static boolean checkContentCanBePulledDown(PtrFrameLayout frame, View content, View header) {
    /**
     * 如果 Content 不是 ViewGroup,返回 true,表示可以下拉</br>
     * 例如:TextView,ImageView
     */
    if (!(content instanceof ViewGroup)) {
        return true;
    }
 
    ViewGroup viewGroup = (ViewGroup) content;
 
    /**
     * 如果 Content 沒有子 View(內容爲空)時候,返回 true,表示可以下拉
     */
    if (viewGroup.getChildCount() == 0) {
        return true;
    }
 
    /**
     * 如果 Content 是 AbsListView(ListView,GridView),當第一個 item 不可見是,返回 false,不可以下拉。
     */
    if (viewGroup instanceof AbsListView) {
        AbsListView listView = (AbsListView) viewGroup;
        if (listView.getFirstVisiblePosition() > 0) {
            return false;
        }
    }
 
    /**
     * 如果 SDK 版本爲 14 以上,可以用 canScrollVertically 判斷是否能在豎直方向上,向上滑動</br>
     * 不能向上,表示已經滑動到在頂部或者 Content 不能滑動,返回 true,可以下拉</br>
     * 可以向上,返回 false,不能下拉
     */
    if (Build.VERSION.SDK_INT >= 14) {
        return !content.canScrollVertically(-1);
    else {
        /**
         * SDK 版本小於 14,如果 Content 是 ScrollView 或者 AbsListView,通過 getScrollY 判斷滑動位置 </br>
         * 如果位置爲 0,表示在最頂部,返回 true,可以下拉
         */
        if (viewGroup instanceof ScrollView || viewGroup instanceof AbsListView) {
            return viewGroup.getScrollY() == 0;
        }
    }
 
    /**
     * 最終判斷,判斷第一個子 View 的 top 值</br>
     * 如果第一個子 View 有 margin,則當 top==子 view 的 marginTop+content 的 paddingTop 時,表示在最頂部,返回 true,可以下拉</br>
     * 如果沒有 margin,則當 top==content 的 paddinTop 時,表示在最頂部,返回 true,可以下拉
     */
    View child = viewGroup.getChildAt(0);
    ViewGroup.LayoutParams glp = child.getLayoutParams();
    int top = child.getTop();
    if (glp instanceof ViewGroup.MarginLayoutParams) {
        ViewGroup.MarginLayoutParams mlp = (ViewGroup.MarginLayoutParams) glp;
        return top == mlp.topMargin + viewGroup.getPaddingTop();
    else {
        return top == viewGroup.getPaddingTop();
    }
}

這裏特別注意一下,以上代碼中存在一些小 bug。Issue

?
1
2
3
if (viewGroup instanceof ScrollView || viewGroup instanceof AbsListView) {
    return viewGroup.getScrollY() == 0;
}

如果 Content 是 AbsListView(ListView,GridView),通過 getScrollY() 獲取的值一直是 0 ,所以這段代碼的判斷,無效。

注意:上述 bug ,在新版本 (3a34b2e) 中已經做出了修復。以下是最新版本的代碼。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static boolean canChildScrollUp(View view) {
    if (android.os.Build.VERSION.SDK_INT < 14) {
        if (view instanceof AbsListView) {
            final AbsListView absListView = (AbsListView) view;
            return absListView.getChildCount() > 0
                    && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0)
                    .getTop() < absListView.getPaddingTop());
        else {
            return view.getScrollY() > 0;
        }
    else {
        return view.canScrollVertically(-1);
    }
}
?
1
2
3
public static boolean checkContentCanBePulledDown(PtrFrameLayout frame, View content, View header) {
    return !canChildScrollUp(content);
}
?
1
2
3
4
@Override
public boolean checkCanDoRefresh(PtrFrameLayout frame, View content, View header) {
    return checkContentCanBePulledDown(frame, content, header);
}

新的判斷方式也比較簡單明瞭。
當然以上給出的是針對通用 View 的判斷方式。如果遇到特殊需求的 View ,或者自定義 View 。使用者還是要自己實現符合需求的判斷。

4.1.3 PtrUIHandler.java

下拉刷新 UI 接口,對下拉刷新 UI 變化的抽象。一般情況下, Header 會實現此接口,處理下拉過程中的頭部 UI 的變化。
包含以下 5 個方法。

?
1
public void onUIReset(PtrFrameLayout frame);

Content 重新回到頂部, Header 消失,整個下拉刷新過程完全結束以後,重置 View 。

?
1
public void onUIRefreshPrepare(PtrFrameLayout frame);

準備刷新,Header 將要出現時調用。

?
1
public void onUIRefreshBegin(PtrFrameLayout frame);

開始刷新,Header 進入刷新狀態之前調用。

?
1
public void onUIRefreshComplete(PtrFrameLayout frame);

刷新結束,Header 開始向上移動之前調用。

?
1
public void onUIPositionChange(PtrFrameLayout frame, boolean isUnderTouch, byte status, int oldPosition, int currentPosition, float oldPercent, float currentPercent);

下拉過程中位置變化回調。

4.1.4 PtrUIHandlerHolder.java

實現 UI 接口 PtrUIHandler ,封裝了 PtrUIHandler ,並將其組織成鏈表的形式。之所以封裝成鏈表的目的是作者希望調用者可以像 Header 一樣去實現 PtrUIHandler,能夠捕捉到 onUIReset,onUIRefreshPrepare,onUIRefreshBegin,onUIRefreshComplete 這幾個時機去實現自己的邏輯或者 UI 效果,而它們統一由 PtrUIHandlerHolder 來管理,你只需要 通過 addHandler 方法加入到鏈表中即可,這一點的抽象爲那些希望去做一些處理的開發者還是相當方便的。

4.1.5 PtrFrameLayout.java

UltraPTR 的核心類,自定義控件類。
作爲自定義控件, UltraPTR 有 8 個自定義屬性。
ptr_header,設置頭部 id 。
ptr_content,設置內容 id 。
ptr_resistance,阻尼係數,默認:1.7f,越大,感覺下拉時越吃力。
ptr_ratio_of_header_height_to_refresh,觸發刷新時移動的位置比例,默認,1.2f,移動達到頭部高度 1.2 倍時可觸發刷新操作。
ptr_duration_to_close,回彈延時,默認200ms,回彈到刷新高度所用時間。
ptr_duration_to_close_header,頭部回彈時間,默認1000ms。
ptr_pull_to_fresh,刷新是否保持頭部,默認值true。
ptr_keep_header_when_refresh,下拉刷新 / 釋放刷新,默認爲釋放刷新。

下面從 顯示 和 行爲 兩個方面分析此類。
(1)顯示( View 繪製)
參考技術點,公共技術點之 View 繪製流程

?
1
2
@Override
protected void onFinishInflate() {...}

UltraPTR 有且只有兩個子 View ,重寫 onFinishInflate 方法來確定 Header 和 Content 。
可以通過setHeaderView在代碼中設置 Header ,或者通過ptr_header和ptr_content兩個自定義屬性來設置。也可以直接在佈局文件中,爲 PtrFrameLayout 加入兩個子 View ,然後在 onFinishInflate 進行判斷賦值。
通常情況下, Header 會實現 PtrUIHandler 接口。
最終,將 Header 實例賦值給 mHeaderView 變量,Content 實例賦值給 mContent 變量。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    ...
    // 測量 Header
    if (mHeaderView != null) {
        measureChildWithMargins(mHeaderView, widthMeasureSpec, 0, heightMeasureSpec, 0);
        MarginLayoutParams lp = (MarginLayoutParams) mHeaderView.getLayoutParams();
        mHeaderHeight = mHeaderView.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
        mOffsetToRefresh = (int) (mHeaderHeight * mRatioOfHeaderHeightToRefresh);
        ...
    }
    // 測量 Content
    if (mContent != null) {
        measureContentView(mContent, widthMeasureSpec, heightMeasureSpec);
        ...
    }
}

重寫 onMeasure ,測量 Header 和 Content ,將 Header 的高度賦值給mHeaderHeight變量,將計算出的下拉刷新偏移量賦值給mOffsetToRefresh變量。

?
1
2
@Override
protected void onLayout(boolean flag, int i, int j, int k, int l) {...}

PtrFrameLayout 繼承 ViewGroup ,繼承 ViewGroup 必須重寫 onLayout 方法來確定子 View 的位置。
PtrFrameLayout 只有兩個子 View 。
對於 Header

?
1
final int top = paddingTop + lp.topMargin + offsetX - mHeaderHeight;

對於 Content

?
1
final int top = paddingTop + lp.topMargin + offsetX;

以上代碼可以看出,計算 Header top 值的時候,向上偏移的一個 Header 的高度 (mHeaderHeight),這樣初始情況下, Header 就會被隱藏。
代碼中有個offsetX變量(我認爲改爲offsetY好些),初始時爲 0,隨着下拉的過程,offsetX會逐漸增大,這樣 Header 和 Content 都會向下移動, Header 會顯示出來,出現下拉的位置移動效果。

(2)行爲( View 事件)
參考技術點,公共技術點之 View 事件傳遞
ViewGroup 的事件處理,通常重寫 onInterceptTouchEvent 方法或者 dispatchTouchEvent 方法,PtrFrameLayout 重寫了 dispatchTouchEvent 方法。
事件處理流程圖 如下:
UltraPTR-dispatchTouchEvent-flow-chart
以上有兩點需要分析下

  1. ACTION_UP 或者 ACTION_CANCEL 時候執行的 onRelease 方法。
    功能上,通過執行tryToPerformRefresh方法,如果向下拉動的位移已經超過了觸發下拉刷新的偏移量mOffsetToRefresh,並且當前狀態是 PTR_STATUS_PREPARE ,執行刷新功能回調。
    行爲上,如果沒有達到觸發刷新的偏移量,或者當前狀態爲 PTR_STATUS_COMPLETE ,或者刷新過程中不保持頭部位置,則執行向上的位置回覆動作。
  2. ACTION_MOVE 中判斷是否可以縱向 move 。
    ACTION_MOVE 的方向向下,如果mPtrHandler不爲空,並且mPtrHandler.checkCanDoRefresh返回值爲 true,則可以移動, Header 和 Content 向下移動,否則,事件交由父類處理。
    ACTION_MOVE 的方向向上,如果當前位置大於起始位置,則可以移動,Header 和 Content 向上移動,否則,事件交由父類處理。

4.1.6 PtrClassicDefaultHeader.java

經典下拉刷新的頭部實現
default-header
PtrClassicDefaultHeader 實現了 PtrUIHandler 接口。
經典樣式的 Header 實現,可以作爲我們實現自定義 Header 的參考,以下是具體實現。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public void onUIReset(PtrFrameLayout frame) {
    resetView();
    mShouldShowLastUpdate = true;
    tryUpdateLastUpdateTime();
}
private void resetView() {
    hideRotateView();
    mProgressBar.setVisibility(INVISIBLE);
}
private void hideRotateView() {
    mRotateView.clearAnimation();
    mRotateView.setVisibility(INVISIBLE);
}

重置 View ,隱藏忙碌進度條,隱藏箭頭 View ,更新最後刷新時間。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public void onUIRefreshPrepare(PtrFrameLayout frame) {
    mShouldShowLastUpdate = true;
    tryUpdateLastUpdateTime();
    mLastUpdateTimeUpdater.start();
 
    mProgressBar.setVisibility(INVISIBLE);
 
    mRotateView.setVisibility(VISIBLE);
    mTitleTextView.setVisibility(VISIBLE);
    if (frame.isPullToRefresh()) {
        mTitleTextView.setText(getResources().getString(R.string.cube_ptr_pull_down_to_refresh));
    else {
        mTitleTextView.setText(getResources().getString(R.string.cube_ptr_pull_down));
    }
}

準備刷新,隱藏忙碌進度條,顯示箭頭 View ,顯示文字,如果是下拉刷新,顯示“下拉刷新”,如果是釋放刷新,顯示“下拉”。

?
1
2
3
4
5
6
7
8
9
10
11
@Override
public void onUIRefreshBegin(PtrFrameLayout frame) {
    mShouldShowLastUpdate = false;
    hideRotateView();
    mProgressBar.setVisibility(VISIBLE);
    mTitleTextView.setVisibility(VISIBLE);
    mTitleTextView.setText(R.string.cube_ptr_refreshing);
 
    tryUpdateLastUpdateTime();
    mLastUpdateTimeUpdater.stop();
}

開始刷新,隱藏箭頭 View ,顯示忙碌進度條,顯示文字,顯示“加載中...”,更新最後刷新時間。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public void onUIRefreshComplete(PtrFrameLayout frame) {
 
    hideRotateView();
    mProgressBar.setVisibility(INVISIBLE);
 
    mTitleTextView.setVisibility(VISIBLE);
    mTitleTextView.setText(getResources().getString(R.string.cube_ptr_refresh_complete));
 
    // update last update time
    SharedPreferences sharedPreferences = getContext().getSharedPreferences(KEY_SharedPreferences, 0);
    if (!TextUtils.isEmpty(mLastUpdateTimeKey)) {
        mLastUpdateTime = new Date().getTime();
        sharedPreferences.edit().putLong(mLastUpdateTimeKey, mLastUpdateTime).commit();
    }
}

刷新結束,隱藏箭頭 View ,隱藏忙碌進度條,顯示文字,顯示“更新完成”,寫入最後刷新時間。

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
public void onUIPositionChange(PtrFrameLayout frame, boolean isUnderTouch, byte status, int lastPos, int currentPos, float oldPercent, float currentPercent) {
    final int mOffsetToRefresh = frame.getOffsetToRefresh();
    if (currentPos < mOffsetToRefresh && lastPos >= mOffsetToRefresh) {
        if (isUnderTouch && status == PtrFrameLayout.PTR_STATUS_PREPARE) {
            crossRotateLineFromBottomUnderTouch(frame);
            if (mRotateView != null) {
                mRotateView.clearAnimation();
                mRotateView.startAnimation(mReverseFlipAnimation);
            }
        }
    else if (currentPos > mOffsetToRefresh && lastPos <= mOffsetToRefresh) {
        if (isUnderTouch && status == PtrFrameLayout.PTR_STATUS_PREPARE) {
            crossRotateLineFromTopUnderTouch(frame);
            if (mRotateView != null) {
                mRotateView.clearAnimation();
                mRotateView.startAnimation(mFlipAnimation);
            }
        }
    }
}

下拉過程中位置變化回調。
在拖動情況下,當下拉距離從 小於刷新高度到大於刷新高度 時,箭頭 View 從向下,變成向上,同時改變文字顯示。
當下拉距離從 大於刷新高度到小於刷新高度 時,箭頭 View 從向上,變爲向下,同時改變文字顯示。

4.1.7 PtrClassicFrameLayout.java

繼承 PtrFrameLayout.java,經典下拉刷新實現類。
添加了 PtrClassicDefaultHeader 作爲頭部,用戶使用時只需要設置 Content 即可。

4.1.8 PtrUIHandlerHook.java

鉤子任務類,實現了 Runnable 接口,可以理解爲在原來的操作之間,插入了一段任務去執行。
一個鉤子任務只能執行一次,通過調用takeOver去執行。執行結束,用戶需要調用resume方法,去恢復執行原來的操作。
如果鉤子任務已經執行過了,調用takeOver將會直接恢復執行原來的操作。
可以通過 PtrFrameLayout 類的setRefreshCompleteHook(PtrUIHandlerHook hook)進行設置。當用戶調用refreshComplete()方法表示刷新結束以後,如果有 hook 存在,先執行 hook 的takeOver方法,執行結束,用戶需要主動調用 hook 的resume方法,然後纔會進行 Header 回彈到頂部的動作。

4.1.9 MaterialHeader.java

Material Design 風格的頭部實現
material-design-header

4.1.10 StoreHouseHeader.java

StoreHouse 風格的頭部實現
store-house-header

4.1.11 PtrLocalDisplay.java

顯示相關工具類,用於獲取用戶設備屏幕寬度(像素,DP),高度(像素,DP)以及屏幕密度。同時提供了 dp 和 px 的轉化方法。

4.2 類關係圖

UltraPTR 類關係圖

5. 雜談

5.1 優點

5.1.1 頭部行爲抽象解耦

刷微博,刷空間,刷朋友圈,“” 已經成爲很多人的習慣動作。
現在的移動應用,幾乎所有的用戶主動刷新操作,都是通過下拉來完成的。
所以一個精心設計的下拉刷新頭部,可以使你的應用讓人眼前一亮。
UltraPTR 對頭部行爲的抽象,可以很方便的使用戶定製自己的下拉刷新頭部,來實現各種效果。

5.1.2 Content 可包含任何 View

UltraPTR 的 Content 可以包含任意的 View 。這樣的好處,就是整個項目中的刷新操作,不管是 ListView , GridView 還是一個 LinearLayout ,都可以用 UltraPTR 來完成,簡便,統一。

5.2 期望

default-header
目前 UltraPTR 在下拉過程中, Header 和 Content 都會有位置變化。

希望能增加更加靈活的行爲,來應對諸如這樣的需求。
例如
知乎,下拉時 Header 和 Content 都沒有位置變化,只是 Header 中有效果變化。
zhihu-header
Evernote ,下拉時 Content 不動, Header 有位置變化。
evernote-header
總結起來,就是希望更加抽象 Header 和 Content 在下拉過程中的行爲變化。做到真正的 Ultra

注意:在新版本 (3a34b2e) ,可以使用

?
1
public void setPinContent(boolean pinContent)

將 Content 固定,這樣下拉只有 Header 位置移動。

5.3 關於加載更多

UltraPTR 沒有集成加載更多的功能。項目的 Issue 裏面也有人提到希望加入這個功能。
希望加入下拉加載········ #35
要是把上拉加載更多 集成進去,就無敵了 #8
作者給予了回覆,認爲下拉刷新和加載更多,不是同一個層級的功能。加載更多不應該由 UltraPTR 去實現,而應該由 Content 自己去實現。
我也覺得這樣是合適的,UltraPTR 的強大之處,就是它的 Content 可以是任何的 View 。因爲刷新的動作,可以在任何的 View 上進行,比如一個 TextView ,一個 ImageView ,一個 WebView 或者一個 LineaerLayout 佈局中。而加載更多的功能,很多時候只是用在了例如 ListView ,GridView 等上面,而大部分的 View 不會需要這個功能。所以交由 ListView 或者 GridView 自己去實現比較好些。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章