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

2017年5月2日 星期二

Android: CircleProgress的實作

前言

這次練習做了一個簡單的Circle Progress,產生幾個圓形,再利用Animator物件幫我們達到持續旋轉的效果,效果如下。



以下我歸納為幾個實作步驟:
  1. 初始化
  2. 於量測階段(onMeasure)設定大小
  3. 於大小確定階段設置基本資源和動畫
  4. 於繪製階段繪製所需圓形

初始化

初始化就如同先前的篇章提到的方式,若你希望提供更多的屬性來客製化,就設定自己所需要的屬性資源即可,這邊我直接使用簡單的方式,直接設定單一顏色即可。

private void init() {
        this.viewWidth = 150;
        this.viewHeight = 150;
        this.color = Color.parseColor("#FF4081");
        this.numberOfCircle = 6;
        this.rotates = new float[this.numberOfCircle];
}

CircleProgress的寬高就直接設置150、顏色固定為粉紅色,本身的圓圈和後面跟隨的小圓圈共有六個,當然你可以提供客製化需求,自定義attrs的設定即可。

於量測階段(onMeasure)設定大小

onMeasure這個方法是View的生命週期(LifeCycle)之一,其實相似於Activity或Fragment的生命週期,就是一個View從無到繪製出來的一個過程,然而onMeasure是在View初始化時,需要先對我們的佈局及當中的子視圖進行量測的一個階段,我們在這個階段可以取得量測的大小,或是直接設定我們所要的尺寸,以下我直接將viewWidth=150和viewHeight=150透過setMeasuredDimension方法存取,程式碼如下。

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        final int measuredWidth = resolveSize(this.viewWidth, widthMeasureSpec);
        final int measuredHeight = resolveSize(this.viewHeight, heightMeasureSpec);
        this.setMeasuredDimension(measuredWidth, measuredHeight);
}

於大小確定階段設置基本資源和動畫

onLayout階段則是佈局與子視圖都已經確定其大小和位置的階段,這時我們就可以進行我們圓形繪製的前置準備,程式碼如下。

@Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        this.initLayoutSize();
        this.initCircles();
        this.prepareAnim();
}

private void initLayoutSize() {
        this.onLayoutWidth = this.getWidth();
        this.onLayoutHeight = this.getHeight();
        this.center = new PointF(this.onLayoutWidth / 2.0f, this.onLayoutHeight / 2.0f);
}

private void initCircles() {
        final float size = Math.min(this.onLayoutWidth, this.onLayoutHeight);
        final float circleRadius = size / 10.0f;
        this.circles = new Circle[this.numberOfCircle];

        for (int i = 0; i < this.numberOfCircle; i++) {
            this.circles[i] = new Circle();
            this.circles[i].setCenter(this.center.x, circleRadius);
            this.circles[i].setColor(this.color);
            this.circles[i].setRadius(circleRadius - circleRadius * i / 6);
        }
}

private void prepareAnim() {
        for (int i = 0; i < this.numberOfCircle; i++) {
            final int index = i;

            ValueAnimator fadeAnimator = ValueAnimator.ofFloat(360, 0, 0, 360);
            fadeAnimator.setRepeatCount(ValueAnimator.INFINITE);
            fadeAnimator.setDuration(2500);
            fadeAnimator.setStartDelay(index * 100);
            fadeAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {

                @Override
                public void onAnimationUpdate(ValueAnimator animation) {
                    rotates[index] = (float) animation.getAnimatedValue();
                    reDraw();
                }
            });

            fadeAnimator.start();
        }
}

private void reDraw() {
        this.invalidate();
}

於繪製階段繪製所需圓形

最後一步則是於onDraw階段繪製我們所有的圓形即可,程式碼如下。

private void drawGraphic(Canvas canvas) {
        for (int i = 0; i < this.numberOfCircle; i++) {
            canvas.save();
            canvas.rotate(this.rotates[i], this.center.x, this.center.y);
            this.circles[i].draw(canvas);
            canvas.restore();
        }
}

Source Code

2017年4月24日 星期一

Android: 會呼吸跳動的聚焦效果

前言

上一篇藉由step的方式,來達到圖形自動增減形成呼吸的跳動效果,這次稍微把程式碼調整加上動畫效果,練習做成一個會呼吸跳動的聚焦效果,效果如下。


以下我歸納為幾個實作步驟:
  1. 初始化
  2. 紀錄HighlightView的相關資料
  3. 動態添加一層可點擊的FrameLayout
  4. 製作HighlightView
  5. 加入進場和退場動畫特效

