1. 程式人生 > >android進階4step1:Android動畫處理與自定義View——自定義View

android進階4step1:Android動畫處理與自定義View——自定義View

為什麼要自定義控制元件

  1. 特定的顯示風格
  2. 處理特有的使用者互動
  3. 優化我們的佈局
  4. 封裝等...

如何自定義控制元件

  1. 自定義屬性的宣告與獲取
  2. 測量onMeasure
  3. 繪製onDraw
  4. 狀態的儲存與恢復

步驟一、自定義屬性宣告與獲取

  1. 分析需要的自定義屬性
  2. 在res/valus/attrs.xml定義宣告
  3. 在layout xml檔案中進行使用
  4. 在View的構造方法中進行獲取

實現步驟: 

1.新建TestView繼承View 實現其構造方法(下面只實現一個)

   public TestView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

2.在value目錄下新建 attrs.xml檔案(可以自定義名字) 自定義屬性

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!--宣告 name一般為自定義View的name-->
    <declare-styleable name="TestView">
        <!--字串-->
        <attr name="test_string" format="string"></attr>
        <!--畫素(px) 1dp=2px-->
        <attr name="test_dimension" format="dimension"></attr>
        <!--布林值-->
        <attr name="test_boolean" format="boolean"></attr>
        <!--整形-->
        <attr name="test_integer" format="integer"></attr>
        <!--列舉型別:當變數只有幾種固定的值時-->
        <attr name="test_enum" format="enum">
            <enum name="top" value="1"></enum>
            <enum name="bottom" value="2"></enum>
        </attr>

    </declare-styleable>
</resources>

3.在佈局檔案中使用自定義的佈局

在根部局中新增 myview 一般是app 也可以自定義

    xmlns:myview="http://schemas.android.com/apk/res-auto"

完整程式碼: 

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:myview="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.demo.customview.TestView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        myview:test_boolean="true"
        myview:test_enum="bottom"
        myview:test_integer="100"
        myview:test_dimension="100px"
        myview:test_string="哇哇哇"
        />

</RelativeLayout>

 4.在構造當中獲取屬性的值

方法1:直接通過typeArray 物件獲取

public class TestView extends View {

    public TestView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        //載入在attr中自定義控制元件的屬性
        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.TestView);
        //方法1:拿定義的屬性值(屬性name,預設值)
        boolean test_boolean = ta.getBoolean(R.styleable.TestView_test_boolean, false);
        float test_dimension = ta.getDimension(R.styleable.TestView_test_dimension, 100);
        String test_string = ta.getString(R.styleable.TestView_test_string);
        int test_integer = ta.getInteger(R.styleable.TestView_test_integer, 1);
        int test_enum = ta.getInt(R.styleable.TestView_test_enum, 1);
        Log.e("TAG", test_boolean + " , "
                + test_dimension + " , "
                + test_string + " , "
                + test_integer + " , "
                + test_enum);
        //回收掉
        ta.recycle();
    }

}

列印結果: 和在佈局檔案中設定的一致

12-10 12:55:06.213 1604-1604/? E/TAG: true , 100.0 , 哇哇哇 , 100 , 2

方法2:先獲取在佈局檔案中一共設定屬性的數目,如果有設定該屬性則負責,否則使用原來定義的值

     //方法2:區別方法1:例如設定預設字串 mText="def"
        //然後在 mText=ta.getString(R.styleable.TestView_test_string);
        //如果佈局檔案中沒有設定該屬性,則返回結果為null 原來的def就不見了
        //方法2可以實現如果沒有設定,則顯示預設的值
        //方法1如果想實現方法2的效果 也可以設定預設值

        //拿到在佈局檔案中設定的屬性個數,沒設定的不算
        int count = ta.getIndexCount();
        for (int i = 0; i < count; i++) {
            //拿到對應位置的屬性名稱
            int index = ta.getIndex(i);
            switch (index) {
                case R.styleable.TestView_test_string:
                    mText = ta.getString(R.styleable.TestView_test_string);
                    break;
            }

        }


        Log.e("TAG-2", test_boolean + " , "
                + test_dimension + " , "
                + mText + " , "
                + test_integer + " , "
                + test_enum);

佈局檔案修改:不定義test_string 屬性

