自定義 View 是 Android 開發裏面的一個大學問。偶然間看到 TIM 郵箱界面的刷新 View 還挺好玩的,因而就本身動手實現了一個,先看看 TIM 裏邊的效果圖:
java
看到上面的動圖,大概也知道咱們須要實現的功能:git
新建一個 RefreshView 類繼承自 View ,而後咱們再在 RefreshView 裏面新建一個內部實體類: Circle
來看一下 Circle
類的代碼github
#Cirlce.javacanvas
class Circle { int x; int y; int r; int color; public Circle(int x, int y, int r, int color) { this.x = x; this.y = y; this.r = r; this.color = color; } }
這是一個實體類,裏面提供了 x , y , r , color 屬性分別表明圓心座標的 x值,y值,圓的半徑 r 跟顏色。
藉助此類來存儲小圓球的相關屬性。app
接下來就是咱們平時自定義 View 常常要重寫的三大方法了,先看 onMeasure()
ide
#RefreshView.java佈局
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthMode = MeasureSpec.getMode(widthMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.EXACTLY) { setMeasuredDimension(mWidth, heightSize); } else if (widthMeasureSpec == MeasureSpec.EXACTLY && heightMeasureSpec == MeasureSpec.AT_MOST) { setMeasuredDimension(widthSize, mHeight); } else if (widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY) { setMeasuredDimension(widthSize, heightSize); } else { setMeasuredDimension(mWidth, mHeight); } }
爲了適配佈局文件中的 wrap_content 參數,咱們須要重寫此方法(此方法不是本文的研究重點,不明白的能夠百度或者google一下,或者參考《Android開發藝術探索》裏面的相關章節)。post
接着看 onLayout()
方法:動畫
#RefreshView.javathis
@Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); initContentAttr(getMeasuredWidth(), getMeasuredHeight()); resetCircles(); }
在此方法中調用了 initContentAttr()
方法來初始化內容大小與 resetCircles()
來初始化(重置)三個小球的屬性。分別看下這兩個方法:
#RefreshView.java
private void initContentAttr(int width, int height) { mContentWidth = width - getPaddingLeft() - getPaddingRight(); mContentHeight = height - getPaddingTop() - getPaddingBottom(); }
這方法很簡單,就是進行了 padding 的處理,得出真正的佈局大小。若是不處理 padding 的話那麼用戶設置了 padding 將失效。再看 resetCircles()
:
#RefreshView.java
public static final int STATE_ORIGIN = 0; public static final int STATE_PREPARED = 1; private int mOriginState = STATE_ORIGIN; private void resetCircles() { if (mCircles.isEmpty()) { int x = mContentWidth / 2; int y = mContentHeight / 2; mGap = x - mMinRadius; //初始化相鄰圓心間的最大間距 Circle circleLeft = new Circle(x, y, mMinRadius, 0xffff7f0a); Circle circleCenter = new Circle(x, y, mMaxRadius, Color.RED); Circle circleRight = new Circle(x, y, mMinRadius, Color.GREEN); mCircles.add(LEFT, circleLeft); mCircles.add(RIGHT, circleRight); mCircles.add(CENTER, circleCenter); } if (mOriginState == STATE_ORIGIN) { int x = mContentWidth / 2; int y = mContentHeight / 2; for (int i = 0; i < mCircles.size(); i++) { Circle circle = mCircles.get(i); circle.x = x; circle.y = y; if (i == CENTER) { circle.r = mMaxRadius; } else { circle.r = mMinRadius; } } } else { prepareToStart(); } }
此方法用於初始化和重置小球,方法裏面進行的兩個大的 if...else 語句判斷,第一個 if 用於判斷是否應該初始化小球,第二個語句則是用於判斷小球的初始化時候的形態。能夠在外部調用 setOriginState()
方法來指定小球的初始化形態,如不指定,則默認爲 NOMAL,即三球重合。
#RefreshView.java
/** * 設置圓球初始狀態 * {@link #STATE_ORIGIN}爲原始狀態(三個小球重合), * {@link #STATE_PREPARED}爲準備好能夠刷新的狀態,三個小球間距最大 */ public void setOriginState(int state) { if (state == 0) { mOriginState = STATE_ORIGIN; } else { mOriginState = STATE_PREPARED; } }
最後就是最有趣的方法 onDraw()
了:
#RefreshView.java
@Override protected void onDraw(Canvas canvas) { for (Circle circle : mCircles) { mPaint.setColor(circle.color); canvas.drawCircle(circle.x + getPaddingLeft(), circle.y + getPaddingTop(), circle.r, mPaint); } }
這方法很簡單,就是將 mCircles 列表裏面的圓畫出來而已(裏面進行了 padding 的處理)。
三大方法都講完了,但是這只是畫出了幾個小圓球而已,咱們需求分析裏的需求還沒實現呢,上面的方法已經把 View 的基礎搭起來了,要實現這個也就不難了。接下來就是你們期待的需求實現了:
實現代碼以下:
#RefreshView.java
public void drag(float fraction) { if (mOriginState == STATE_PREPARED) { return; } if (mAnimator != null && mAnimator.isRunning()) { return; } if (fraction > 1) { return; } mCircles.get(LEFT).x = (int) (mMinRadius + mGap * (1f - fraction)); mCircles.get(RIGHT).x = (int) (mContentWidth / 2 + mGap * fraction); postInvalidate(); }
在方法裏面進行三次判斷,若是初始狀態是 STATE_PREPARED (三小球距離最大,不必再變更了)、動畫正在進行或者進度大於1 都不進行移動。而後修改小球的屬性,再重繪。
這個是這個自定義 View 最難的部分了,須要一些數學的小運算,有點繁瑣。
咱們先來理清實現動畫的邏輯,看了開篇的gif,應該能夠了解到,剛準備開始動畫時,左邊的小球應該是處於最左端,中間的小球處於中間,右邊的處於最右端。咱們一個個小球來分析。
1) 左邊小球:動畫開始後,左邊的小球向右移動,而且逐漸變大,直到小球運動到中點,過了中點後小球繼續往右移動,不過卻逐漸變小,到了終點後小球將消失(消失過程爲先縮小再消失,下同),接着又從左邊出現(出現過程也是從小到大的漸變,下同),而後重複上述過程。
2) 中間小球:中間的小球先向右移動,逐漸縮小,而後消失,後來再從左邊出現,最後移動到中間,其間逐漸變大。後面就是重複的上述動做。
3) 右邊小球:右邊的小球則是先消失,再從左邊出現,接着移動到中間,其間逐漸變大,而後再從中點移動到末端,其間逐漸縮小。
理清小球的移動過程對代碼的實現頗有幫助,咱們能夠分析出:
1)每一個小球對於座標系的移動特色是同樣的。
2)每一個小球對於動畫的進度的移動特色是不同的。
聽起來好像有點拗口,咱們用人話來解釋一下:
1)每一個小球對於座標系的移動特色是同樣的:左邊的小球在座標的最左邊是先出現,而後再向右移動,那麼中間和右邊的小球呢?實際上是一樣的,它們在座標軸最左邊的時候都是先出現,再向右移動,不管哪一個小球,它們在座標軸的同一點上的動做和形態應該是一致的。
2)每一個小球對於動畫的進度的移動特色是不同的:左邊的小球在動畫剛開始時是處於最左端,而中間的小球卻在中間位置,右邊的則在最右端。當動畫開始後,好比進行了一半,這時候左邊的小球應該移動到了中點附近,而中間的確是在末端(消失),右邊的小球就會出如今中間附近。
按照上面分析的邏輯,我把動畫的總進度分爲6份,爲何是6份呢?經過上面的動畫分析,知道小球應該經歷一下過程(不分時間前後):
爲了讓小球之間的間隔保持一個優美的狀態(動畫開始後小球間不會重疊,相鄰小球的間隔基本一致),就把一、4出現和消失階段分別設爲 1/6 的動畫週期,中間二、3兩個階段分別佔用 1/3 個動畫週期。 ![座標.png](https://user-gold-cdn.xitu.io/2019/1/5/1681d638ed8d3fdb?w=900&h=375&f=png&s=26870) 這樣一來,出現跟消失佔用了 1/3 動畫進度,其餘兩個部分分別佔用了 1/3 動畫進度。舉個例子:剛開始動畫時,設最左邊的小球爲 1,中間的小球爲 2,最右端的小球爲 3 。 當 **小球1** 移動到中點時,這時動畫進行了 1/3 ,那麼此時的 **小球2** 就應該移動到末端,**小球3** 則恰好經歷消失和出現過程,因而應該出現於座標軸的起點。 由此能夠看到又恢復到了剛開始時候的狀況(一個小球在最左,一個在中,一個在最右),只不過是顏色不一樣了而已。以此類推,無限循環,就能夠造成優美的動畫了。 分析出這些有什麼用呢?我發現用座標來肯定小球的移動實現起來會有點小問題,因此就用動畫的進度來實現,下面看具體實現。 須要實現小球的無限運動,最實用的就是用動畫來實現,這裏我用了屬性動畫。先初始化 Animotor 類: \#RefreshView.java ``` private void initAnimator() { ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f); animator.setDuration(1500); animator.setRepeatCount(-1); animator.setRepeatMode(ValueAnimator.RESTART); animator.setInterpolator(new LinearInterpolator()); animator.addListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animation) { prepareToStart(); //確保View達到能夠刷新的狀態 } @Override public void onAnimationEnd(Animator animation) { } @Override public void onAnimationCancel(Animator animation) { } @Override public void onAnimationRepeat(Animator animation) { } }); animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { for (Circle circle : mCircles) { updateCircle(circle, mCircles.indexOf(circle), animation.getAnimatedFraction()); } postInvalidate(); } }); mAnimator = animator; } ``` 能夠看到,這是一個無限循環的動畫,若是不手動中止,它就會一直循環下去。對於 mAnimator ,還添加了一個監聽器,當開始動畫是就調用 `prepareToStart()` 方法,這個方法看起來是否是有點眼熟,沒錯,它就是咱們上面 `resetCircles()` 裏面判斷小球形態爲 STATE_PREPARED 是調用過,此方法將確保小球達到刷新的臨界點。咱們主要看看 UpdateLisener 中的 `onAnimationUpdate()` 方法裏面的 `updateCircle()` 方法: \#RefreshView ``` private void updateCircle(Circle circle, int index, float fraction) { float progress = fraction; //真實進度 float virtualFraction; //每一個小球內部的虛擬進度 switch (index) { case LEFT: if (fraction < 5f / 6f) { progress = progress + 1f / 6f; } else { progress = progress - 5f / 6f; } break; case CENTER: if (fraction < 0.5f) { progress = progress + 0.5f; } else { progress = progress - 0.5f; } break; case RIGHT: if (fraction < 1f / 6f) { progress += 5f / 6f; } else { progress -= 1f / 6f; } break; } if (progress <= 1f / 6f) { virtualFraction = progress * 6; appear(circle, virtualFraction); return; } if (progress >= 5f / 6f) { virtualFraction = (progress - 5f / 6f) * 6; disappear(circle, virtualFraction); return; } virtualFraction = (progress - 1f / 6f) * 3f / 2f; move(circle, virtualFraction); } ``` 我用了一個 virtualFraction 來表示每一個小球的虛擬進度(至關於上面座標圖中的下值,即座標百分比),例如當動畫的總進度爲 0 時,左小球的虛擬進度就應該是 1/6+0 (默認已經通過了出現過程,消耗了 1/6),中間小球的虛擬進度爲 1/6+1/3+0 = 1/2 (默認經歷了出現,移動到中間過程),最右邊小球的虛擬進度爲 1/6+1/3+1/3+0 = 5/6 。而後動畫的總進度到 1/3 時,左小球的虛擬進度就爲 1/2 (中間位置)...... 下面再看下 `move()` 、`appear()`、`disapear()` 方法: \#RefreshView ``` private void appear(Circle circle, float fraction) { circle.r = (int) (mMinRadius * fraction); circle.x = mMinRadius; } private void disappear(Circle circle, float fraction) { circle.r = (int) (mMinRadius * (1 - fraction)); } private void move(Circle circle, float fraction) { int difference = mMaxRadius - mMinRadius; if (fraction < 0.5) { circle.r = (int) (mMinRadius + difference * fraction * 2); } else { circle.r = (int) (mMaxRadius - difference * (fraction - 0.5) * 2); } circle.x = (int) (mMinRadius + mGap * 2 * fraction); } ``` 這個三個方法都很簡單,根據座標的佔比來計算出小球的座標跟大小。 以上就是整個 RefershView 的實現了,若是須要看源碼的能夠拉到文末。
看下怎麼使用:
#MainActivity
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); mRefreshView = findViewById(R.id.refresh_view); // mRefreshView.setOriginState(RefreshView.STATE_PREPARED); Button start = findViewById(R.id.start); Button stop = findViewById(R.id.stop); SeekBar seekBar = findViewById(R.id.seek_bar); seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { mRefreshView.drag(progress / 100f); } @Override public void onStartTrackingTouch(SeekBar seekBar) { } @Override public void onStopTrackingTouch(SeekBar seekBar) { } }); start.setOnClickListener(this); stop.setOnClickListener(this); } @Override public void onClick(View v) { switch (v.getId()) { case R.id.start: mRefreshView.start(); break; case R.id.stop: mRefreshView.stop(); break; } }
效果圖:
因爲錄製軟件的問題,綠色的小球顯示效果不太好,在手機或虛擬機上顯示是正常的。再看個項目裏的實際運用效果:
錄屏軟件對綠色好像過敏,將就看一下吧。
此文到此就結束了,感謝閱讀,喜歡的動動小手點個贊。
Demo 地址:https://github.com/gminibird/...