雖然Android API給我們提供了衆多控件View來使用,但是鑒于Android的開發性,自然少不了根據需求自定義控件View了。比如說QQ頭像是圓形的,但是縱觀整個Android控件也找不到一個加載圓形圖檔的Button或者ImageView,那麼咋辦?廢話,肯定是自定義一個圓形RoundImageView控件啦!這裡我們可以繼承ImageView重寫裡面的方法來實作這一效果。還有一種自定義控件是繼承View重寫裡面的onDraw()方法,這類自定義View需要定義自己的屬性以備在xml布局檔案中使用。
自定義View的步驟
- 自定義View的屬性
- 在自定義View的構造方法中獲得View屬性值
- 重寫onMeasure(int,int)方法。(該方法可重寫可不重寫,具體看需求)
- 重寫onDraw(Canvas canvas)方法。
- 在xml布局檔案中如何使用自定義view的屬性?
自定義View的屬性
在res/values下面建立attrs.xml屬性檔案。我們看看atrrs.xml檔案怎麼寫?
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--name 是自定義屬性名,一般采用駝峰命名,可以随意。 format 是屬性的機關-->
<attr name="titleSize" format="dimension"></attr>
<attr name="titleText" format="string"></attr>
<attr name="titleColor" format="color"></attr>
<attr name="titleBackgroundColor" format="color"></attr>
<!--name 是自定義控件的類名-->
<declare-styleable name="MyCustomView">
<attr name="titleSize"></attr>
<attr name="titleText"></attr>
<attr name="titleColor"></attr>
<attr name="titleBackgroundColor"></attr>
</declare-styleable>
</resources>
自定義屬性分兩步:
- 定義公共屬性
- 定義控件的主題樣式
如上面的xml檔案第一部分是公共的屬性,第二部分是自定義控件MyCustomView的主題樣式,該主題樣式裡的屬性必須包含在公共屬性裡面。言外之意就是公共屬性可以被多個自定義控件主題樣式使用。有些人可能會糾結format字段後面都有哪些屬性機關?如果你是使用AS開發的話IDE會自動有提示,基本包括如下:
dimension(字型大小)string(字元串)color(顔色)boolean(布爾類型)float(浮點型)integer(整型)enmu(枚舉)fraction(百分比)等。不了解的可以百度一把。
獲得View屬性值
自定義View一般需要實作一下三個構造方法
public MyCustomView(Context context) {
this(context, null);
}
public MyCustomView(Context context, AttributeSet attrs) {
this(context, attrs, );
}
public MyCustomView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
從代碼中不難看出,這三個構造方法是層調用一層的,是個遞進關系,是以,我們隻需要在最後一個構造方法中來獲得View的屬性了。看代碼:
public MyCustomView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
final Resources.Theme theme = context.getTheme();
TypedArray a = theme.obtainStyledAttributes(attrs,
R.styleable.MyCustomView, defStyleAttr, );
if (null != a) {
int n = a.getIndexCount();
for (int i = ; i < n; i++) {
int attr = a.getIndex(i);
switch (attr) {
case R.styleable.MyCustomView_titleColor:
titleColor = a.getColor(attr, Color.BLACK);
break;
case R.styleable.MyCustomView_titleSize:
titleSize = a.getDimensionPixelSize(attr, titleSize);
break;
case R.styleable.MyCustomView_titleText:
titleText = a.getString(attr);
break;
case R.styleable.MyCustomView_titleBackgroundColor:
titleBackgroundColor = a.getColor(attr, Color.WHITE);
break;
}
}
a.recycle();
init();
}
}
第一步通過theme.obtainStyledAttributes()方法獲得自定義控件的主題樣式數組。第二步就是周遊每個屬性來獲得對應屬性的值,也就是我們在xml布局檔案中寫的屬性值。注意:在分支case裡R.styleable.後面的屬性名稱有一個規則:控件的樣式主題名 +“_”+ 屬性名,循環結束之後記得調用a.recycle()回收資源。至此就獲得了自定義控件的屬性值了。至于為什麼這樣來獲得屬性值?具體可以參考Android 系統的TextView源碼裡的構造方法。
重寫onDraw()方法來繪制View控件
這一步進行的操作是将你需要顯示的控件View的内容繪制到畫布Canvas上面。例如我們在一個圓裡面寫字,先來效果圖