<com.demo.customview.TestView
        android:layout_width="300dp"
        android:layout_height="300dp"
        android:layout_centerInParent="true"
        android:background="@color/colorPrimaryDark"
        myview:test_boolean="true"
        myview:test_enum="bottom"
        myview:test_integer="100"
        myview:test_dimension="100px"
        />

 列印:

  • 方法2:會根據佈局中是否設定了該屬性的值,如果有則賦值,如果沒有,則使用原來設定的值
  • 方法1:會自動更新ta.getString 的值,則為null
12-10 13:04:59.921 4315-4315/? E/TAG-1: true , 100.0 , null , 100 , 2
12-10 13:04:59.922 4315-4315/? E/TAG-2: true , 100.0 , def , 100 , 2

步驟二、自定義View的測量(onMeasure)

  1. EXACTLY(固定值 例如:100dp) , AT_MOST(最多不多過父佈局 wrap_content) , UNSPECIFIED (不確定 滑動佈局)
  2. MeasureSpec
  3. setMeasuredDimension
  4. requestLayout()(重新整理重新測量)

上面函式實現了:

  • 接受父控制元件傳入的高度 heightMeasureSpec
  • 通過MeasureSpec類獲取mode 和size
  • 如果是佈局設定為固定高度則直接返回size
  • 如果不是,如果是At_MOST 則需要取小於size的高度(不能大於父控制元件的size)返回

getMeasuredWidth和getWidth的區別

 

View的getWidth()和getMeasuredWidth()有什麼區別嗎?

View的高寬是由View本身和Parent容器共同決定的。
getMeasuredWidth()getWidth()分別對應於檢視繪製的measure和layout階段。

getMeasuredWidth()獲取的是View原始的大小,也就是這個View在XML檔案中配置或者是程式碼中設定的大小。getWidth()獲取的是這個View最終顯示的大小,這個大小有可能等於原始的大小,也有可能不相等。

比如說,在父佈局的onLayout()方法或者該View的onDraw()方法裡呼叫measure(0, 0),二者的結果可能會不同(measure中的引數可以自己定義)。

實現步驟:

1、重寫OnMeasure方法

  • MeasureSpec.EXACTLY:精確模式,尺寸的值是多少,那麼這個元件的長或寬就是多少
  • MeasureSpec.AT_MOST:最大模式:同時父控制元件給出一個最大空間,不能超過這個值
  • MeasureSpec.UNSPECIFIED:未指定模式,當前元件,可得到的空間不受限制
 @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

        /**
         * 寬度測量
         * 1.拿到mode
         * 2.判斷屬於哪種mode
         * 3.得到具體的測量值
         */
        //拿到父控制元件傳入寬度的mode和size
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        //設定測量寬度
        int width = 0;
        //如果設定的是確定的模式
        if (widthMode == MeasureSpec.EXACTLY) {
            //則測量的寬度為確定的寬度
            width = widthSize;
        } else {

            //所需要的寬度 如果有設定padding
            int needWidth = MeasureWidth() + getPaddingLeft() + getPaddingRight();
            if (widthMode == MeasureSpec.AT_MOST) {
                //取較小值 因為不能大於size
                needWidth = Math.min(needWidth, widthSize);
            } else {//否則就是UNSPECIFIED
                //你測量多大,就有多大
                width = needWidth;
            }

        }
        /**
         * 高度測量 同上
         */
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int height = 0;
        if (heightMode == MeasureSpec.EXACTLY) {
            height = heightSize;
        } else {
            int needHeight = MeasureHeight() + getPaddingBottom() + getPaddingTop();
            if (heightMode == MeasureSpec.AT_MOST) {
                height = Math.min(needHeight, heightSize);
            } else {
                height = needHeight;
            }
        }
        //設定測量畫素
        setMeasuredDimension(width, height);
    }


    /**
     * 返回空間的高
     * @return
     */
    private int MeasureHeight() {
        return 0;
    }
    /**
     * 返回空間的寬
     * @return
     */
    private int MeasureWidth() {

        return 0;
    }

步驟三、繪製onDraw

  • 1、繪製內容區域
  • 2、invalidate() , postInvalidate();
  • 3、Canvas.drawXXX
  • 4、translate、rotatescale、skew
  • 5、save()、restore()

具體步驟