初始化

初始化分為兩部分,一個是初始欲聚焦視圖的資源,我們可以從Activity傳入所需的配置,像是欲聚焦視圖、聚焦的形狀和疊加上去的背景顏色,程式碼如下。

private void initMemberVariables(Activity activity, View focusedOnView, int highlightShape, int backgroundColor) {
        this.activity = activity;
        this.focusedOnView = focusedOnView;
        this.highlightShape = (highlightShape == 0 ? SHAPE_CIRCLE : SHAPE_RECT);
        this.backgroundColor = backgroundColor != 0 ? this.activity.getResources().getColor(backgroundColor) : this.activity.getResources().getColor(R.color.default_color_CC000000);
}

第二則是儲存手機的寬高,並且取得其中心點的XY值,程式碼如下。

private void initScreenResources() {
        DisplayMetrics displayMetrics = new DisplayMetrics();
        this.activity.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);

        int deviceWidth = displayMetrics.widthPixels;
        int deviceHeight = displayMetrics.heightPixels;

        this.centerX = deviceWidth / 2;
        this.centerY = deviceHeight / 2;
}

紀錄HighlightView的相關資料

初始化完成後,我們可以記錄一些關於聚焦視圖的資料,像是聚焦視圖的XY值、寬度、高度、繪製圖形的半徑,程式碼如下。
  • 聚焦視圖的XY值可以用getLocationInWindow()方法取得,如此一來,我們就能快速得知聚焦圖形需要繪製在什麼位置,程式碼如下。
