FlyRefresh——讓人眼前一亮的下拉刷新

幾天前在網上看到 @Zee Young 的一個下拉刷新的設計 Replace。以下圖: android

replace-zeeyoung.gif


第一眼看到這個設計就以爲眼前一亮,在Dribble上得到了 1.7k 多的 like,微博上也有大量轉發。可見確實一個很成功的設計。我準備在 Android 上來實現它。 git


通過幾天的折騰,最終實現並開源在 Github 上,項目地址: FlyRefresh,實際效果以下圖: github


flyrefresh-screenshot.gif


整體上還原了設計的70%~80%,還有一些細節須要改進。由於沒有拿到設計師的設計源文件,動畫和顏色的細節並無可以作的徹底一致。下面分享一下實現的過程。 canvas

1 分析設計效果圖

要實現這個設計,就要很是仔細的分析這個動畫的每一個細節。因爲沒有設計源文件,我最開始就一直盯着這個 GIF 圖看,而後構思一下大體的實現流程。在寫代碼的過程當中,甚至把 GIF 圖分解成一幀一幀的圖片來分析,把 GIF 圖分解的方法以下: bash


convert -coalesce animation.gif frame.png


從設計圖中,獲得大體以下的結論: ide

  1. 整體上是一個下拉刷新的效果; 函數

  2. 頁面上大概分爲兩部分:頭部和內容部分; 工具

  3. 頭部塊疊放在內容塊的下面; 佈局

  4. 內容塊能夠下拉,放手可以回彈,並觸發飛機飛出的動畫; 動畫

  5. 頭部塊隨着下拉過程當中有動畫(這個是重點,後面會詳細介紹);

2 軟件設計

軟件上我打算把它實現成一個下拉刷新的控件。一說到下拉刷新,有一大堆的開源實現,都或多或少的須要一些修改才能知足我這裏的需求,我打算本身實現一個量身定作的。 控件的佈局關係大概以下圖所示:

header-size

佈局分爲上下兩塊,上部實線框爲頭部,虛線框爲內容區域。內容區域覆蓋在頭部上面。一般狀況下,內容區域覆蓋頭部,留出頭部Normal height 的高度。內容區域能夠上滑,最多覆蓋到Shrink height高度;下滑最多能夠把頭部區域留出Expended height,下滑超過Normal height的時候,放手會自動彈回。內容區域能夠滑動的距離爲Expended_height - Shrink_height。

這是一個比較通用的佈局模式,只要重載這個佈局,基本上能夠涵蓋了全部下刷新的模式。例如Shrink_height=0的話,頭部能夠所有收起來的;若是Shrink_height==Normal height的話,就是一個有固定頭部的下拉控件;若是Expended_height > Normal height > Shrink_height,就是頭部能夠擴展收縮的下拉控件。

頭部動畫部分,這裏可能不一樣的設計,變化最大的部分。可是有一個共同點,就是頭部顯示會根據內容塊的滑動狀況來變化。在軟件上,設計出接口,不一樣的動畫,實現此接口就能夠。本文的 FlyRefresh 的動畫只是這個接口的一個具體實現。若是要實現其餘的刷新動畫,並不須要作多大的改動。

3 具體實現


根據上面的設計,畫出類圖以下:

flyrefresh-uml

3.1 PullHeaderLayout

這是一個基類,實現了佈局和滑動功能。從類圖中能夠看到,這個佈局中主要包含兩部分View:mHeaderView,mContent,另外還有mFlyView,這頭部和內容鏈接處的按鈕。佈局也比較簡單,具體實現能夠參考代碼 layoutChildren()。

滑動是這裏這個類的實現重點,這裏須要特別當心處理 Touch 事件。Touch 事件須要知足的是,若是 ContentView 能夠總體滑動,咱們的 Layout 就須要截獲 Touch 事件。否這須要把 Touch 事件傳遞給子 View,這樣纔不會影響內部子 View 的功能。