重寫onDraw方法

  @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        initPaint();
        //畫一個圓形 以檢視的中心為圓點 半徑為寬度/2
        canvas.drawCircle(getWidth() / 2, getHeight() / 2, getWidth() / 2 - mPaint.getStrokeWidth() / 2, mPaint);
        //畫一條直線
        canvas.drawLine(0, getHeight() / 2, getWidth(), getHeight() / 2, mPaint);
        //畫一條直線
        canvas.drawLine(getWidth() / 2, 0, getWidth() / 2, getHeight(), mPaint);
        //畫字
           canvas.drawText(mText, 0, mText.length(), 0, getHeight() / 2, mPaint);

    }

    /**
     * 初始化畫筆
     */
    private void initPaint() {
        //1.定義畫筆
        mPaint = new Paint();
        //設定畫筆的style 空心的
        mPaint.setStyle(Paint.Style.STROKE);
        //設定畫筆顏色(紅色)
        mPaint.setColor(0xFFFF0000);
        //設定畫筆的大小
        mPaint.setStrokeWidth(6);
        //設定字型大小
        mPaint.setTextSize(100);
    }

效果:

 步驟4、狀態的儲存與恢復

首先把佈局的背景色去掉來進行測試

我們知道當檢視被中斷的時候再回來會被重建(例如螢幕旋轉 它會重新執行onCreate方法)

為了儲存在重建之前要儲存狀態,等oncreate之後拿到上一次儲存的狀態 就實現了狀態的儲存和恢復

  • 1、onSaveInstanceState
  • 2、onRestoreInstanceState

 例子:沒有進行狀態儲存之前 不會儲存

重寫以下方法:

   /**
     * 點選檢視更新畫面
     *
     * @param event
     * @return
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mText = "8888";
        //View 重繪 回撥onDraw
        invalidate();
        //返回true代
        return true;
    }


    /**
     * 狀態儲存
     *
     * @return
     */
    @Nullable
    @Override
    protected Parcelable onSaveInstanceState() {
        Bundle bundle = new Bundle();
        //控制元件的字串 之前定義的
        bundle.putString(KEY_TEXT, mText);
        //存父控制元件的狀態
        bundle.putParcelable(INSTANCE, super.onSaveInstanceState());
        return bundle;
    }

    /**
     * 狀態恢復
     *
     * @param state
     */
    @Override
    protected void onRestoreInstanceState(Parcelable state) {
        if (state instanceof Bundle) {
            Bundle b = (Bundle) state;
            //恢復父類的狀態
            Parcelable parcelable = b.getParcelable(INSTANCE);
            super.onRestoreInstanceState(parcelable);
            //恢復子類的狀態
            mText = b.getString(KEY_TEXT);
            return;
        }
        super.onRestoreInstanceState(state);
    }

invalidate();

View(非容器類) 呼叫invalidate方法只會重繪自身,ViewGrounp呼叫則會重繪整個View樹

之後:可以儲存了

如果你發現還是不可以儲存:那麼你一定是忘記給控制元件新增id了!! 要在xml佈局中新增id 因為系統是根據id來儲存狀態的

 結合上述四個步驟:實現案例


1、在attrs.xml檔案中新增 自定義RoundProgressBar的屬性

<?xml version="1.0" encoding="utf-8"?>
<resources>
     
    <declare-styleable name="RoundProgressBar">
        <attr name="color" format="color"></attr>
        <attr name="line_width" format="dimension"></attr>
        <attr name="radius" format="dimension"></attr>
        <!--也可以直接使用系統自帶的屬性-->
        <attr name="android:progress"></attr>
        <attr name="android:textSize"></attr>
    </declare-styleable>

    <!--宣告-->
    <declare-styleable name="TestView">
  .....
    </declare-styleable>
</resources>

2.佈局檔案

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:myview="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.demo.customview.RoundProgressBar
        android:id="@+id/progressbar"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:background="@color/colorPrimary"
        android:padding="10dp"
        android:layout_centerInParent="true" />

</RelativeLayout>

3.看一張圖片

然後解決高度問題:  參考:Android自定義View繪製真正的居中文字
先了解一下Android是怎麼樣繪製文字的,這裡涉及到幾個概念,分別是文字的top,bottom,ascent,descent,baseline。 
Android自定義View繪製真正的居中文字 
Baseline是基線,在android中

文字的繪製都是從Baseline處開始的Baseline往上至字元“最高處”的距離我們稱之為ascent(上坡度)

Baseline往下至字元“最低處”的距離我們稱之為descent(下坡度); 

