前言
最近從Airbnb的Lottie開源程式中,挑了Favorite Star和Twitter Heart的效果來練習,這兩個動畫效果其實相仿,只是使用星星和愛心而已,這篇文章就是來解說如何達到這個效果,效果如下。
以下我歸納為幾個實作步驟:
- 製作CircleView類別(中間圓圈的繪製)
- 製作SideCircleView類別(外側大小圓圈的繪製)
- 製作HeartView類別
- 製作FavoriteView類別
製作CircleView類別(中間圓圈的繪製)
中間圓圈分成內圈和外圈兩種,基本上就是繪製兩層圓圈,只是內圈會使用延遲,看起來會有同心圓的效果,以下為重要的程式碼片段。
- Init(): 這個是初始化方法,分別設置內外圓圈的畫筆。
private void init() {
this.outerCirclePaint.setStyle(Paint.Style.FILL);
this.outerCirclePaint.setColor(COLOR);
this.innerCirclePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
this.innerCirclePaint.setColor(COLOR);
}
- onSizeChanged(): 這個是視圖的生命週期的其中一個方法,當視圖尺寸有變更時所呼叫的方法,我們在這邊設置圓圈最大的尺寸。
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
this.maxCircleSize = w / 2;
this.tempBitmap = Bitmap.createBitmap(this.getWidth(), this.getWidth(), Bitmap.Config.ARGB_8888);
this.tempCanvas = new Canvas(this.tempBitmap);
}
- onDraw(): 在這邊我們繪製內外圓圈,並且設置兩個變數 - outerCircleRadiusProgress與innerCircleRadiusProgress,提供Animator在數值改變時,同時更新我們的進度。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
this.tempCanvas.drawColor(0xffffff, PorterDuff.Mode.CLEAR);
this.tempCanvas.drawCircle(this.getWidth() / 2, this.getHeight() / 2, this.outerCircleRadiusProgress * this.maxCircleSize, this.outerCirclePaint);
this.tempCanvas.drawCircle(this.getWidth() / 2, this.getHeight() / 2, this.innerCircleRadiusProgress * this.maxCircleSize, this.innerCirclePaint);
canvas.drawBitmap(this.tempBitmap, 0, 0, null);
}
製作SideCircleView類別(外側大小圓圈的繪製)
外側圓圈我們共繪製八個,其實你可以使用八種不同的色彩來進行顏色繪製,但這邊我直接使用同一種顏色(0xFF99BBFF),以下為重要的程式碼片段。- init(): 這邊初始化八個畫筆,你可以將顏色設定成隨機八種,為求方便,這邊只使用一種。
private void init() {
for (int i = 0; i < this.circlePaints.length; i++) {
this.circlePaints[i] = new Paint();
this.circlePaints[i].setStyle(Paint.Style.FILL);
this.circlePaints[i].setColor(COLOR);
}
}
- onSizeChanged(): 同CircleView類別,只是設定中心點的X與Y的位置、尺寸大小、大圓和小圓的半徑。大圓的半徑則為寬度一半小一點,小圓則是一樣,基本上這邊可以自由設置。
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
this.centerX = w / 2;
this.centerY = h / 2;
this.maxDotSize = 20;
this.maxOuterDotsRadius = w / 2 - this.maxDotSize * 2;
this.maxInnerDotsRadius = this.maxOuterDotsRadius;
}
- onDraw(): 這邊需要用到三角函數來計算每個大小圓被分配的弧度,也就是大小圓的實際X和Y,先計算出大圓的位置,小圓則是往左偏移,也就是將大圓的位置扣除欲偏移的距離即可。
@Override
protected void onDraw(Canvas canvas) {
this.drawOuterDotsFrame(canvas);
this.drawInnerDotsFrame(canvas);
}
private void drawOuterDotsFrame(Canvas canvas) {
for (int i = 0; i < DOTS_COUNT; i++) {
int cX = (int) (centerX + this.currentRadius1 * Math.cos(i * OUTER_DOTS_POSITION_ANGLE * Math.PI / 180));
int cY = (int) (centerY + this.currentRadius1 * Math.sin(i * OUTER_DOTS_POSITION_ANGLE * Math.PI / 180));
canvas.drawCircle(cX, cY, this.currentDotSize1, this.circlePaints[i % this.circlePaints.length]);
}
}
private void drawInnerDotsFrame(Canvas canvas) {
for (int i = 0; i < DOTS_COUNT; i++) {
int cX = (int) (centerX + this.currentRadius2 * Math.cos((i * OUTER_DOTS_POSITION_ANGLE - 10) * Math.PI / 180));
int cY = (int) (centerY + this.currentRadius2 * Math.sin((i * OUTER_DOTS_POSITION_ANGLE - 10) * Math.PI / 180));
canvas.drawCircle(cX, cY, this.currentDotSize2, this.circlePaints[(i + 1) % this.circlePaints.length]);
}
}
- setCurrentProgress(): 這個方法則是動畫更新時,我們要更新的大小圓繪製進度。大圓則是在進度70%前,維持原本尺寸,在70%後則逐漸消失;小圓分為三種進度,20%前維持原本尺寸,20%至50%則是逐漸出現,之後則是逐漸消失。
public void setCurrentProgress(float currentProgress) {
this.currentProgress = currentProgress;
this.updateOuterDotsPosition();
this.updateInnerDotsPosition();
this.postInvalidate();
}
private void updateOuterDotsPosition() {
this.currentRadius1 = (float) this.rangeValue(currentProgress, 0.0f, 1.0f, 0.0f, maxOuterDotsRadius);
if (currentProgress < 0.7) {
this.currentDotSize1 = maxDotSize;
} else {
this.currentDotSize1 = (float) this.rangeValue(currentProgress, 0.7f, 1.0f, maxDotSize, 0.0f);
}
}
private void updateInnerDotsPosition() {
this.currentRadius2 = (float) this.rangeValue(currentProgress, 0.0f, 1.0f, 0.0f, maxInnerDotsRadius);
if (currentProgress < 0.2) {
this.currentDotSize2 = maxDotSize;
} else if (currentProgress < 0.5) {
this.currentDotSize2 = (float) this.rangeValue(currentProgress, 0.2f, 0.5f, maxDotSize, 0.8f * maxDotSize);
} else {
this.currentDotSize2 = (float) this.rangeValue(currentProgress, 0.5f, 1.0f, maxDotSize * 0.8f, 0.0f);
}
}
public double rangeValue(double value, double fromLow, double fromHigh, double toLow, double toHigh) {
return toLow + ((value - fromLow) / (fromHigh - fromLow) * (toHigh - toLow));
}
製作HeartView類別
先前已介紹過愛心的繪製,這邊就不多做說明,可以參考先前的文章:Android: 製作Heart Beat視圖。
製作FavoriteView類別
這個視圖則是將所有的視圖組合起來,並且加入動畫,以下為重要的程式碼片段。- custom_view_favorite.xml
<merge xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<com.xy.favoriteview.view.SideCircleView
android:id="@+id/view_side_circle"
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_gravity="center" />
<com.xy.favoriteview.view.CircleView
android:id="@+id/view_circle"
android:layout_width="58dp"
android:layout_height="58dp"
android:layout_gravity="center" />
<com.xy.favoriteview.view.HeartView
android:id="@+id/view_heart"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center" />
</merge>
- init()
private void init() {
View view = LayoutInflater.from(this.getContext()).inflate(R.layout.custom_view_favorite, this, true);
this.circleView = (CircleView) view.findViewById(R.id.view_circle);
this.sideCircleView = (SideCircleView) view.findViewById(R.id.view_side_circle);
this.heartView = (HeartView) view.findViewById(R.id.view_heart);
}
- launchAnim()
public void launchAnim() {
this.circleView.setInnerCircleRadiusProgress(0);
this.circleView.setOuterCircleRadiusProgress(0);
this.sideCircleView.setCurrentProgress(0);
final ObjectAnimator outerCircleAnimator = ObjectAnimator.ofFloat(this.circleView, CircleView.OUTER_CIRCLE_RADIUS_PROGRESS, 0.1f, 1f);
outerCircleAnimator.setDuration(300);
outerCircleAnimator.setInterpolator(this.decelerateInterpolator);
final ObjectAnimator innerCircleAnimator = ObjectAnimator.ofFloat(this.circleView, CircleView.INNER_CIRCLE_RADIUS_PROGRESS, 0.1f, 1f);
innerCircleAnimator.setDuration(350);
innerCircleAnimator.setStartDelay(100);
innerCircleAnimator.setInterpolator(this.decelerateInterpolator);
final ObjectAnimator sideCircleAnimator = ObjectAnimator.ofFloat(this.sideCircleView, this.sideCircleView.SIDE_CIRCLE_PROGRESS, 0, 1f);
sideCircleAnimator.setDuration(600);
sideCircleAnimator.setStartDelay(300);
sideCircleAnimator.setInterpolator(this.decelerateInterpolator);
final ValueAnimator heartColorAnimator = ValueAnimator.ofInt(ORIGINAL_COLOR, FINAL_COLOR);
heartColorAnimator.setEvaluator(this.argbEvaluator);
heartColorAnimator.setDuration(500);
heartColorAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
heartView.setHeartColor((int) animation.getAnimatedValue());
}
});
final ObjectAnimator heartScaleYAnimator = ObjectAnimator.ofFloat(this.heartView, ImageView.SCALE_Y, 0.2f, 1f);
heartScaleYAnimator.setDuration(500);
heartScaleYAnimator.setInterpolator(this.overshootInterpolator);
final ObjectAnimator heartScaleXAnimator = ObjectAnimator.ofFloat(this.heartView, ImageView.SCALE_X, 0.2f, 1f);
heartScaleXAnimator.setDuration(500);
heartScaleXAnimator.setInterpolator(this.overshootInterpolator);
this.animatorSet = new AnimatorSet();
this.animatorSet.playTogether(outerCircleAnimator, innerCircleAnimator, heartColorAnimator, heartScaleYAnimator, heartScaleXAnimator, sideCircleAnimator);
this.animatorSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationCancel(Animator animation) {
circleView.setInnerCircleRadiusProgress(0);
circleView.setOuterCircleRadiusProgress(0);
sideCircleView.setCurrentProgress(0);
}
});