在處理Touch事件的時候,須要時刻判斷 View 所處的狀態,這裏藉助兩個輔助類 HeaderController 和 ScrollChecker。HeaderController主要是保存和判斷當前 Header 的高度和狀態。ScrollChecker 用來檢測 ContentView 是否能夠滑動。爲了讓滑動流暢,還須要當心處理Fling 狀態,這裏藉助了 Scroller 和 VelocityTracker兩個工具類。

另外值得一提的是,當滑動 Header 的高度大於 Normal height 的時候,ContentView 須要自動恢復回去。仔細觀察原設計的動畫,這個回彈過程是有相似橡皮筋同樣的彈性的。這裏利用了屬性動畫類,使用自定義的插值器實現,具體參考源代碼的 'ElasticOutInterpolator' 類(參考自:AnimationEasingFunctions)。

由於這裏這個類的功能和常見的下拉刷新的相似,這樣就有不少優秀的開源庫能夠參考,個人實現中很大程度上借鑑了優秀的開源庫:Ultra Pull To Refresh,讓我避免了不少坑。

3.2 FlyRefreshLayout

這裏 FlyRefreshLayout 直接繼承與上面的 PullHeaderLayout。由於大部分工做都在基類中完成,這個類實現很簡單。這個類主要是爲了簡化使用,默認添加了動畫頭部 MountanScenceView 和添加了刷新的接口 OnPullRefreshListener。

紙飛機的動畫就在這裏實現。紙飛機動畫包括三個部分:

  1. 隨着下拉,逆時針轉動;

  2. 放手的時候,觸發刷新,發射出去;

  3. 刷新完成,飛機飛回來,回到原來的位置。

動畫 1:實現很是簡單,由於 PullHeaderLayout 有 onMoveHeader() 的回調,只要重載這個函數,設置旋轉 view.setRotation(degree)便可;

動畫 2:仔細觀察設計,這是一個組合動畫:總體向右上角移動,同時繞 X 軸作 3D 轉動,飛機頭部慢慢趨向水平,而且慢慢縮小。這裏須要實現,由於須要符合真實的物理效果,否這可能看起來會很是生硬。注意這裏,咱們可使用 PathInterpolatorCompat 來幫助咱們生成任意貝塞爾曲線插值器。

動畫 3:這一步和動畫2相似。

在紙飛機執行動畫的同時,頭部的山脈和樹也會隨着動,這裏動效比較複雜,並且比較獨立,我這裏就寫到一個專門的類MountanScenceView 中,見 3.3 節。

3.3 MountanScenceView

最後來實現最抓人眼球的 MountanScenceView。和以前的思路同樣,咱們先來分解一下原設計的動畫:山脈按照遠近分爲三層景深,近處的山的顏色比較深,並且隨着下拉的時候也會向下移動,而且呈現視差,而且伴隨這樹的扭動,這是整個動畫的點睛之筆。

從畫面的風格來看,這是矢量圖,隨着畫面大小後者長寬變化,山脈應該可以自動適應,並充滿視圖。須要注意的是,無論畫面怎麼變化,須要保持長寬比不變。這樣的話,用若是用圖片就不能很好的知足要求了,因此決定是 Path 來手動繪製整個場景。由於場景要適應 View 的大小,因此在 onMeasure() 的時候,計算出縮放比例:

1
2
3
4
5
6
7
8
9
10
11
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {  
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    final float width = getMeasuredWidth();
    final float height = getMeasuredHeight();
    mScaleX = width / WIDTH;
    mScaleY = height / HEIGHT;
 
    updateMountainPath(mMoveFactor);
    updateTreePath(mMoveFactor, true);
}

繪製山脈比較簡單,Path 也不復雜,好比其中一個山的Path的生成以下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void updateMountainPath(float factor) {
 
  mTransMatrix.reset();
  mTransMatrix.setScale(mScaleX, mScaleY);
 
  int offset1 = (int) (10 * factor);
  mMount1.reset();
  mMount1.moveTo(0, 95 + offset1);
  mMount1.lineTo(55, 74 + offset1);
  mMount1.lineTo(146, 104 + offset1);
  mMount1.lineTo(227, 72 + offset1);
  mMount1.lineTo(WIDTH, 80 + offset1);
  mMount1.lineTo(WIDTH, HEIGHT);
  mMount1.lineTo(0, HEIGHT);
  mMount1.close();
  mMount1.transform(mTransMatrix);
  ...
}