leading(行間距)則表示上一行字元的descent到該行字元的ascent之間的距離;

Baseline是基線,Baseline以上是負值,以下是正值,因此 ascent是負值, descent是正值。

也可以通過

  • int a=mPaint.ascent() 拿到Ascent—Baseline的距離
  • int b=mPaint.desent()拿到Baseline—Desent的距離
  • b-a 即為Ascent——Desent的距離

4.程式碼實現(仔細看註解)

RoundProgressBar.java 自定義檢視


import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Bundle;
import android.os.Parcelable;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.View;

/**
 * <p>檔案描述:<p>
 * <p>作者:Mr-Donkey<p>
 * <p>建立時間:2018/12/10 12:31<p>
 * <p>更改時間:2018/12/10 12:31<p>
 * <p>版本號:1<p>
 */
public class RoundProgressBar extends View {
    //定義成員變數
    private int mRadius; //半徑
    private int mColor;//顏色
    private int mLineWidth;//線寬
    private int mTextSize;//字型大小
    private int mProgress;//進度

    private Paint mPaint;
    private RectF mProgressCicleRectf;
    private Rect bound;
    private int textHeight;
    private String text;

    public RoundProgressBar(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        //載入在attr中自定義控制元件的屬性
        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.RoundProgressBar);
        mRadius = (int) ta.getDimension(R.styleable.RoundProgressBar_radius, dp2px(30));
        mColor = ta.getColor(R.styleable.RoundProgressBar_color, 0xffff0000);
        mLineWidth = (int) ta.getDimension(R.styleable.RoundProgressBar_line_width, dp2px(3));
        mTextSize = (int) ta.getDimension(R.styleable.RoundProgressBar_android_textSize, dp2px(16));
        mProgress = ta.getInt(R.styleable.RoundProgressBar_android_progress, 30);
        //初始化畫筆
        initPaint();
        //回收掉
        ta.recycle();
    }

    /**
     * 將dp轉成px的方法
     *
     * @param dpVal
     * @return
     */
    private float dp2px(int dpVal) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dpVal, getResources().getDisplayMetrics());
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

        /**
         * 寬度測量
         * 1.拿到mode
         * 2.判斷屬於哪種mode
         * 3.得到具體的測量值
         */
        //拿到父控制元件傳入寬度的mode和size
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        //設定測量寬度
        int width = 0;
        //如果設定的是確定的模式
        if (widthMode == MeasureSpec.EXACTLY) {
            //則測量的寬度為確定的寬度
            width = widthSize;
        } else {

            //所需要的寬度 如果有設定padding
            int needWidth = MeasureWidth() + getPaddingLeft() + getPaddingRight();
            if (widthMode == MeasureSpec.AT_MOST) {
                //取較小值 因為不能大於size
                needWidth = Math.min(needWidth, widthSize);
            } else {//否則就是UNSPECIFIED
                //你測量多大,就有多大
                width = needWidth;
            }

        }
        /**
         * 高度測量 同上
         */
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int height = 0;
        if (heightMode == MeasureSpec.EXACTLY) {//指明寬度高度 width=xxdp
            height = heightSize;
        } else {
            int needHeight = MeasureHeight() + getPaddingBottom() + getPaddingTop();
            if (heightMode == MeasureSpec.AT_MOST) { //wrap_content時
                height = Math.min(needHeight, heightSize);
            } else {
                height = needHeight;
            }
        }
        //因為是圓形,假設使用者傳入寬高不等,肯定是不行的
        //所以取他們倆的最小值,保持寬高一定
        width = Math.min(width, height);
        //設定測量畫素
        setMeasuredDimension(width, width);
    }


    /**
     * 返回控制元件需要的高
     *
     * @return
     */
    private int MeasureHeight() {
        return mRadius * 2;
    }

    /**
     * 返回控制元件需要的寬
     *
     * @return
     */
    private int MeasureWidth() {

        return mRadius * 2;
    }

    /**
     * 先進行onSizeChanged 再ondraw
     *
     * @param w
     * @param h
     * @param oldw
     * @param oldh
     */
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        //內部圓形矩形區域
        mProgressCicleRectf = new RectF(0, 0, w - getPaddingLeft() - getPaddingRight(), h - getPaddingTop() - getPaddingBottom());
        //拿到字的高度
        bound = new Rect();

    }

    /**
     * 畫布繪製
     * ondraw方法中儘可能不要new物件和進行復雜的操作
     * 可以放到onSizeChanged中進行
     *
     * @param canvas
     */
    @Override
    protected void onDraw(Canvas canvas) {
        //空心圓
        mPaint.setStyle(Paint.Style.STROKE);
        //設定內部圓畫筆的寬度
        mPaint.setStrokeWidth(mLineWidth * 1.0f / 4);
        //繪製圓
        int width = getWidth();
        int height = getHeight();
        //圓心x,y,半徑r,畫筆
        //半徑要減去畫筆的寬度的一半 和padding的距離
        canvas.drawCircle(width / 2, height / 2,
                width / 2 - getPaddingLeft() - mPaint.getStrokeWidth() / 2,
                mPaint);
        //繪製外部圓 圓弧
        //重寫設定畫筆寬度
        mPaint.setStrokeWidth(mLineWidth);
        canvas.save();
        //移動繪製座標的圓點讓(0,0)變成(getPaddingLeft(),getPaddingTop()) 在這裡是(10,10)
        //就可以拿到圓所在的矩形區域了
        canvas.translate(getPaddingLeft(), getPaddingTop());
        //拿到角度
        float angle = mProgress * 1.0f / 100 * 360;
        //矩形的繪製 RectF 因為已經平移了 所以 可以將(10,10)當做新位置的初始座標0,0,寬度,高度
        //引數1:所在的矩形區域(規定圓的範圍) 引數2:開始的位置 引數3:結束的位置 引數4:是否畫扇形 引數5:畫筆
        canvas.drawArc(mProgressCicleRectf,
                0, angle, false, mPaint);
        canvas.restore();


        /**
         * 繪製中間文字 進度值
         */
        text = mProgress + "%";
        //讓文字水平居中
        mPaint.setTextAlign(Paint.Align.CENTER);
        mPaint.setTextSize(mTextSize);
        int y = getHeight() / 2;
        mPaint.getTextBounds(text, 0, text.length(), bound);
        textHeight = bound.height();
        //最主要是y的位置 是baseline(基線) 看圖 已經在onSizeChanged中進行計算 避免在onDraw方法在new 物件
        //y的位置要加上文字的一半
        canvas.drawText(text, 0, text.length(), getWidth() / 2, y + textHeight / 2
                , mPaint);
        //特別說明:當字型是中文時 y的高度需要設定為 上移descent的1/2
        //y + textHeight / 2 - mPaint.descent()/2;


    }

    /**
     * 對外提供設定progress
     * 屬性動畫呼叫
     *
     * @param progress
     */
    public void setProgress(int progress) {
        mProgress = progress;
        //重繪檢視
        invalidate();
    }

    public int getProgress() {
        return mProgress;
    }

    /**
     * 初始化畫筆
     */
    private void initPaint() {
        //1.定義畫筆
        mPaint = new Paint();
        mPaint.setColor(mColor);
        //設定抗鋸齒
        mPaint.setAntiAlias(true);
    }

    private static final String INSTANCE = "instance";
    private static final String KEY_PROGRESS = "key_progress";


    /**
     * 狀態儲存
     *
     * @return
     */
    @Nullable
    @Override
    protected Parcelable onSaveInstanceState() {
        Bundle bundle = new Bundle();
        //控制元件的字串 之前定義的
        bundle.putInt(KEY_PROGRESS, mProgress);
        //存父控制元件的狀態
        bundle.putParcelable(INSTANCE, super.onSaveInstanceState());
        return bundle;
    }

    /**
     * 狀態恢復
     *
     * @param state
     */
    @Override
    protected void onRestoreInstanceState(Parcelable state) {
        if (state instanceof Bundle) {
            Bundle b = (Bundle) state;
            //恢復父類的狀態
            Parcelable parcelable = b.getParcelable(INSTANCE);
            super.onRestoreInstanceState(parcelable);
            //恢復子類的狀態
            mProgress = b.getInt(KEY_PROGRESS);
            return;
        }
        super.onRestoreInstanceState(state);
    }
}

MainActivity.java

設定了屬性動畫,自動

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        final View view = findViewById(R.id.progressbar);
        view.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                //設定屬性動畫
                //物件屬性自動賦值 get set 方法 自動賦值開始0 結束100
                ObjectAnimator.ofInt(view, "progress", 0, 100).setDuration(3000).start();
            }
        });
    }
}

完成啦