int[] viewPoints = new int[2];
focusedOnView.getLocationInWindow(viewPoints);

  • 聚焦視圖的寬高,可以直接利用getWidth()與getHeight()兩個方法取得,程式碼如下。

  • this.focusedOnViewWidth = focusedOnView.getWidth();
    this.focusedOnViewHeight = focusedOnView.getHeight();
    


  • 繪製圖形的半徑要比原本聚焦的視圖來得大一些,所以可以使用聚焦視圖對角線的一半作為半徑,當然你也可以用聚焦視圖的一半,再往外加一點也行。。

  • this.focusedOnViewRadius = (int) (Math.hypot(this.focusedOnViewWidth, this.focusedOnViewHeight) / 2);
    

    動態添加一層可點擊的FrameLayout

    因為我們需要在原本的Acitivity上疊加一層,所以這邊我們動態加入一層FrameLayout,程式碼如下。

    this.highlightContainer = new FrameLayout(this.activity);
    this.highlightContainer.setTag(HIGHLIGHT_CONTAINER_TAG);
    this.highlightContainer.setClickable(true);
    this.highlightContainer.setOnClickListener(this);
    this.highlightContainer.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
    this.mainViewGroup.addView(this.highlightContainer);
    

    製作HighlightView

    我們要客製一個聚焦的視圖,除了基本的繪圓和繪方外,還要用到上篇文章提到的方式,才能讓聚焦視圖產生呼吸跳動的效果,程式碼如下。

    @Override
    protected void onDraw(Canvas canvas) {
            super.onDraw(canvas);
    
            if (this.bitmap == null) {
                this.bitmap = Bitmap.createBitmap(this.getWidth(), this.getHeight(), Bitmap.Config.ARGB_8888);
                this.bitmap.eraseColor(this.backgroundColor);
            }
    
            canvas.drawBitmap(this.bitmap, 0, 0, this.backgroundPaint);
    
            if (this.renderHelper.isFocus()) {
                this.animCounter = this.animCounter + this.step;
    
                if (this.renderHelper.getHighlightShape() == ViewManager.SHAPE_CIRCLE) {
                    this.drawCircle(canvas);
                } else {
                    this.drawRoundedRect(canvas);
                }
    
                if (this.animCounter == ANIM_COUNTER_MAX) {
                    this.step = -1;
                } else if (this.animCounter == 0) {
                    this.step = 1;
                }
            }
    
            this.postInvalidate();
    }
    

    加入進場和退場動畫特效

    我們要讓Framlayout產生正確的動畫,所以需要取得正確的寬高,所以須透過getViewTreeObserver()方法,取得正確的數值,才能順利完成動畫效果;而退場效果因為已經繪製完成,所以無需透過該方法取得,可以直接利用getWidth()和getHeight()兩個方法,程式碼如下。
    private void enterAnimation() {
            final int revealRadius = (int) Math.hypot(highlightContainer.getWidth(), highlightContainer.getHeight());
            int startRadius = 0;
    
            if (focusedOnView != null) {
                startRadius = focusedOnView.getWidth() / 2;
            }
    
            Animator enterAnimator = ViewAnimationUtils.createCircularReveal(highlightContainer, centerX, centerY, startRadius, revealRadius);
            enterAnimator.setDuration(1000);
            enterAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
            enterAnimator.start();
    
            this.highlightContainer.getViewTreeObserver().addOnPreDrawListener(
                    new ViewTreeObserver.OnPreDrawListener() {
                        @Override
                        public boolean onPreDraw() {
                            highlightContainer.getViewTreeObserver().removeOnPreDrawListener(this);
    
                            final int revealRadius = (int) Math.hypot(highlightContainer.getWidth(), highlightContainer.getHeight());
                            int startRadius = 0;
    
                            if (focusedOnView != null) {
                                startRadius = focusedOnView.getWidth() / 2;
                            }
    
                            Animator enterAnimator = ViewAnimationUtils.createCircularReveal(highlightContainer, centerX, centerY, startRadius, revealRadius);
                            enterAnimator.setDuration(1000);
                            enterAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
                            enterAnimator.start();
    
                            return false;
                        }
                    });
    }
    

    private void exitAnimation() {
            final int revealRadius = (int) Math.hypot(highlightContainer.getWidth(), highlightContainer.getHeight());
    
            Animator exitAnimator = ViewAnimationUtils.createCircularReveal(highlightContainer, centerX, centerY, revealRadius, 0f);
            exitAnimator.setDuration(300);
            exitAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
            exitAnimator.addListener(new AnimatorListenerAdapter() {
                @Override
                public void onAnimationEnd(Animator animation) {
                    mainViewGroup.removeView(highlightContainer);
                }
            });
            exitAnimator.start();
    }
    

    Source Code

    2017年4月20日 星期四

    Android: 製作Heart Beat視圖

    前言

    做了一個心跳的效果動畫,名為HeartBeatView,不靠Animation或Animator即可達到的客製化視圖,當中還包括可以直接在xml設定其速度和跳動幅度,效果如下:


    這邊我歸納幾個實現的步驟:
    1. 定義HeartBeatView的屬性(declare-styleaqle)
    2. 實作HeartBeatView
    3. 初始化資源檔內容與Paint物件
    4. 繪製愛心
    5. 繪製跳動與幅度

    定義HeartBeatView的屬性(declare-styleaqle) 

    <resources>
        <declare-styleable name="HeartBeatImageView">
            <attr name="animMoveFactor" format="float" />
            <attr name="animCounter" format="integer" />
            <attr name="heartColor" format="color" />
        </declare-styleable>
    </resources>
    

    實作HeartBeatView

    public class HeartBeatImageView extends View {
    }
    

    初始化資源檔內容與Paint物件

    這邊則是在呼叫HeartBeatView的建構子時,順便將我們在xml裡設定的屬性給帶進來(要是你有使用到的話!),可以透過AttributeSet這個參數獲取其在xml設定的內容,程式碼如下。

        public HeartBeatImageView(Context context) {
            this(context, null);
        }
    
        public HeartBeatImageView(Context context, AttributeSet attrs) {
            this(context, attrs, 0);
        }
    
        public HeartBeatImageView(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            this.initParams(context, attrs);
            this.initPaint();
        }
    
        private void initParams(Context context, AttributeSet attrs) {
            TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.HeartBeatImageView);
            this.animMoveFactor = typedArray.getFloat(R.styleable.HeartBeatImageView_animMoveFactor, DEFAUT_ANIM_MOVE_FACTOR);
            this.animCounter = typedArray.getInt(R.styleable.HeartBeatImageView_animCounter, DEFAULT_ANIM_COUNTER);
            this.heartColor = typedArray.getColor(R.styleable.HeartBeatImageView_heartColor, DEFAULT_COLOR);
            typedArray.recycle();
        }
    
        private void initPaint() {
            this.redCirclePaint = new Paint();
            this.redCirclePaint.setAntiAlias(true);
            this.redCirclePaint.setColor(this.heartColor);
        }
    

    繪製愛心

    先繪製一個正方形,再加上兩個圓形,並將之轉成45度即可,程式碼如下。

            int centerX = canvas.getWidth() / 2;
            int centerY = canvas.getHeight() / 2;
            float radius = this.getCircleRadius(canvas.getWidth() / 6, this.animCounter, this.animMoveFactor);
    
            canvas.rotate(45, centerX, centerY);
    
            canvas.drawRect(centerX - radius, centerY - radius, centerX + radius, centerY + radius, this.redCirclePaint);
            canvas.drawCircle(centerX - radius, centerY, radius, this.redCirclePaint);
            canvas.drawCircle(centerX, centerY - radius, radius, this.redCirclePaint);
    

    繪製跳動與幅度

    這邊是透過animCounter與step這兩個參數來控制增減,並期重新更新radius數值,程式碼如下。


        private void drawHeart(Canvas canvas) {
            this.animCounter = this.animCounter + this.step;
    
            int centerX = canvas.getWidth() / 2;
            int centerY = canvas.getHeight() / 2;
            float radius = this.getCircleRadius(canvas.getWidth() / 6, this.animCounter, this.animMoveFactor);
    
            canvas.rotate(45, centerX, centerY);
    
            canvas.drawRect(centerX - radius, centerY - radius, centerX + radius, centerY + radius, this.redCirclePaint);
            canvas.drawCircle(centerX - radius, centerY, radius, this.redCirclePaint);
            canvas.drawCircle(centerX, centerY - radius, radius, this.redCirclePaint);
    
            if (this.animCounter >= ANIM_COUNTER_MAX) {
                this.step = -1;
            } else if (this.animCounter <= 0) {
                this.step = 1;
            }
        }
    
        private float getCircleRadius(int radius, int animCounter, double animFactor) {
            return (float) (radius + animCounter * animFactor);
        }
    

    2017年4月17日 星期一

    Android: 下雪撒圖的效果製作

    前言

    由於想研究關於UI方面的議題,所以最近試著寫一個客製化的FrameLayout,可以顯示撒圖的效果,這篇文章主要就是介紹概念與一些作法。這裡我製作了一個名為SnowEffectFrameLayout的客製化PercentFrameLayout,這個Layout主要是用來控制圖片的動畫與生成,當中還包括一些參數的設定。我們先來看一下實際效果,效果如下:


    這邊我歸納幾個實現的步驟:
    1. 定義SnowEffectFrameLayout的屬性(declare-styleaqle)
    2. 實作SnowEffectFrameLayout
    3. 初始化資源檔內容
    4. 初始化圖片物件池
    5. 設定每張圖片的TranslateAnimation

    定義SnowEffectFrameLayout的屬性(declare-styleaqle)

    <resources>
        <declare-styleable name="SnowEffectFrameLayout">
            <attr name="snowBasicCount" format="integer" />
            <attr name="dropAverageDuration" format="integer" />
            <attr name="isRotation" format="boolean" />
        </declare-styleable>
    </resources>
    

    實作SnowEffectFrameLayout

    首先讓SnowEffectFrameLayout繼承PercentFrameLayout,主要是讓每個圖片能夠利用PercentFrameLayout的百分比特性,均勻地分布在螢幕的X軸上,至於什麼是PercentFrameLayout,其實就是透過百分比來控制子視圖的大小,程式碼如下。

    # Gradle: dependencies

    compile 'com.android.support:percent:25.3.1'

    # Java

    public class SnowEffectFrameLayout extends PercentFrameLayout {
    }
    

    初始化資源檔內容

    這邊則是在呼叫SnowEffectFrameLayout的建構子時,順便將我們在xml裡設定的屬性給帶進來(要是你有使用到的話!),可以透過AttributeSet這個參數獲取其在xml設定的內容,程式碼如下。

    private void init(Context context, AttributeSet attrs) {
            TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.SnowEffectFrameLayout);
            this.snowBasicCount = typedArray.getInteger(R.styleable.SnowEffectFrameLayout_snowBasicCount, DEFAULT_SNOW_BASIC_COUNT);
            this.dropAverageDuration = typedArray.getInteger(R.styleable.SnowEffectFrameLayout_dropAverageDuration, DEFAULT_DROP_AVERAGE_DURATION);
            this.isRotation = typedArray.getBoolean(R.styleable.SnowEffectFrameLayout_isRotation, DEFAULT_IS_ROTATION);
            this.dropFrequency = DEFAULT_DROP_FREQUENCY;
    
            this.snowList = new ArrayList<>();
            if ((this.snowList == null || this.snowList.size() == 0)) {
                this.snowList.add(ContextCompat.getDrawable(this.getContext(), R.drawable.snow));
            }
    }
    

    初始化圖片物件池

    為了要能重複利用圖片,這邊初始了一個物件池,用來存放這些大量的視圖,並且不用每次重新產生,造成資源的浪費,程式碼如下。

    private void initSnowPool() {
            final int snowCount = this.snowList.size();
            if (snowCount == 0) {
                throw new IllegalStateException("There are no drawables.");
            }
    
            this.cleanSnowPool();
    
            final int expectedMaxSnowCountOnScreen = (int) ((1 + RELATIVE_DROP_DURATION_OFFSET) * snowBasicCount * dropAverageDuration / ((float) dropFrequency));
            this.snowPool = new Pools.SynchronizedPool<>(expectedMaxSnowCountOnScreen);
            for (int i = 0; i < expectedMaxSnowCountOnScreen; i++) {
                final ImageView snow = this.generateSnowImage(this.snowList.get(i % snowCount));
                this.addView(snow, 0);
                this.snowPool.release(snow);
            }
    
            RandomTool.setSeed(10);
    }
    

    設定每張圖片的TranslateAnimation

    最後就是透過TranslateAnimation讓每張圖片呈現撒落的感覺,再結合RotateAnimation在撒落同時能有旋轉的效果,程式碼如下。

    private void startDropAnimationForSingleSnow(final ImageView snow) {
            final int currentDuration = (int) (this.dropAverageDuration * RandomTool.floatInRange(1, RELATIVE_DROP_DURATION_OFFSET));
    
            final AnimationSet animationSet = new AnimationSet(false);
            final TranslateAnimation translateAnimation = new TranslateAnimation(
                    Animation.RELATIVE_TO_SELF, 0,
                    Animation.RELATIVE_TO_SELF, RandomTool.floatInRange(0, 5),
                    Animation.RELATIVE_TO_PARENT, 0,
                    Animation.ABSOLUTE, this.windowHeight);
    
            if (this.isRotation) {
                final RotateAnimation rotateAnimation = new RotateAnimation(
                        0,
                        RandomTool.floatInRange(0, 360),
                        Animation.RELATIVE_TO_SELF,
                        0.5f,
                        Animation.RELATIVE_TO_SELF,
                        0.5f);
                animationSet.addAnimation(rotateAnimation);
            }
    
            animationSet.addAnimation(translateAnimation);
            animationSet.setDuration(currentDuration);
            animationSet.setAnimationListener(new Animation.AnimationListener() {
                @Override
                public void onAnimationStart(Animation animation) {
                }
    
                @Override
                public void onAnimationEnd(Animation animation) {
                    snowPool.release(snow);
                }
    
                @Override
                public void onAnimationRepeat(Animation animation) {
                }
            });
    
            snow.startAnimation(animationSet);
        }
    

    以上還有些小細節沒有交代,就是透過亂數的方式製造位置和動畫速度不一的落差,但這邊不多做贅言,實際的程式碼則在GitHub上,有興趣的人可以參考看看。

    Source Code

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


    2016年12月8日 星期四

    Testing: Android的單元測試相關內容

    前言

    因為自己在學習單元測試及閱讀相關書籍的過程中,總覺得資料很多,但觀念都很分散,無法很有系統地瞭解單元測試議題,所以在自我學習後,我練習一些例子幫助我瞭解單元測試,所以將這些內容有系統並循序漸進地整理起來。

    相關觀念


    框架使用


    Testing: 隔離框架(Isolation framework) - 使用PowerMock驗證靜態方法

    前言

    Testing: 隔離框架(Isolation framework) - 使用Mockito建立測試替身一文中,我們練習如何使用Mockito框架模擬測試替身後,接著要來學習PowerMock的框架,輔助我們測試與驗證靜態方法。

    PowerMock的使用

    PowerMock使用起來相當簡單,首先我們在gradle中引用PowerMock,程式碼如下。

    dependencies {
        testCompile 'org.powermock:powermock-module-junit4:1.6.2'
        testCompile 'org.powermock:powermock-api-mockito:1.6.2'
    }
    

    以RegisterManager為例

    • RegisterManager物件
    public class RegisterManager {
        private IEmailRegister emailRegister;
        private IMobileRegister mobileRegister;
    
        public RegisterManager(IEmailRegister emailRegister, IMobileRegister mobileRegister) {
            this.emailRegister = emailRegister;
            this.mobileRegister = mobileRegister;
        }
    
        public void register(String content) {
            if (RegistrationFormat.checkFormat(content)) {
                this.emailRegister.emailRegister();
            } else {
                this.mobileRegister.mobileRegister();
            }
        }
    }
    • RegistrationFormat類別
    public class RegistrationFormat {
    
        public static boolean checkFormat(String content) {
            boolean isEmail = false;
    
            if (content.contains("@")) {
                return isEmail;
            }
    
            return !isEmail;
        }
    }
    • IEmailRegister介面
    public interface IEmailRegister {
        void emailRegister();
    }
    • IMobileRegister介面
    public interface IMobileRegister {
        void mobileRegister();
    }
    

    RegisterManager的功能相當簡單,它提供了Email與Mobile這兩種註冊方法,透過RegistrationFormat類別的靜態方法檢查,若是屬於Email格式,就使用Email註冊,Mobile註冊則反之,接著我們來看測試程式。

    @RunWith(PowerMockRunner.class)
    @PrepareForTest(RegistrationFormat.class)
    public class RegisterManagerWithPowerMockitoTest {
    
        @Test
        public void testRegisterIsEmail() throws Exception {
            // Arrange
            IEmailRegister mockEmailRegister = Mockito.mock(IEmailRegister.class);
            IMobileRegister mockMobileRegister = Mockito.mock(IMobileRegister.class);
            PowerMockito.mockStatic(RegistrationFormat.class);
            Mockito.when(RegistrationFormat.checkFormat(Mockito.anyString())).thenReturn(true);
            RegisterManager registerManager = new RegisterManager(mockEmailRegister, mockMobileRegister);
    
            // Act
            registerManager.register(Mockito.anyString());
    
            // Assert
            Mockito.verify(mockEmailRegister).emailRegister();
        }
    
        @Test
        public void testRegisterIsMobile() throws Exception {
            // Arrange
            IEmailRegister mockEmailRegister = Mockito.mock(IEmailRegister.class);
            IMobileRegister mockMobileRegister = Mockito.mock(IMobileRegister.class);
            PowerMockito.mockStatic(RegistrationFormat.class);
            Mockito.when(RegistrationFormat.checkFormat(Mockito.anyString())).thenReturn(false);
            RegisterManager registerManager = new RegisterManager(mockEmailRegister, mockMobileRegister);
    
            // Act
            registerManager.register(Mockito.anyString());
    
            // Assert
            Mockito.verify(mockMobileRegister).mobileRegister();
        }
    
        @Test
        public void testRegistrationFormatCheckFormatIsCalled() throws Exception {
            // Arrange
            IEmailRegister mockEmailRegister = Mockito.mock(IEmailRegister.class);
            IMobileRegister mockMobileRegister = Mockito.mock(IMobileRegister.class);
            PowerMockito.mockStatic(RegistrationFormat.class);
            Mockito.when(RegistrationFormat.checkFormat(Mockito.anyString())).thenReturn(false);
            RegisterManager registerManager = new RegisterManager(mockEmailRegister, mockMobileRegister);
    
            // Act
            registerManager.register(Mockito.anyString());
    
            // Assert
            PowerMockito.verifyStatic(Mockito.times(1));
            RegistrationFormat.checkFormat(Mockito.anyString());
        }
    }
    

    要使用PowerMock首先要依循以下方式:
    • @RunWith(PowerMockRunner.class): 使用@RunWith的Annotation,並且類別指定PowerMockRunner.class。
    • @PrepareForTest(RegistrationFormat.class): 使用@PrepareForTest的Annotation,聲明要Mock的靜態方法類別,這邊指定RegistrationFormat.class。
    • PowerMockito.mockStatic(RegistrationFormat.class): 使用mockStatic方法Mock靜態類別

    接著預期我們的測試程式,可以使用下列方法。
    • Mockito.when(RegistrationFormat.checkFormat(Mockito.anyString())).thenReturn(false): 如同Mockito的方法,我們指定RegistrationFormat使用checkFormat的靜態方法時,所欲回傳的值。
    • PowerMockito.verifyStatic(Mockito.times(1)): 先斷言該靜態方法執行的次數。
    • RegistrationFormat.checkFormat(Mockito.anyString()): 實際執行該方法。

    這邊需要注意的是,PowerMock斷言的方式有些奇怪,要先斷言再執行呼叫,值得注意喔!這邊也能順利通過測試,如下圖。


    小結

    我們都順利討論完隔離框架的使用啦!但目前為止,這些議題是個人學習單元測試上,覺得非常重要的觀念,希望對大家都有幫助,之後也會不定期更新相關議題,若有任何意見,也歡迎提供給我,謝謝大家!

    Source Code

    Github: https://github.com/xavier0507/UnitTestSample.git