其實由代碼可知,其實就是畫一個封閉的多邊形。其中 offset1 是根據滑動的程度計算出的移動距離。

下面重點是看樹的繪製。這裏的樹能夠分解成兩部分:樹幹和樹枝。樹幹能夠當作是一個矩形,而後上面加一個三角形;樹枝是下部一個半圓,往上逐漸收縮成到一點。其實這裏仍是比較簡單,但問題是須要隨着滑動,樹要逐漸彎曲。

這裏我作了不少嘗試,例如每條邊都用貝塞爾曲線,效果不都是很理想。最後仍是採用比較「簡單粗暴」的方法:

整個樹對稱中心,用一條「不可見」的貝塞爾曲線支撐,樹幹和樹枝圍繞這條中心線密集的用直線堆積構建。樹的彎曲效果,只須要移動貝塞爾曲線的控制點。

具體實現是這樣的,首先咱們仍是利用 PathInterpolatorCompat 來建立一個貝塞爾曲線插值器:


1
Interpolator interpolator = PathInterpolatorCompat.create(0.8f, -0.5f * factor);


其中, (0.8, -0.5*factor)是控制點,factor 是彎曲程度,這裏的參數根據須要能夠調整。而後對這個曲線進行採樣,得到歸一化曲線座標,我這裏採樣25個點。我感受這樣實現並不完美,這裏就是我前面說的「簡單粗暴」的緣由。採樣的方法以下:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
final int N = 25;  
final float dp = 1f / N;  
final float dy = -dp * height;  
float y = y0;  
float p = 0;  
float[] xx = new float[N + 1];  
float[] yy = new float[N + 1];  
for (int i = 0; i <= N; i++) {  
    // 把歸一化的採樣座標轉換爲實際座標
    xx[i] = interpolator.getInterpolation(p) * maxMove + x0;
    yy[i] = y;
    y += dy;
    p += dp;
}


而後,沿着這些採樣點,逐點用 path.lineTo() 構建樹枝和樹幹。構建樹幹的代碼以下:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
final float trunkSize = width * 0.05f;  
mTrunk.reset();  
mTrunk.moveTo(x0 - trunkSize, y0);  
int max = (int) (N * 0.7f); // 樹幹的高度爲整個樹的0.7  
int max1 = (int) (max * 0.5f); // 三角形收縮開始的點  
float diff = max - max1;  
// 添加樹幹左邊的邊緣
for (int i = 0; i < max; i++) {  
    if (i < max1) { // 等距
        mTrunk.lineTo(xx[i] - trunkSize, yy[i]);
    } else { // 線性收縮
        mTrunk.lineTo(xx[i] - trunkSize * (max - i) / diff, yy[i]);
    }
}
 
// 添加樹幹右邊的邊緣,這裏和上面對稱
for (int i = max - 1; i >= 0; i--) {  
    if (i < max1) {
        mTrunk.lineTo(xx[i] + trunkSize, yy[i]);
    } else {
        mTrunk.lineTo(xx[i] + trunkSize * (max - i) / diff, yy[i]);
    }
}
mTrunk.close();


由於樹的形態基本一致,只是大小和顏色不同,因此只要生成一個便可。生成樹枝 Path 的代碼和上面相似:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
mBranch.reset();  
int min = (int) (N * 0.4f);  
diff = N - min;
 
mBranch.moveTo(xx[min] - branchSize, yy[min]);  
// 添加樹枝底部的半圓弧
mBranch.addArc(new RectF(xx[min] - branchSize, yy[min] - branchSize, xx[min] + branchSize, yy[min] + branchSize), 0f, 180f);  
// 添加樹枝左邊的邊緣
for (int i = min; i <= N; i++) {  
    float f = (i - min) / diff;
    // 注意這裏不是線性收縮,這樣看起來樹會更加圓潤
    mBranch.lineTo(xx[i] - branchSize + f * f * branchSize, yy[i]);
}
// 添加樹枝右邊的邊緣,和上面對稱
for (int i = N; i >= min; i--) {  
    float f = (i - min) / diff;
    mBranch.lineTo(xx[i] + branchSize - f * f * branchSize, yy[i]);
}


