本文爲 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) 中已經做出了修復。以下是最新版本的代碼。
1234567891011121314public
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
);
}
}
123public
static
boolean
checkContentCanBePulledDown(PtrFrameLayout frame, View content, View header) {
return
!canChildScrollUp(content);
}
1234@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 方法。
事件處理流程圖 如下:
以上有兩點需要分析下
- ACTION_UP 或者 ACTION_CANCEL 時候執行的 onRelease 方法。
功能上,通過執行tryToPerformRefresh方法,如果向下拉動的位移已經超過了觸發下拉刷新的偏移量mOffsetToRefresh,並且當前狀態是 PTR_STATUS_PREPARE ,執行刷新功能回調。
行爲上,如果沒有達到觸發刷新的偏移量,或者當前狀態爲 PTR_STATUS_COMPLETE ,或者刷新過程中不保持頭部位置,則執行向上的位置回覆動作。- ACTION_MOVE 中判斷是否可以縱向 move 。
ACTION_MOVE 的方向向下,如果mPtrHandler不爲空,並且mPtrHandler.checkCanDoRefresh返回值爲 true,則可以移動, Header 和 Content 向下移動,否則,事件交由父類處理。
ACTION_MOVE 的方向向上,如果當前位置大於起始位置,則可以移動,Header 和 Content 向上移動,否則,事件交由父類處理。
4.1.6 PtrClassicDefaultHeader.java
經典下拉刷新的頭部實現
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 風格的頭部實現
4.1.10 StoreHouseHeader.java
StoreHouse 風格的頭部實現
4.1.11 PtrLocalDisplay.java
顯示相關工具類,用於獲取用戶設備屏幕寬度(像素,DP),高度(像素,DP)以及屏幕密度。同時提供了 dp 和 px 的轉化方法。
4.2 類關係圖
5. 雜談
5.1 優點
5.1.1 頭部行爲抽象解耦
刷微博,刷空間,刷朋友圈,“刷” 已經成爲很多人的習慣動作。
現在的移動應用,幾乎所有的用戶主動刷新操作,都是通過下拉來完成的。
所以一個精心設計的下拉刷新頭部,可以使你的應用讓人眼前一亮。
UltraPTR 對頭部行爲的抽象,可以很方便的使用戶定製自己的下拉刷新頭部,來實現各種效果。
5.1.2 Content 可包含任何 View
UltraPTR 的 Content 可以包含任意的 View 。這樣的好處,就是整個項目中的刷新操作,不管是 ListView , GridView 還是一個 LinearLayout ,都可以用 UltraPTR 來完成,簡便,統一。
5.2 期望
目前 UltraPTR 在下拉過程中, Header 和 Content 都會有位置變化。
希望能增加更加靈活的行爲,來應對諸如這樣的需求。
例如
知乎,下拉時 Header 和 Content 都沒有位置變化,只是 Header 中有效果變化。
Evernote ,下拉時 Content 不動, Header 有位置變化。
總結起來,就是希望更加抽象 Header 和 Content 在下拉過程中的行爲變化。做到真正的 Ultra
注意:在新版本 (3a34b2e) ,可以使用
1public
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 自己去實現比較好些。