自定義View的有好幾種分類,可以分成4種:
1.特定的View的子類:Android的API已經為我們提供了不少可以使用的View,如TextView、ImageView、Button等等,但是有時候我們需要在這些基礎的View上擴展一些功能,例如在Button里綁定一個TextWatch監測若干個EditText的輸入情況時,就是繼承Button類,在它的子類進行擴展了。這種自定義View實現難度低,不需要自己支持wrap_content和padding等屬性,非常常見。
2.特定的ViewGroup子類:Android的API也為我們提供了不少可以使用的ViewGroup,如LinearLayout、RelativeLayout等等,但是有時候我們想把實現同一個需求若干個View組合起來,就可以用這種方式的自定義View來打包了。這種自定義View的實現難度低,也不需要自己處理ViewGroup對每個子View的測量和布局,非常常見。
3.View的子類:View是一個很基礎的父類,有一個空的onDraw()方法,繼承它首先就是要實現這個方法,在里面利用Canvas畫出自己想要的內容,不然View是不會顯示任何東西的,使用這種自定義View主要用于實現一些非常規的圖形效果,例如一些動態變化的View等等。這種自定義View的實現難度比較高,除了需要自己重寫onDraw(),還要自己支持wrap_content和padding等屬性,不過這種View也很常見。
4.ViewGroup的子類:ViewGroup是用于實現View的組合布局的基礎類,直接繼承ViewGroup的子類主要是用于實現一些非常規的布局,即不同于官方API給出的LinearLayout等這些的布局。這種這種自定義View的實現難度高,需要處理好ViewGroup和它子View的測量和布局,比較少見。
** 4種自定義View所需的步驟**
自定義屬性
想要實現自定義的功能,我們有時候就需要一些自己定義的屬性,怎么讓這些屬性可以通過在xml上設置呢?只需要在res/value文件夾里新建一個attrs.xml(名字隨便,建立位置對就行):
<?xml version="1.0" encoding="utf-8"?>
<resources>
<attr name="Color" format="color"/>
<attr name="inVelocityX" format="integer"/>
<attr name="inVelocityY" format="integer"/>
<attr name="Text" format="string"/>
<attr name="TextColor" format="color"/>
<declare-styleable name="BallView">
<attr name="color"/>
<attr name="inVelocityX" />
<attr name="inVelocityY" />
<attr name="Text" />
<attr name="TextColor"/>
</declare-styleable>
</resources>
BallView就是我demo里面的自定義View名字,在declare-styleable外面聲明一些自定義屬性和屬性的類型format,在里面申明BallView需要哪些屬性(當然也可以直接在declare-styleable里面聲明屬性的format,這樣就不需要在外面聲明了,但是這樣的話這些屬性也不能被另一個自定義View重用)。
關于屬性的format有很多種,reference,color,boolean等等,想看全部可以參考這里。
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:cust="http://schemas.android.com/apk/res-auto"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.zhjohow.customview.BallView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
cust:color="#ff0000"
cust:Text="我是一個球"
cust:TextColor="#ffffff"
cust:TextSize= "34"
cust:inVelocityX="6"
cust:inVelocityY="6"/>
</RelativeLayout>
然后我們就要在自定義View里面獲取這些屬性了,自定義View的構造函數有4個,自定義View必須重寫至少一個構造函數:
public BallView(Context context) {
super(context);
}
public BallView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public BallView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
//API21之后才使用
public BallView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
4個構造函數中:如果View是在Java代碼里面new的,則調用第一個構造函數;如果是在xml里聲明的,則調用第二個構造函數,我們所需要的自定義屬性也就是從這個AttributeSet參數傳進來的;第三第四個構造函數不會自動調用,一般是在第二個構造主動調用(例如View有style屬性的時候)。如果想深入了解構造函數,可以參考這里和這里 所以,我們就可以重寫第二個構造函數那里獲取我們在xml設定的自定義屬性:
//球的x,y方向速度
private int velocityX = 0,velocityY = 0;
//球的顏色
private int color;
//球里面的文字
private String text;
//文字的顏色
private int textColor;
public BallView(Context context, AttributeSet attrs) {
super(context, attrs);
//獲取自定義屬性數組
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.BallView, 0, 0);
int n = a.getIndexCount();
for (int i = 0;i < n;i++){
int attr = a.getIndex(i);
switch (attr){
case R.styleable.BallView_inVelocityX:
velocityX = a.getInt(attr,0);
break;
case R.styleable.BallView_inVelocityY:
velocityY = a.getInt(attr,0);
break;
case R.styleable.BallView_color:
color = a.getColor(attr,Color.BLUE);
break;
case R.styleable.BallView_Text:
text = a.getString(attr);
break;
case R.styleable.BallView_TextColor:
textColor = a.getColor(attr,Color.RED);
break;
}
}
}
可以看到輸出:
System.out: text:球
System.out: textColor:-1
System.out: velocityX:3
System.out: velocityY:3
System.out: color:-65536
重寫onMeasure()
關于重寫onMeasure()的解釋,我覺得用BallView不合適,于是就另外開了個TestMeasureView進行測試: 下面是沒有重寫onMeasure()來支持wrap_content的例子:
public class TestMeasureView extends View {
private Paint paint;
public TestMeasureView(Context context) {
super(context);
}
public TestMeasureView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public TestMeasureView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawColor(Color.BLUE);
}
}
在xml上使用這個View:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:cust="http://schemas.android.com/apk/res-auto"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.zhjh.customview.TestMeasureView
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</RelativeLayout>
得出的結果是這樣的:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int hSpeSize = MeasureSpec.getSize(heightMeasureSpec);
int hSpeMode = MeasureSpec.getMode(heightMeasureSpec);
int wSpeSize = MeasureSpec.getSize(widthMeasureSpec);
int wSpeMode = MeasureSpec.getMode(widthMeasureSpec);
int width = wSpeSize;
int height = hSpeSize;
if (wSpeMode == MeasureSpec.AT_MOST){
//在這里實現計算需要wrap_content時需要的寬度,這里我直接當作賦值處理了
width =200;
}
if (hSpeMode == MeasureSpec.AT_MOST){
//在這里實現計算需要wrap_content時需要的高度,這里我直接當作賦值處理了
height = 200;
}
//傳入處理后的寬高
setMeasuredDimension(width,height);
}
結果是成功的: 網上的很多都是這樣做,通過判斷測量模式是否AT_MOST來判斷View的參數是否是wrap_content,然而,通過上面的表我們發現View的AT_MOST模式對應的不只是wrap_content,還有當父View是AT_MOST模式的時候的match_parent,如果我們這樣做的話,父View是AT_MOST的時候這個自定義View的match_parent不就失效了嗎。
測試一下,我們把TestMeasureView長寬參數設置為match_parent,然后在外面再包一個模式為AT_MOST的父View(把父View的寬高都設為wrap_content,這樣就確保了模式是AT_MOST,UNSPECIFIED因為不會出現在這里可以忽略):
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:cust="http://schemas.android.com/apk/res-auto"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<com.zhjh.customview.TestMeasureView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</RelativeLayout>
運行一下,結果果然是match_parent失效: 所以說看到的東西要思考一下,才能真正地轉化為自己的,然后這個怎么解決呢,很簡單,直接在onMeasure里面判斷參數是否wrap_content就好:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int hSpeSize = MeasureSpec.getSize(heightMeasureSpec);
int hSpeMode = MeasureSpec.getMode(heightMeasureSpec);
int wSpeSize = MeasureSpec.getSize(widthMeasureSpec);
int wSpeMode = MeasureSpec.getMode(widthMeasureSpec);
int width = wSpeSize;
int height = hSpeSize;
if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT){
//在這里實現計算需要wrap_content時需要的寬
width =200;
}
if (getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT){
//在這里實現計算需要wrap_content時需要的高
height =200;
}
//傳入處理后的寬高
setMeasuredDimension(width,height);
}
然后我把參數設回wrap_content(xml就不貼代碼了),結果是正確的: 但是這種方法有一個缺陷,就是可能會將UNSPECIFIED的情況也覆蓋掉,但是UNSPECIFIED一般只出現在系統內部的View,不會出現在自定義View,而且當它出現的時候也可以加個判斷按情況解決。
重寫onDraw()
這里就是利用onDraw()給出的Canvas畫出各種東西了,這里是BallView的onMeasure()方法和onDraw(),通過以下代碼,可以實現在wrap_content的時候根據字的內容長度畫出相應的圓,然后可以根據給出的速度移動,遇到“墻會碰撞”。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int wSpeSize = MeasureSpec.getSize(widthMeasureSpec);
int hSpeSize = MeasureSpec.getSize(heightMeasureSpec);
int width = wSpeSize ;
int height = hSpeSize;
if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT){
//在這里實現計算需要wrap_content時需要的寬高
width = bounds.width();
}else if(getLayoutParams().width != ViewGroup.LayoutParams.MATCH_PARENT){
width = getLayoutParams().width;
}
if (getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT){
//在這里實現計算需要wrap_content時需要的寬高
height =bounds.height();
}else if(getLayoutParams().height != ViewGroup.LayoutParams.MATCH_PARENT){
height = getLayoutParams().height;
}
//計算半徑
radius = Math.max(width,height)/2;
//傳入處理后的寬高
setMeasuredDimension((int) (radius*2+1), (int) (radius*2+1));
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawCircle(getWidth()/2,getHeight()/2,radius,paintFill);
//讓字體處于球中間
canvas.drawText(text,getWidth()/2,getHeight()/2+bounds.height()/2,paintText);
checkCrashScreen();
offsetLeftAndRight(velocityX);
offsetTopAndBottom(velocityY);
postInvalidateDelayed(10);
}
//檢測碰撞,有碰撞就反彈
private void checkCrashScreen(){
if ((getLeft() <= 0 && velocityX < 0)){
velocityX = -velocityX ;
}
if (getRight() >= screenWidth && velocityX > 0){
velocityX = -velocityX ;
}
if ((getTop() <= 0 && velocityY < 0)) {
velocityY = -velocityY ;
}
if (getBottom() >= screenHeight -sbHeight && velocityY > 0){
velocityY = -velocityY ;
}
}
最后結果:
重寫自身和子類的onMesure()和onLayout()
上面是以自定義View為例子,這次就以一個自定義ViewGroup做為例子,做一個很簡單的可以按照斜向下依次排列View的ViewGroup,類似于LinearLayout。要做一個新的ViewGroup,首先就是要重寫它的onMesure()方法,讓它可以按照需求測量子View和自身的寬高,還可以在這里支持wrap_content。
onMesure()和onLayout()是干什么的呢?為什么需要重寫的是它們?因為View的繪制過程大概是Measure(測量)→Layout(定位)→Draw(繪圖)三個過程,至于具體是怎樣的呢?可以看工匠若水的這篇文章,看不懂沒關系,可以看圖。。。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);
// 計算出所有的childView的寬和高
measureChildren(widthMeasureSpec, heightMeasureSpec);
int cCount = getChildCount();
int width = 0;
int height = 0;
//處理WRAP_CONTENT情況,把所有子View的寬高加起來作為自己的寬高
if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT){
for (int i = 0; i < cCount; i++){
View childView = getChildAt(i);
width += childView.getMeasuredWidth();
}
}else {
width = sizeWidth;
}
if (getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT){
for (int i = 0; i < cCount; i++){
View childView = getChildAt(i);
height += childView.getMeasuredHeight();
}
}else {
height =sizeHeight;
}
//傳入處理后的寬高
setMeasuredDimension(width,height);
}
還有通過重寫onLayout()把子View一個個排序斜向放好:
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int cCount = getChildCount();
int sPointX = 0;
int sPointY = 0;
int cWidth = 0;
int cHeight = 0;
//遍歷子View,根據它們的寬高定位
for (int i = 0; i < cCount; i++){
View childView = getChildAt(i);
//這里使用getMeasuredXXX()方法是因為還沒layout完,使用getWidth()和getHeight()獲取會得不到正確的寬高
cWidth = childView.getMeasuredWidth();
cHeight = childView.getMeasuredHeight();
//定位
childView.layout(sPointX,sPointY,sPointX + cWidth,sPointY + cHeight);
sPointX += cWidth;
sPointY += cHeight;
}
}
結果: 參數為WRAP_CONTENT的時候,成功地顯示了:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:cust="http://schemas.android.com/apk/res-auto"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<com.zhjh.customview.InclinedLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#000fff">
<TextView
android:layout_width="50dp"
android:layout_height="50dp"
android:text="1"
android:background="#fff000"/>
<TextView
android:layout_width="20dp"
android:layout_height="50dp"
android:text="2"
android:background="#00ff00"/>
<TextView
android:layout_width="50dp"
android:layout_height="30dp"
android:text="3"
android:background="#ff0000"/>
</com.zhjh.customview.InclinedLayout>
</RelativeLayout>
這樣斜向下排列的ViewGroup就完成了,這些只是最簡單的一個demo,用于我們熟悉自定義View的步驟,掌握了這些,復雜的自定義View也可以一步一步地完成了。