編輯推薦:稀土掘金,這是一個(gè)針對(duì)技術(shù)開(kāi)發(fā)者的一個(gè)應(yīng)用,你可以在掘金上獲取最新最優(yōu)質(zhì)的技術(shù)干貨,不僅僅是Android知識(shí)、前端、后端以至于產(chǎn)品和設(shè)計(jì)都有涉獵,想成為全棧工程師的朋友不要錯(cuò)過(guò)!
原文:http://www./flyrefresh/ ,很給力有木有。
幾天前在網(wǎng)上看到 @Zee Young 的一個(gè)下拉刷新的設(shè)計(jì) Replace。如下圖:
第一眼看到這個(gè)設(shè)計(jì)就覺(jué)得眼前一亮,在Dribble上獲得了 1.7k 多的 like,微博上也有大量轉(zhuǎn)發(fā)??梢?jiàn)確實(shí)一個(gè)很成功的設(shè)計(jì)。我準(zhǔn)備在 Android 上來(lái)實(shí)現(xiàn)它。
經(jīng)過(guò)幾天的折騰,最終實(shí)現(xiàn)并開(kāi)源在 Github 上,項(xiàng)目地址: FlyRefresh,實(shí)際效果如下圖:
總體上還原了設(shè)計(jì)的70%~80%,還有一些細(xì)節(jié)需要改進(jìn)。因?yàn)闆](méi)有拿到設(shè)計(jì)師的設(shè)計(jì)源文件,動(dòng)畫和顏色的細(xì)節(jié)并沒(méi)有能夠做的完全一致。下面分享一下實(shí)現(xiàn)的過(guò)程。 1 分析設(shè)計(jì)效果圖要實(shí)現(xiàn)這個(gè)設(shè)計(jì),就要非常仔細(xì)的分析這個(gè)動(dòng)畫的每個(gè)細(xì)節(jié)。由于沒(méi)有設(shè)計(jì)源文件,我最開(kāi)始就一直盯著這個(gè) GIF 圖看,然后構(gòu)思一下大致的實(shí)現(xiàn)流程。在寫代碼的過(guò)程中,甚至把 GIF 圖分解成一幀一幀的圖片來(lái)分析,把 GIF 圖分解的方法如下:
- convert -coalesce animation.gif frame.png
從設(shè)計(jì)圖中,得到大致如下的結(jié)論: 總體上是一個(gè)下拉刷新的效果; 頁(yè)面上大概分為兩部分:頭部和內(nèi)容部分; 頭部塊疊放在內(nèi)容塊的下面; 內(nèi)容塊可以下拉,放手能夠回彈,并觸發(fā)飛機(jī)飛出的動(dòng)畫; 頭部塊隨著下拉過(guò)程中有動(dòng)畫(這個(gè)是重點(diǎn),后面會(huì)詳細(xì)介紹);
2 軟件設(shè)計(jì)軟件上我打算把它實(shí)現(xiàn)成一個(gè)下拉刷新的控件。一說(shuō)到下拉刷新,有一大堆的開(kāi)源實(shí)現(xiàn),都或多或少的需要一些修改才能滿足我這里的需求,我打算自己實(shí)現(xiàn)一個(gè)量身定做的。
控件的布局關(guān)系大概如下圖所示:
布局分為上下兩塊,上部實(shí)線框?yàn)轭^部,虛線框?yàn)閮?nèi)容區(qū)域。內(nèi)容區(qū)域覆蓋在頭部上面。通常情況下,內(nèi)容區(qū)域覆蓋頭部,留出頭部 Normal height 的高度。內(nèi)容區(qū)域可以上滑,最多覆蓋到Shrink height 高度;下滑最多可以把頭部區(qū)域留出Expended height ,下滑超過(guò)Normal height 的時(shí)候,放手會(huì)自動(dòng)彈回。內(nèi)容區(qū)域可以滑動(dòng)的距離為Expended_height - Shrink_height 。 這是一個(gè)比較通用的布局模式,只要重載這個(gè)布局,基本上可以涵蓋了所有下刷新的模式。例如Shrink_height=0 的話,頭部可以全部收起來(lái)的;如果Shrink_height==Normal height 的話,就是一個(gè)有固定頭部的下拉控件;如果Expended_height > Normal height > Shrink_height ,就是頭部可以擴(kuò)展收縮的下拉控件。 頭部動(dòng)畫部分,這里可能不同的設(shè)計(jì),變化最大的部分。但是有一個(gè)共同點(diǎn),就是頭部顯示會(huì)根據(jù)內(nèi)容塊的滑動(dòng)情況來(lái)變化。在軟件上,設(shè)計(jì)出接口,不同的動(dòng)畫,實(shí)現(xiàn)此接口就可以。本文的 FlyRefresh 的動(dòng)畫只是這個(gè)接口的一個(gè)具體實(shí)現(xiàn)。如果要實(shí)現(xiàn)其他的刷新動(dòng)畫,并不需要做多大的改動(dòng)。 3 具體實(shí)現(xiàn)
根據(jù)上面的設(shè)計(jì),畫出類圖如下: 3.1 PullHeaderLayout 這是一個(gè)基類,實(shí)現(xiàn)了布局和滑動(dòng)功能。從類圖中可以看到,這個(gè)布局中主要包含兩部分View:mHeaderView ,mContent ,另外還有 mFlyView ,這頭部和內(nèi)容連接處的按鈕。布局也比較簡(jiǎn)單,具體實(shí)現(xiàn)可以參考代碼 layoutChildren() 。 滑動(dòng)是這里這個(gè)類的實(shí)現(xiàn)重點(diǎn),這里需要特別小心處理 Touch 事件。Touch 事件需要滿足的是,如果 ContentView 可以整體滑動(dòng),我們的 Layout 就需要截獲 Touch 事件。否這需要把 Touch 事件傳遞給子 View,這樣才不會(huì)影響內(nèi)部子 View 的功能。 在處理Touch事件的時(shí)候,需要時(shí)刻判斷 View 所處的狀態(tài),這里借助兩個(gè)輔助類 HeaderController 和 ScrollChecker 。HeaderController 主要是保存和判斷當(dāng)前 Header 的高度和狀態(tài)。ScrollChecker 用來(lái)檢測(cè) ContentView 是否可以滑動(dòng)。為了讓滑動(dòng)流暢,還需要小心處理 Fling 狀態(tài),這里借助了 Scroller 和 VelocityTracker 兩個(gè)工具類。 另外值得一提的是,當(dāng)滑動(dòng) Header 的高度大于 Normal height 的時(shí)候,ContentView 需要自動(dòng)恢復(fù)回去。仔細(xì)觀察原設(shè)計(jì)的動(dòng)畫,這個(gè)回彈過(guò)程是有類似橡皮筋一樣的彈性的。這里利用了屬性動(dòng)畫類,使用自定義的插值器實(shí)現(xiàn),具體參考源代碼的 'ElasticOutInterpolator' 類(參考自:AnimationEasingFunctions)。 因?yàn)檫@里這個(gè)類的功能和常見(jiàn)的下拉刷新的類似,這樣就有很多優(yōu)秀的開(kāi)源庫(kù)可以參考,我的實(shí)現(xiàn)中很大程度上借鑒了優(yōu)秀的開(kāi)源庫(kù):Ultra Pull To Refresh,讓我避免了很多坑。 3.2 FlyRefreshLayout 這里 FlyRefreshLayout 直接繼承與上面的 PullHeaderLayout 。因?yàn)榇蟛糠止ぷ鞫荚诨愔型瓿?,這個(gè)類實(shí)現(xiàn)很簡(jiǎn)單。這個(gè)類主要是為了簡(jiǎn)化使用,默認(rèn)添加了動(dòng)畫頭部 MountanScenceView 和添加了刷新的接口 OnPullRefreshListener 。 紙飛機(jī)的動(dòng)畫就在這里實(shí)現(xiàn)。紙飛機(jī)動(dòng)畫包括三個(gè)部分: 隨著下拉,逆時(shí)針轉(zhuǎn)動(dòng); 放手的時(shí)候,觸發(fā)刷新,發(fā)射出去; 刷新完成,飛機(jī)飛回來(lái),回到原來(lái)的位置。
動(dòng)畫 1:實(shí)現(xiàn)非常簡(jiǎn)單,因?yàn)?PullHeaderLayout 有 onMoveHeader() 的回調(diào),只要重載這個(gè)函數(shù),設(shè)置旋轉(zhuǎn) view.setRotation(degree) 即可; 動(dòng)畫 2:仔細(xì)觀察設(shè)計(jì),這是一個(gè)組合動(dòng)畫:整體向右上角移動(dòng),同時(shí)繞 X 軸做 3D 轉(zhuǎn)動(dòng),飛機(jī)頭部慢慢趨向水平,并且慢慢縮小。這里需要實(shí)現(xiàn),因?yàn)樾枰险鎸?shí)的物理效果,否這可能看起來(lái)會(huì)非常生硬。注意這里,我們可以使用 PathInterpolatorCompat 來(lái)幫助我們生成任意貝塞爾曲線插值器。 動(dòng)畫 3:這一步和動(dòng)畫2類似。 在紙飛機(jī)執(zhí)行動(dòng)畫的同時(shí),頭部的山脈和樹(shù)也會(huì)隨著動(dòng),這里動(dòng)效比較復(fù)雜,而且比較獨(dú)立,我這里就寫到一個(gè)專門的類 MountanScenceView 中,見(jiàn) 3.3 節(jié)。 3.3 MountanScenceView 最后來(lái)實(shí)現(xiàn)最抓人眼球的 MountanScenceView 。和之前的思路一樣,我們先來(lái)分解一下原設(shè)計(jì)的動(dòng)畫:山脈按照遠(yuǎn)近分為三層景深,近處的山的顏色比較深,而且隨著下拉的時(shí)候也會(huì)向下移動(dòng),并且呈現(xiàn)視差,并且伴隨這樹(shù)的扭動(dòng),這是整個(gè)動(dòng)畫的點(diǎn)睛之筆。 從畫面的風(fēng)格來(lái)看,這是矢量圖,隨著畫面大小后者長(zhǎng)寬變化,山脈應(yīng)該能夠自動(dòng)適應(yīng),并充滿視圖。需要注意的是,不管畫面怎么變化,需要保持長(zhǎng)寬比不變。這樣的話,用如果用圖片就不能很好的滿足要求了,所以決定是 Path 來(lái)手動(dòng)繪制整個(gè)場(chǎng)景。因?yàn)閳?chǎng)景要適應(yīng) View 的大小,所以在 onMeasure() 的時(shí)候,計(jì)算出縮放比例: - @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);
- }
繪制山脈比較簡(jiǎn)單,Path 也不復(fù)雜,比如其中一個(gè)山的Path的生成如下: - 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);
- ...
- }
其實(shí)由代碼可知,其實(shí)就是畫一個(gè)封閉的多邊形。其中 offset1 是根據(jù)滑動(dòng)的程度計(jì)算出的移動(dòng)距離。 下面重點(diǎn)是看樹(shù)的繪制。這里的樹(shù)可以分解成兩部分:樹(shù)干和樹(shù)枝。樹(shù)干可以看成是一個(gè)矩形,然后上面加一個(gè)三角形;樹(shù)枝是下部一個(gè)半圓,往上逐漸收縮成到一點(diǎn)。其實(shí)這里還是比較簡(jiǎn)單,但問(wèn)題是需要隨著滑動(dòng),樹(shù)要逐漸彎曲。 這里我做了很多嘗試,例如每條邊都用貝塞爾曲線,效果不都是很理想。最后還是采用比較“簡(jiǎn)單粗暴”的方法: 整個(gè)樹(shù)對(duì)稱中心,用一條“不可見(jiàn)”的貝塞爾曲線支撐,樹(shù)干和樹(shù)枝圍繞這條中心線密集的用直線堆積構(gòu)建。樹(shù)的彎曲效果,只需要移動(dòng)貝塞爾曲線的控制點(diǎn)。
具體實(shí)現(xiàn)是這樣的,首先我們還是利用 PathInterpolatorCompat 來(lái)創(chuàng)建一個(gè)貝塞爾曲線插值器:
- Interpolator interpolator = PathInterpolatorCompat.create(0.8f, -0.5f * factor);
其中, (0.8, -0.5*factor) 是控制點(diǎn),factor 是彎曲程度,這里的參數(shù)根據(jù)需要可以調(diào)整。然后對(duì)這個(gè)曲線進(jìn)行采樣,獲得歸一化曲線坐標(biāo),我這里采樣25個(gè)點(diǎn)。我感覺(jué)這樣實(shí)現(xiàn)并不完美,這里就是我前面說(shuō)的“簡(jiǎn)單粗暴”的原因。采樣的方法如下:
- 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++) {
- // 把歸一化的采樣坐標(biāo)轉(zhuǎn)換為實(shí)際坐標(biāo)
- xx[i] = interpolator.getInterpolation(p) * maxMove + x0;
- yy[i] = y;
- y += dy;
- p += dp;
- }
然后,沿著這些采樣點(diǎn),逐點(diǎn)用 path.lineTo() 構(gòu)建樹(shù)枝和樹(shù)干。構(gòu)建樹(shù)干的代碼如下:
- final float trunkSize = width * 0.05f;
- mTrunk.reset();
- mTrunk.moveTo(x0 - trunkSize, y0);
- int max = (int) (N * 0.7f); // 樹(shù)干的高度為整個(gè)樹(shù)的0.7
- int max1 = (int) (max * 0.5f); // 三角形收縮開(kāi)始的點(diǎn)
- float diff = max - max1;
- // 添加樹(shù)干左邊的邊緣
- 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]);
- }
- }
-
- // 添加樹(shù)干右邊的邊緣,這里和上面對(duì)稱
- 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();
因?yàn)闃?shù)的形態(tài)基本一致,只是大小和顏色不一樣,所以只要生成一個(gè)即可。生成樹(shù)枝 Path 的代碼和上面類似:
- mBranch.reset();
- int min = (int) (N * 0.4f);
- diff = N - min;
-
- mBranch.moveTo(xx[min] - branchSize, yy[min]);
- // 添加樹(shù)枝底部的半圓弧
- mBranch.addArc(new RectF(xx[min] - branchSize, yy[min] - branchSize, xx[min] + branchSize, yy[min] + branchSize), 0f, 180f);
- // 添加樹(shù)枝左邊的邊緣
- for (int i = min; i <= N; i++) {
- float f = (i - min) / diff;
- // 注意這里不是線性收縮,這樣看起來(lái)樹(shù)會(huì)更加圓潤(rùn)
- mBranch.lineTo(xx[i] - branchSize + f * f * branchSize, yy[i]);
- }
- // 添加樹(shù)枝右邊的邊緣,和上面對(duì)稱
- for (int i = N; i >= min; i--) {
- float f = (i - min) / diff;
- mBranch.lineTo(xx[i] + branchSize - f * f * branchSize, yy[i]);
- }
到這里,最關(guān)鍵的部分就已經(jīng)完成了。接下來(lái)就是把這些 Path 畫出來(lái)。這里畫的時(shí)候就是一些 canvas 的變換了,這里就不貼代碼了。可以直接參考源代碼。 3.4 列表動(dòng)畫的實(shí)現(xiàn)列表本身不是 FlyRefresh 庫(kù)的重點(diǎn)。為了盡量還原原設(shè)計(jì),這里也實(shí)現(xiàn)一下。這里的列表可以用 ListView 或者 RecyclerView 。因?yàn)?RecyclerView 對(duì)動(dòng)畫控制更靈活,這里就選用它。 如果仔細(xì)觀察,下拉回彈的時(shí)候,列表的第一項(xiàng)會(huì)因?yàn)閼T性晃動(dòng)一下。實(shí)現(xiàn)方法如下:
- private void bounceAnimateView(View view) {
- ...
- Animator swing = ObjectAnimator.ofFloat(view, "rotationX", 0, 30, -20, 0);
- swing.setDuration(400);
- swing.setInterpolator(new AccelerateInterpolator());
- swing.start();}
然后就是刷新完成,插入新的項(xiàng)的時(shí)候的動(dòng)畫。這可以通過(guò)給 RecyclerView 設(shè)置自定義的 ItemAnimator 來(lái)實(shí)現(xiàn)。為了方便,我這里直接用了開(kāi)源庫(kù) RecyclerView Animators,重載了BaseItemAnimator ,插入新項(xiàng)的動(dòng)畫如下:
- @Override
- protected void preAnimateAddImpl(RecyclerView.ViewHolder holder) {
- // 設(shè)置初始狀態(tài)
- View icon = holder.itemView.findViewById(R.id.icon);
- icon.setRotationX(30);
- View right = holder.itemView.findViewById(R.id.right);
- // 注意這里是沿著最左邊旋轉(zhuǎn)
- 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();
- }
完成的其實(shí)就是 icon 的晃動(dòng)和內(nèi)容的 3D 旋轉(zhuǎn)。 4 寫在最后首先,非??隙ǖ氖?Zee Young 的這個(gè)設(shè)計(jì)是很成功。因?yàn)樗倪@個(gè)漂亮的設(shè)計(jì),我的這個(gè)庫(kù)在 Github 這幾天也收獲了 800 多個(gè) Star,而且還一度在 Trending 的總榜排第一。我非常清楚,代碼實(shí)現(xiàn)質(zhì)量并不是多完美,大家都是被這個(gè)設(shè)計(jì)所吸引。 但是,在實(shí)現(xiàn)的過(guò)程中,我也注意到這個(gè)設(shè)計(jì)的些許不足: 作為一個(gè)下拉刷新設(shè)計(jì),一般包含至少三個(gè)狀態(tài):空閑狀態(tài),下拉,刷新中,刷新完成(可以細(xì)分為:刷新成功和刷新失?。?。這個(gè)設(shè)計(jì)中,缺少了刷新中的狀態(tài),或者說(shuō)不是很明確。我在實(shí)現(xiàn)中,使用紙飛機(jī)飛出,表示在刷新中,飛機(jī)飛回來(lái),表示刷新完成。這樣并不是很好,因?yàn)轱w機(jī)飛出去,并不是一個(gè)很明顯的刷新中的動(dòng)畫。對(duì)比普通的下拉刷新,是有一個(gè)轉(zhuǎn)動(dòng)的 ProgressBar 表示正在處理; 這個(gè)設(shè)計(jì)中,紙飛機(jī)按鈕的作用是什么?按照 Material Design 的規(guī)范,這是一個(gè) Float Action Button,主要用來(lái)做正向的操作。這里主要是用來(lái)刷新動(dòng)畫,如果點(diǎn)擊這個(gè)按鈕,紙飛機(jī)飛出去,動(dòng)畫并不能很好的連貫起來(lái),感覺(jué)也是有點(diǎn)怪怪的。
最后,源代碼在這里:FlyRefresh。
|