仿PC端12306的刷新loading動畫的自定義view

想法由來

大家肯定都在PC端的12306網站上買過票吧,最近的這兩天又去買票的時候留意到這個刷新loading,覺得還是挺好看的,所以就想動手實現下,順便把自定義view和動畫的知識稍微再熟悉一遍。

原12306的刷新圖:
原12306的刷新圖
實現的效果圖如下:
實現後的效果圖

自定義view的代碼如下(暫且就叫PassView吧)
package com.example.yanxu.loading.view;

import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.LinearInterpolator;

import com.example.yanxu.loading.R;


/**
 * Created by chen on 2018/12/12.
 * 仿PC端12306的刷新loading動畫的自定義view
 */

public class PassView extends View {

    private int mColor = Color.RED;
    private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private int mWidth, mHeight;
    private float mRadius = 0f;
    private ValueAnimator animator;
    private int theCircle = -1;//當前哪個圓球變化

    public PassView(Context context) {
        this(context, null);
    }

    public PassView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public PassView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(attrs);
    }

    //初始化
    private void init(AttributeSet attrs) {
        TypedArray typedArray = getContext().obtainStyledAttributes(attrs, R.styleable.PassView);
        if (typedArray != null) {
            mColor = typedArray.getColor(R.styleable.PassView_pass_color, Color.RED);
            mRadius = typedArray.getDimension(R.styleable.PassView_pass_radius, 0);
            typedArray.recycle();
        }
        mWidth = (int) (2 * mRadius);
        mHeight = (int) (2 * mRadius);
    }

    /**
     * 重寫onMeasure方法,解決wrap_content問題,因爲:
     * 直接繼承View的自定義控件需要重寫onMeasure方法並設置wrap_content時的自身大小,否則在佈局中使用wrap_content就相當於使用match_parent
     * 這裏的view默認寬高爲了方便直接指定爲小圓的直徑
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
        if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(mWidth, mHeight);
        } else if (widthSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(mWidth, heightSpecSize);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(widthSpecSize, mHeight);
        }
    }

    /**
     * 重寫onDraw方法實現自己的想要的效果,同時處理padding問題,因爲:
     * 繪製的時候需要考慮到View四周的空白,即padding,否則計算會有偏差
     */
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int pLeft = getPaddingLeft();
        int pRight = getPaddingRight();
        int pTop = getPaddingTop();
        int pBottom = getPaddingBottom();
        int width = getWidth() - pLeft - pRight;
        int height = getHeight() - pTop - pBottom;
        int mHLength = 150;//大圓的半徑
        mPaint.setColor(mColor);
        mPaint.setTextSize(50);
        //1、動畫開啓前,theCircle的初始值爲-1,所以初始化時只走canvas.drawCircle()方法,即所有位置均爲藍色
        //2、動畫開啓後,theCircle=0時不改變顏色,即所有位置均爲藍色
        //3、繼續,theCircle=1、i=2時,第一個位置依舊爲藍色,第二個及剩餘其他位置爲灰色
        //4、繼續,theCircle=2,i=3時,第一、二個位置依舊爲藍色,第三個及剩餘其他位置爲灰色
        //5、......
        //6、theCircle=11,i=12時,第一到十一個位置全部爲藍色,剩餘的第十二個爲灰色
        //7、直到,theCircle=0,循環繼續
        //注:思路重點爲,Paint使用的是同一個,所以在重新setColor()前的顏色是一樣的,setColor()後的顏色是另一樣的
        for (int i = 1; i <= 12; i++) {
            if (theCircle + 1 == i && theCircle != 0) {
                mPaint.setColor(getResources().getColor(R.color.colorPassGray));
            }
            canvas.drawCircle(width / 2 + (float) Math.sin(Math.PI * (i - 1) / 6) * mHLength,
                    height / 2 - (float) Math.cos(Math.PI * (i - 1) / 6) * mHLength, mRadius, mPaint);
            //Math.PI即爲π,等於180度
        }
    }

    //開啓動畫
    public void start() {
        if (animator != null)
            animator.cancel();
        animator = ValueAnimator.ofInt(0, 12);
        animator.setDuration(2000);//動畫時長
        animator.setRepeatMode(ValueAnimator.RESTART);//動畫的重複次數
        animator.setRepeatCount(ValueAnimator.INFINITE);//動畫的重複模式
        animator.setInterpolator(new LinearInterpolator());//線性插值器:勻速動畫
        //監聽動畫過程
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                //獲取當前動畫的進度值,此處的theCircle值爲(0、1、2...11、0)
                theCircle = ((int) animation.getAnimatedValue() % 12);
                invalidate();
            }
        });
        animator.start();
    }

}
在說具體的實現思路前,這裏先回顧下自定義view的流程:

對於繼承自View的自定義view而言,首先我們肯定是要重寫onDraw()方法通過代碼邏輯來實現具體的效果,如果想要支持wrap_content,那麼還需要重寫onMeasure()方法,既然是自定義view,那麼我們免不了需要添加自定義的屬性,概括下來具體流程如下:

  1. 第一步,在values目錄下創建自定義屬性的XML(如attrs.xml),並聲明自定義屬性集合,本例爲“PassView”