onDraw方法實作如下:
.............
/**
* 初始化
*/
private void init() {
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setTextSize(titleSize);
/**
* 得到自定義View的titleText内容的寬和高
*/
mBound = new Rect();
mPaint.getTextBounds(titleText, , titleText.length(), mBound);
}
................
@Override
protected void onDraw(Canvas canvas) {
mPaint.setColor(titleBackgroundColor);
canvas.drawCircle(getWidth() / f, getWidth() / f, getWidth() / f, mPaint);
mPaint.setColor(titleColor);
canvas.drawText(titleText, getWidth() / - mBound.width() / , getHeight() / + mBound.height() / , mPaint);
}
先new一個Paint執行個體初始化畫筆,給畫筆設定文字大小,然後先給畫筆設定一個背景顔色,在畫一個圓,再次設定畫筆的文字顔色,在繪制字元串到畫布,最後就得到如上圖檔的效果了。
布局中使用自定義View
使用自定義View控件需要在根布局中添加xmlns:custom=”http://schemas.android.com/apk/res-auto”命名空間。其中字首名:”custom” 也是自定義的,可以是除了被Android系統使用過的字眼以外的任何字元串,自然你這裡了也可以寫成“myCustom”。不知道在Android哪個版本之前命名控件是這樣應用的xmlns:custom=”http://schemas.android.com/apk/res/com.xjp.customview。res/後面的是自定義控件所在的包名。當然隻要你代碼不報錯兩種命名空間都是可以的。隻是我用的AS開發,然後targetSdkVersion是21,是以我用的是第一種命名空間。
代碼如下:
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:custom="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.xjp.customview.MyCustomView
android:layout_width="wrap_content"
android:layout_height="match_parent"
custom:titleColor="@android:color/black"
custom:titleSize="25sp"
custom:titleBackgroundColor="#ff0000"
custom:titleText="自定義的View" />
</RelativeLayout>
從上面的代碼你會發現,凡是自定義的屬性使用時候的字首是命名空間名稱 custom。
至此,整個自定義View的流程就跑通了。貼出整個代碼部分如下:
package com.xjp.customview;
import android.content.Context;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.View;
/**
* Description:自定義控件View
* User: xjp
* Date: 2015/5/27
* Time: 14:50
*/
public class MyCustomView extends View {
private static final String TAG = "MyCustomView";
private static final boolean DEBUG = false;
private String titleText = "Hello world";
private int titleColor = Color.BLACK;
private int titleBackgroundColor = Color.WHITE;
private int titleSize = ;
private Paint mPaint;
private Rect mBound;
public MyCustomView(Context context) {
this(context, null);
}
public MyCustomView(Context context, AttributeSet attrs) {
this(context, attrs, );
}
public MyCustomView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
final Resources.Theme theme = context.getTheme();
TypedArray a = theme.obtainStyledAttributes(attrs,
R.styleable.MyCustomView, defStyleAttr, );
if (null != a) {
int n = a.getIndexCount();
for (int i = ; i < n; i++) {
int attr = a.getIndex(i);
switch (attr) {
case R.styleable.MyCustomView_titleColor:
titleColor = a.getColor(attr, Color.BLACK);
break;
case R.styleable.MyCustomView_titleSize:
titleSize = a.getDimensionPixelSize(attr, titleSize);
break;
case R.styleable.MyCustomView_titleText:
titleText = a.getString(attr);
break;
case R.styleable.MyCustomView_titleBackgroundColor:
titleBackgroundColor = a.getColor(attr, Color.WHITE);
break;
}
}
a.recycle();
init();
}
}
/**
* 初始化
*/
private void init() {
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setTextSize(titleSize);
/**
* 得到自定義View的titleText内容的寬和高
*/
mBound = new Rect();
mPaint.getTextBounds(titleText, , titleText.length(), mBound);
}
@Override
protected void onDraw(Canvas canvas) {
mPaint.setColor(titleBackgroundColor);
canvas.drawCircle(getWidth() / f, getWidth() / f, getWidth() / f, mPaint);
mPaint.setColor(titleColor);
canvas.drawText(titleText, getWidth() / - mBound.width() / , getHeight() / + mBound.height() / , mPaint);
}
}
運作結果圖:
細心的你會發現,跟我們上面預期的效果圖有不一樣啊?怎麼回事?布局大小的問題?
android:layout_width="wrap_content"
android:layout_height="match_parent"
從布局大小來看寬度應該包裹内容,但是卻充滿了整個螢幕。接下來我們就要想到其實我們在自定義View的流程中還有一個onMeasure方法沒有重寫。
重寫onMeasure控制View大小
當你沒有重寫onMeasure方法時候,系統調用預設的onMeasure方法。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
這個方法的作用是:測量控件的大小。其實Android系統在加載布局的時候是由系統測量各子View的大小來告訴父View我需要占多大空間,然後父View會根據自己的大小來決定配置設定多大空間給子View。那從上面的效果來看:當你在布局中設定View的大小為”wrap_content”時,其實系統測量出來的大小是“match_parent”。為什麼會是這樣子呢?那得從MeasureSpec的specMode模式說起了。一共有三種模式:
- MeasureSpec.EXACTLY:父視圖希望子視圖的大小是specSize中指定的大小;一般是設定了明确的值或者是MATCH_PARENT
- MeasureSpec.AT_MOST:子視圖的大小最多是specSize中的大小;表示子布局限制在一個最大值内,一般為WARP_CONTENT
- MeasureSpec.UNSPECIFIED:父視圖不對子視圖施加任何限制,子視圖可以得到任意想要的大小;表示子布局想要多大就多大,很少使用。
我們跳進源碼看看系統預設的 super.onMeasure(widthMeasureSpec, heightMeasureSpec);是怎麼實作的
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
..................
public static int getDefaultSize(int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
result = size;
break;
case MeasureSpec.AT_MOST:
case MeasureSpec.EXACTLY:
result = specSize;
break;
}
return result;
}
從上面的代碼getDefaultSize()方法中看出,原來MeasureSpec.AT_MOST和MeasureSpec.EXACTLY走的是同一個分支,也就是父視圖希望子視圖的大小是specSize中指定的大小。
得出來的預設值就是填充整個父布局。是以,不管你布局大小是”wrap_content”還是“match_parent”效果都是充滿整個父布局。那我想要”wrap_content”的效果怎麼辦?不着急,隻有重寫onMeasure方法了。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
/**
* 測量模式
*/
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
/**
* 父布局希望子布局的大小,如果布局裡面設定的是固定值,這裡取布局裡面的固定值和父布局大小值中的最小值.
* 如果設定的是match_parent,則取父布局的大小
*/
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
if (DEBUG)
Log.e(TAG, "the widthSize:" + widthSize + " the heightSize" + heightSize);
int width;
int height;
Rect mBounds = new Rect();
if (widthMode == MeasureSpec.EXACTLY) {
width = widthSize;
} else {
mPaint.setTextSize(titleSize);
mPaint.getTextBounds(titleText, , titleText.length(), mBounds);
float textWidth = mBounds.width();
int desired = (int) (getPaddingLeft() + textWidth + getPaddingRight());
width = desired;
}
if (heightMode == MeasureSpec.EXACTLY) {
height = heightSize;
} else {
height = width;
}
/**
* 最後調用父類方法,把View的大小告訴父布局。
*/
setMeasuredDimension(width, height);
}
這樣就可以實作第一張圖檔的效果了。解釋都在代碼裡了。
源碼下載下傳