到這裏,最關鍵的部分就已經完成了。接下來就是把這些 Path 畫出來。這裏畫的時候就是一些 canvas 的變換了,這裏就不貼代碼了。能夠直接參考源代碼。

3.4 列表動畫的實現

列表自己不是 FlyRefresh 庫的重點。爲了儘可能還原原設計,這裏也實現一下。這裏的列表能夠用 ListView 或者 RecyclerView。由於RecyclerView 對動畫控制更靈活,這裏就選用它。

若是仔細觀察,下拉回彈的時候,列表的第一項會由於慣性晃動一下。實現方法以下:


1
2
3
4
5
6
private void bounceAnimateView(View view) {  
    ...
    Animator swing = ObjectAnimator.ofFloat(view, "rotationX", 0, 30, -20, 0);
    swing.setDuration(400);
    swing.setInterpolator(new AccelerateInterpolator());
    swing.start();}


而後就是刷新完成,插入新的項的時候的動畫。這能夠經過給 RecyclerView 設置自定義的 ItemAnimator 來實現。爲了方便,我這裏直接用了開源庫 RecyclerView Animators,重載了BaseItemAnimator,插入新項的動畫以下:


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
@Override
protected void preAnimateAddImpl(RecyclerView.ViewHolder holder) {  
    // 設置初始狀態
    View icon = holder.itemView.findViewById(R.id.icon);
    icon.setRotationX(30);
    View right = holder.itemView.findViewById(R.id.right);
    // 注意這裏是沿着最左邊旋轉
    right.setPivotX(0);
    right.setPivotY(0);
    right.setRotationY(90);
}
 
@Override
protected void animateAddImpl(final RecyclerView.ViewHolder holder) {  
    View target = holder.itemView;
    View icon = target.findViewById(R.id.icon);
    Animator swing = ObjectAnimator.ofFloat(icon, "rotationX", 45, 0);
    swing.setInterpolator(new OvershootInterpolator(5));
 
    View right = holder.itemView.findViewById(R.id.right);
    Animator rotateIn = ObjectAnimator.ofFloat(right, "rotationY", 90, 0);
    rotateIn.setInterpolator(new DecelerateInterpolator());
 
    AnimatorSet animator = new AnimatorSet();
    animator.setDuration(getAddDuration());
    animator.playTogether(swing, rotateIn);
 
    animator.start();
}


完成的其實就是 icon 的晃動和內容的 3D 旋轉。

4 寫在最後

首先,很是確定的是 Zee Young 的這個設計是很成功。由於他的這個漂亮的設計,個人這個庫在 Github 這幾天也收穫了 800 多個 Star,並且還一度在 Trending 的總榜排第一。我很是清楚,代碼實現質量並非多完美,你們都是被這個設計所吸引。

可是,在實現的過程當中,我也注意到這個設計的些許不足:

  1. 做爲一個下拉刷新設計,通常包含至少三個狀態:空閒狀態,下拉,刷新中,刷新完成(能夠細分爲:刷新成功和刷新失敗)。這個設計中,缺乏了刷新中的狀態,或者說不是很明確。我在實現中,使用紙飛機飛出,表示在刷新中,飛機飛回來,表示刷新完成。這樣並非很好,由於飛機飛出去,並非一個很明顯的刷新中的動畫。對比普通的下拉刷新,是有一個轉動的 ProgressBar 表示正在處理;

  2. 這個設計中,紙飛機按鈕的做用是什麼?按照 Material Design 的規範,這是一個 Float Action Button,主要用來作正向的操做。這裏主要是用來刷新動畫,若是點擊這個按鈕,紙飛機飛出去,動畫並不能很好的連貫起來,感受也是有點怪怪的。

最後,源代碼在這裏:FlyRefresh

相關文章
相關標籤/搜索