自定義view也算是Android的一大難點,里面涉及到很多值得學習的地方,我會在接下來寫一系列文章去介紹它,本篇文章以自定義一個TextView為例。
View的構(gòu)造方法
自定義view之前我們先了解view的四個構(gòu)造方法,自定義view無非就是新建一個類去繼承View,為了閱讀方便,我們采用Java代碼進行分析(kotlin語言可讀性較差),首先定義一個類TextView2繼承View,按照提示實現(xiàn)所有構(gòu)造方法
public class TextView2 extends View {
public TextView2(Context context) {
super(context);
}
public TextView2(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public TextView2(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public TextView2(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
}
需要實現(xiàn)它的四個構(gòu)造方法,我在下面分別給出每個構(gòu)造方法的調(diào)用時期。
public TextView2(Context context) {
super(context);
}
這個構(gòu)造方法通過閱讀源碼注釋加以理解可以知道,它是在new對象時簡單調(diào)用的構(gòu)造方法,其中參數(shù)context是視圖運行的上下文,通過它可以訪問當前的主題、資源等。
public TextView2(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
上面這個構(gòu)造方法是當TextView2在layout布局中使用時調(diào)用,參數(shù):
context-----視圖運行的上下文,通過它可以訪問當前的主題、資源等
attrs-----使視圖膨脹的XML標記的屬性
public TextView2(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
這個構(gòu)造方法調(diào)用時期是在自定義樣式時調(diào)用,比如我們在layout視圖中使用了我們自定義的某個style,就會調(diào)用這個構(gòu)造方法,參數(shù):
context-----視圖運行的上下文,通過它可以訪問當前的主題、資源等
attrs-----使視圖膨脹的XML標記的屬性
defStyleAttr-----當前主題中的一個屬性,它包含對為視圖提供默認值的樣式資源的引用
public TextView2(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
最后這個方法是后面才加入的,也很少用到,它的作用是從XML執(zhí)行膨脹,并從主題屬性或樣式資源應(yīng)用特定于類的基樣式。 View的這個構(gòu)造函數(shù)允許子類在膨脹時使用自己的基樣式。當確定一個特定屬性的最終值時,有四個輸入會起作用:
- 給定AttributeSet中的任何屬性值。
- 在AttributeSet中指定的樣式資源(名為“style”)。
- defStyleAttr指定的默認樣式。
- defStyleRes指定的默認樣式。
關(guān)于這個構(gòu)造函數(shù)在使用時發(fā)現(xiàn)會報錯,具體什么原因后面會細講,現(xiàn)在先賣個關(guān)子。
我們一般會對前三個構(gòu)造方法進行改造,方便我們使用其中任何一個構(gòu)造方法都能遍歷所有構(gòu)造方法,比如可以在第一個構(gòu)造方法中調(diào)用第二個,在第二個中調(diào)用第三個,這樣不管使用哪個構(gòu)造方法都能實現(xiàn)我們構(gòu)造方法中的業(yè)務(wù)邏輯。改造如下
//在第一個構(gòu)造方法中調(diào)用第二個構(gòu)造方法
public TextView2(Context context) {
this(context, null);
}
//在第二個構(gòu)造方法中調(diào)用第三個構(gòu)造方法
public TextView2(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
//通常在第三個構(gòu)造方法中獲取自定義屬性
public TextView2(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
自定義屬性并獲取自定義屬性
接上文,我們自定義view入門就是自定義一個Textview,系統(tǒng)提供的TextView在布局中使用時基本上如下所示:
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="zzp"
android:textColor="@color/black"
android:textSize="16sp" />
一個TextView必須具備的就是寬和高的指定,這個在在定義的下一部分會講,還有就是后面三句,對text、textColor和textSize進行了賦值,這三個就是view的屬性,那我們在自定義view時屬性也是需要自定義的,那怎么進行屬性的自定義呢,我在這里將它分為一下幾步
- 1、創(chuàng)建自定義屬性資源文件
在res文件夾下的value模塊創(chuàng)建一個Values Resource File取名為attrs(可以自由取名),在內(nèi)部實現(xiàn)三個自定義屬性text1、textColor1、textSize1,如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--自定義屬性的名字-->
<declare-styleable name="TextView2">
<!--name 屬性名稱 format 格式-->
<!-- 常用格式:color 顏色
string 文字
dimension 寬高或者文字大小
reference 資源
-->
<attr name="text1" format="string"/>
<attr name="textColor1" format="color"/>
<attr name="textSize1" format="dimension"/>
</declare-styleable>
</resources>
這沒什么好說的,注意看代碼中注釋的常用格式?,F(xiàn)在就是已經(jīng)自定義了三個屬性text1、textColor1、textSize1,分別是string型、color型、dimension型。值得注意的是在自定義屬性時系統(tǒng)已經(jīng)有的屬性不能被重復(fù)定義,所以name不能用text、textColor和textSize。我都加了個1作區(qū)分,不然會被認定為重新定義系統(tǒng)屬性而報錯。
- 2、在布局文件中使用
接下來可以仿照TextView在布局文件中使用
<com.example.kotlindemo.TextView2
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:text1="zzp"
app:textColor1="@color/black"
app:textSize1="16sp" />
我們使用了自定義的TextView2,并將自定義屬性賦值,但是運行還是沒有效果,因為我們還需要將自定義的屬性與原本的屬性相關(guān)聯(lián),比如自定義的text1屬性相當于TextView的text,textColor1屬性相當于TextView的textColor等
- 3、獲取自定義屬性
一般在第三個構(gòu)造方法中獲取屬性,整體代碼如下,請看注釋:
public class TextView2 extends View {
private String mText;
private int mTextColor= Color.BLACK;
private int mTextSize;
//在第一個構(gòu)造方法中調(diào)用第二個構(gòu)造方法
public TextView2(Context context) {
this(context, null);
}
//在第二個構(gòu)造方法中調(diào)用第三個構(gòu)造方法
public TextView2(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
//通常在第三個構(gòu)造方法中獲取自定義屬性
public TextView2(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//獲取自定義布局TextView2的屬性
TypedArray array=context.obtainStyledAttributes(attrs,R.styleable.TextView2);
//將獲取到的屬性賦值給TextView2的成員變量
mText=array.getString(R.styleable.TextView2_text1);
mTextColor=array.getColor(R.styleable.TextView2_textColor1,Color.BLACK);//第二個參數(shù)為默認值
mTextSize=array.getDimensionPixelSize(R.styleable.TextView2_textSize1,18);//注意接收時使用getDimensionPixelSize,在TextView源碼中就是使用這個方法接收,也算是個坑
//回收
array.recycle();
}
}
運行起來發(fā)現(xiàn)還是沒有效果,因為還沒有指定寬高和繪畫,且往下看。
自定義View中的onMeaSure()
我們在上面實現(xiàn)的代碼運行起來依舊一片空白,我也說了是因為沒有指定寬高和進行繪畫,一步步來,先看指定寬高,怎么指定寬高呢?自定義view時需要在onMeaSure方法為view指定寬高,所以在解決這個問題前我們先來了解一下這個方法的一些基本知識。
onMeaSure()方法是自定義view中一個非常重要的方法,他具體有什么作用呢?它是自定義view的測量方法,view的寬和高都由它來指定。
我們先來看一段代碼,了解幾樣東西。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//獲取寬高的模式
int widthmode=MeasureSpec.getMode(widthMeasureSpec);
int heifhtmode=MeasureSpec.getMode(heightMeasureSpec);
}
在上面這段代碼中我們實現(xiàn)了onMeaSure方法,并且在里面獲取了兩個變量。我們在布局文件中定義view時通常會給view指定寬高,指定寬高時分為三種情況,第一種是包裹內(nèi)容(wrap_content),第二種是是view充滿布局(match_parent),第三種是給view指定一個數(shù)值的寬高(比如100dp),上面代碼中的
int widthmode=MeasureSpec.getMode(widthMeasureSpec);
int heifhtmode=MeasureSpec.getMode(heightMeasureSpec);
可以用來判斷該自定義view使用了哪種方式指定寬高,下面我就直接用代碼說明獲取到的值分別對應(yīng)哪一種指定寬高的情況。
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//獲取寬高的模式
int widthmode=MeasureSpec.getMode(widthMeasureSpec);
int heifhtmode=MeasureSpec.getMode(heightMeasureSpec);
//說明三種情況對應(yīng)的值,以寬為例
if (widthmode==MeasureSpec.AT_MOST){
Log.e("zzp","指定寬度的方式為wrap_content");
}else if (widthmode==MeasureSpec.EXACTLY){
Log.e("zzp","指定寬度的方式為確切的值,match_parent,fill_parent");
}else if (widthmode==MeasureSpec.UNSPECIFIED){
Log.e("zzp","布局盡可能大,一般在listview,scollview會用到");
}
}
如上面代碼所示,getMode()能夠獲取到三個有效值,每個值對應(yīng)哪種情況,值得注意的是確切值和match_parent都是對應(yīng)MeasureSpec.EXACTLY,我看到好多博客都把match_parent寫成對應(yīng)MeasureSpec.UNSPECIFIED,這是錯誤的。
在了解三種測量模式后,我們就可以針對不同情況對view指定寬高,根據(jù)三種模式分為三種情況:
- 1、確切的值,不需要計算,給多少就是多少
- 2、給的是wrap_content,需要計算寬高,使用畫筆進行計算
public class TextView2 extends View {
private String mText;
private int mTextColor = Color.WHITE;
private int mTextSize;
private Paint mPaint = new Paint();
//在第一個構(gòu)造方法中調(diào)用第二個構(gòu)造方法
public TextView2(Context context) {
this(context, null);
}
//在第二個構(gòu)造方法中調(diào)用第三個構(gòu)造方法
public TextView2(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
//通常在第三個構(gòu)造方法中獲取自定義屬性
public TextView2(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//獲取自定義布局TextView2的屬性
TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.TextView2);
mText = array.getString(R.styleable.TextView2_text1);
mTextColor = array.getColor(R.styleable.TextView2_textColor1, Color.BLACK);//第二個參數(shù)為默認值
mTextSize = array.getDimensionPixelSize(R.styleable.TextView2_textSize1, 18);//注意接收時使用getDimensionPixelSize,在TextView源碼中就是使用這個方法接收,也算是個坑
//設(shè)置抗鋸齒
mPaint.setAntiAlias(true);
//設(shè)置字體
mPaint.setTextSize(mTextSize);
//設(shè)置字體顏色
mPaint.setColor(mTextColor);
//回收
array.recycle();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//獲取寬高的模式
int widthmode=MeasureSpec.getMode(widthMeasureSpec);
int heifhtmode=MeasureSpec.getMode(heightMeasureSpec);
//確切的值,不需要測量
int width=MeasureSpec.getSize(widthMeasureSpec);
//給的是wrap_content,需要計算寬高,使用畫筆進行計算
if (widthmode==MeasureSpec.AT_MOST){
Rect rect=new Rect();
mPaint.getTextBounds(mText,0,mText.length(),rect);
width=rect.width();
}
int height=MeasureSpec.getSize(heightMeasureSpec);
if (heifhtmode==MeasureSpec.AT_MOST){
Rect rect=new Rect();
mPaint.getTextBounds(mText,0,mText.length(),rect);
height=rect.width();
}
setMeasuredDimension(width,height);
}
}
為了效果明顯,我給了一個黑色背景
<com.example.kotlindemo.TextView2
android:background="#000"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:text1="zzp"
app:textColor1="@color/white"
app:textSize1="16sp" />
現(xiàn)在運行起來就有效果了,有一個黑色的小框,只是沒有顯示文字,因為還要進行繪畫,可以在布局文件中切換wrap_content和確切的值運行后查看效果。關(guān)于畫筆,有興趣的同學可以去了解一下,我都是把它當做一個測量的工具,直接死記硬背。
自定義View中的onDraw()
我們在上面實現(xiàn)計算控件寬高,但是顯示不出文字,是因為還沒進行繪畫,所以還需要實現(xiàn)它的onDraw()方法。
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//第二個參數(shù)--代表開始的水平位置(以控件為基準)
//第三個參數(shù)--基線,baseline
canvas.drawText(mText,0,getHeight()/2,mPaint);
}
這樣就像是出了效果

但是發(fā)現(xiàn)文字顯示偏上,這是因為基線y的值不能直接賦值為高度的一半,這里涉及到多個概念,我也是查閱很多資料才弄懂。
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//第二個參數(shù)--代表開始的水平位置(以控件為基準)
//第三個參數(shù)--基線,baseline
Paint.FontMetricsInt fontMetricsInt=mPaint.getFontMetricsInt();
int dy=(fontMetricsInt.bottom-fontMetricsInt.top)/2-fontMetricsInt.bottom;
int baseLine=getHeight()/2+dy;
canvas.drawText(mText,0,baseLine,mPaint);
}
下面借助一張圖來解釋一下為什么要這樣算基線的值

如果我們想要文字豎直居中,那么基線位置如圖中BaseLine一樣,先來梳理一下現(xiàn)在的已知條件,
getHeight()/2------整個視圖高度的一半
FontMetricsInt.top ----基線到文字頂部的距離,是一個負值
FontMetricsInt.bottom--基線到文字底部的距離,是一個正值
假設(shè)基線的值y=getHeight()/2+dy
dy表示基線與高度的一半之間的距離。
dy=(fontMetricsInt.bottom-fontMetricsInt.top)/2-fontMetricsInt.bottom
這樣基線的值就可以計算出來了。
重新運行,就能看到豎直居中后的效果了。