2017年5月21日 星期日

Android: FavoriteView - 模擬TwitterLike的效果

前言

最近從Airbnb的Lottie開源程式中,挑了Favorite Star和Twitter Heart的效果來練習,這兩個動畫效果其實相仿,只是使用星星和愛心而已,這篇文章就是來解說如何達到這個效果,效果如下。



以下我歸納為幾個實作步驟:
  1. 製作CircleView類別(中間圓圈的繪製)
  2. 製作SideCircleView類別(外側大小圓圈的繪製)
  3. 製作HeartView類別
  4. 製作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);
            }
        });
      

Source Code

GitHub: https://github.com/xavier0507/FavoriteView.git