<?xml version="1.0" encoding="utf-8"?>
<resources>

    <declare-styleable name="PassView">
        <attr name="pass_color" format="color" />
        <attr name="pass_radius" format="dimension" />
    </declare-styleable>

</resources>
  1. 第二步,在view的構造方法中去解析自定義屬性的值
    //初始化
    private void init(AttributeSet attrs) {
        TypedArray typedArray = getContext().obtainStyledAttributes(attrs, R.styleable.PassView);
        if (typedArray != null) {
            mColor = typedArray.getColor(R.styleable.PassView_pass_color, Color.RED);
            mRadius = typedArray.getDimension(R.styleable.PassView_pass_radius, 0);
            typedArray.recycle();
        }
        mWidth = (int) (2 * mRadius);
        mHeight = (int) (2 * mRadius);
    }
  1. 第三步,在佈局文件中使用自定義的view
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".ui.MainActivity">

    <com.example.yanxu.loading.view.PassView
        android:id="@+id/pv_test"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:visibility="visible"
        app:pass_color="@color/colorPassLight"
        app:pass_radius="5dp" />

</LinearLayout>
說完了自定義view的流程,緊接着再簡單說下我的思路,主要的涉及到核心代碼的地方都加了註釋,本身邏輯也不是特別複雜,相信大家應該都能看的明白。
  1. 首先是先畫出十二個小圓球;
  2. 實現讓當前小圓球從灰色變成藍色的狀態;
  3. 依次循環繪畫,實現動畫效果。
1、首先是先畫出十二個小圓球

畫的有點醜,湊合看下吧。。
如圖,十二個小圓球均勻分佈在大圓的周邊,我們定義大圓的中心點爲該控件的中心點(width / 2 , height / 2),則圖中那個小圓球的座標爲如圖手寫所示,其他幾個小圓球的座標只要改變爲對應的角度即可畫出所有的小圓球:

for (int i = 1; i <= 12; i++) {
            canvas.drawCircle(width / 2 + (float) Math.sin(Math.PI * (i - 1) / 6) * mHLength,
                    height / 2 - (float) Math.cos(Math.PI * (i - 1) / 6) * mHLength, mRadius, mPaint);
            //Math.PI即爲π,等於180度
        }
2、實現讓當前小圓球從灰色變成藍色的狀態

這裏我們可以使用屬性動畫,讓ValueAnimator對象的變化範圍爲ValueAnimator.ofInt(0, 12),勻速循環播放

        animator = ValueAnimator.ofInt(0, 12);
        animator.setDuration(2000);//動畫時長
        animator.setRepeatMode(ValueAnimator.RESTART);//動畫的重複次數
        animator.setRepeatCount(ValueAnimator.INFINITE);//動畫的重複模式
        animator.setInterpolator(new LinearInterpolator());//線性插值器:勻速動畫
3、依次循環繪畫,實現動畫效果

這裏通過監聽動畫過程,獲取當前動畫的進度值,即繪製到哪一個小圓球,通過監聽動畫過程,我們可得到此處的theCircle值的變化爲(0、1、2…11、0)

        //監聽動畫過程
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                //獲取當前動畫的進度值,此處的theCircle值爲(0、1、2...11、0)
                theCircle = ((int) animation.getAnimatedValue() % 12);
                invalidate();
            }
        });

然後我們進行依次繪製的過程,具體如下:

  1. 動畫開啓前,theCircle的初始值爲-1,所以初始化時只走canvas.drawCircle()方法,即所有位置均爲藍色
  2. 動畫開啓後,theCircle=0時不改變顏色,即所有位置均爲藍色
  3. 繼續,theCircle=1、i=2時,第一個位置爲藍色,第二個及剩餘其他位置爲灰色
  4. 繼續,theCircle=2,i=3時,第一、二個位置爲藍色,第三個及剩餘其他位置爲灰色
  5. theCircle=11,i=12時,第一到十一個位置全部爲藍色,剩餘的第十二個爲灰色
  6. 直到,theCircle=0,循環繼續

注:思路重點爲,Paint使用的是同一個,所以在重新setColor()前的顏色是一樣的,setColor()後的顏色是另一樣的

        mPaint.setColor(mColor);
        mPaint.setTextSize(50);
        for (int i = 1; i <= 12; i++) {
            if (theCircle + 1 == i && theCircle != 0) {
                mPaint.setColor(getResources().getColor(R.color.colorPassGray));
            }
            canvas.drawCircle(width / 2 + (float) Math.sin(Math.PI * (i - 1) / 6) * mHLength,
                    height / 2 - (float) Math.cos(Math.PI * (i - 1) / 6) * mHLength, mRadius, mPaint);
            //Math.PI即爲π,等於180度
        }

總結:以上就是我的具體實現方案以及代碼邏輯,整個過程涉及到自定義view以及屬性動畫的使用,應該還有更好的實現方案,還望指正。個人覺得有些效果真的很有必要去親自代碼邏輯實現下,開始看到這個效果的時候覺得很簡單,但是真正實現起來有些細節部分還是需要仔細想想的,而且在實現的過程中也是一個知識鞏固的過程~

demo已經上傳至github,需要的小夥伴可以看一下–>傳送門

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章