android進階4step1:Android動畫處理與自定義View——自定義View
為什麼要自定義控制元件
- 特定的顯示風格
- 處理特有的使用者互動
- 優化我們的佈局
- 封裝等...
如何自定義控制元件
- 自定義屬性的宣告與獲取
- 測量onMeasure
- 繪製onDraw
- 狀態的儲存與恢復
步驟一、自定義屬性宣告與獲取
- 分析需要的自定義屬性
- 在res/valus/attrs.xml定義宣告
- 在layout xml檔案中進行使用
- 在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)
- EXACTLY(固定值 例如:100dp) , AT_MOST(最多不多過父佈局 wrap_content) , UNSPECIFIED (不確定 滑動佈局)
- MeasureSpec
- setMeasuredDimension
- 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。
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();
}
});
}
}
完成啦