Android UI —— 自定義組件

眾所周知,自定義組件的第一步是繼承android.view.View類,然后重寫其中一些方法來實現自定義功能。在官網文檔說明自定義view的部分列出了一些方法。我理解是一些比較常用的需要復寫的方法,因此本篇就來詳解下這些方法,包括構造器、onDraw、onMeasure、事件響應等。View類中的方法和域是非常多的,其他的以后找機會再詳解。

View的構造器


View (Context context)

Simple constructor to use when creating a view from code.

也就是說,這個構造函數可以在代碼新建view的時候使用,而無法在xml中使用。下面來自定義一個只有此構造函數的view,然后在復寫onDraw(之后細講)來寫行字:

class MyView extends View {
        public MyView(Context context) {
            super(context);
        }
        @Override
        public void onDraw(Canvas c){
            Paint p = new Paint();
            p.setColor(Color.BLUE);
            p.setTextSize(50);
            c.drawText("自定義view",50,50,p);
        }
    }

之后在代碼中生成一個MyView并加入當前界面:

ViewGroup.LayoutParams p = findViewById(R.id.container).getLayoutParams();
        p.height = ViewGroup.LayoutParams.WRAP_CONTENT;
        p.width = ViewGroup.LayoutParams.MATCH_PARENT;
        this.addContentView(new MyView(this),p);

效果如下:


構造函數1使用

而如果將onDraw中的內容去掉,界面會變為空白。而假如在xml中使用這個自定義view,在運行時會報:

java.lang.RuntimeException: Unable to start activity ComponentInfo{com.tgtpp.themandsytle/com.tgtpp.themandsytle.MainActivity}: android.view.InflateException: Binary XML file line #21: Binary XML file line #21: Error inflating class com.tgtpp.themandsytle.MainActivity.MyView

因此僅有這個構造函數,那么這個View默認沒有任何樣式,只能通過編寫代碼來令其展示內容與樣式,且只能在代碼中調用。

View (Context context, AttributeSet attrs)

Constructor that is called when inflating a view from XML. This is called when a view is being constructed from an XML file, supplying attributes that were specified in the XML file. This version uses a default style of 0, so the only attribute values applied are those in the Context's Theme and the given AttributeSet.

使用這個構造函數后,便可在xml中使用這個自定義view。為MyView增加構造函數:

public class MyView extends View {
    public MyView(Context context) {
        super(context);
    }
    public MyView(Context context, AttributeSet attributeSet){
        super(context, attributeSet);
    }
    @Override
    public void onDraw(Canvas c){
        super.onDraw(c);
        Paint p = new Paint();
        p.setColor(Color.BLUE);
        p.setTextSize(50);
        c.drawText("自定義view",50,50,p);
    }
}

然后在xml中引用此類:

 <com.tgtpp.themandsytle.MyView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
/>

此時已經可以在android studio的xml的design界面看到這個view的預覽了,令自定義view可以用于xml的好處就是可以方便的設置屬性。在xml中為此View添加background:

<com.tgtpp.themandsytle.MyView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="#00ffff"
 />

效果如下:


自定義view加背景

可見成功添加了背景色,但是也可以注意到,雖然layout_height設置為wrap_content,但是高度并非是想象中包圍文字的。想達到此效果,還應更詳細地重寫其他函數。而至于新增的AttributeSet參數,看了下相關的使用還是比較復雜的,應該是為了可以將xml中設置的屬性傳遞給View,一般來似乎不需要處理。因此之后有時間再細看這個。

View (Context context, AttributeSet attrs, int defStyleAttr)

Perform inflation from XML and apply a class-specific base style from a theme attribute. This constructor of View allows subclasses to use their own base style when they are inflating. For example, a Button class's constructor would call this version of the super class constructor and supply R.attr.buttonStyle for defStyleAttr; this allows the theme's button style to modify all of the base view attributes (in particular its background) as well as the Button class's attributes.

既然官方文檔里舉了Button的例子,那么就來看下Button的源碼:

@RemoteView
public class Button extends TextView {
    public Button(Context context) {
        this(context, null);
    }

    public Button(Context context, AttributeSet attrs) {
        this(context, attrs, com.android.internal.R.attr.buttonStyle);
    }

    public Button(Context context, AttributeSet attrs, int defStyleAttr) {
        this(context, attrs, defStyleAttr, 0);
    }

    public Button(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }

    @Override
    public CharSequence getAccessibilityClassName() {
        return Button.class.getName();
    }
}

可以看到是在第二個構造函數中,向父類傳遞了com.android.internal.R.attr.buttonStyle作為第三個參數。如果看過我上一篇博文的,可能會對buttonStyle有些印象:在應用的theme中重新設定buttonStyle,可以使得全局button的樣式改變。參考Button實現方式,將com.android.internal.R.attr.buttonStyle傳入自定義View:

public class MyView extends View {
    public MyView(Context context) {
        super(context);
    }
    
    public MyView(Context context, AttributeSet attributeSet){
        super(context, attributeSet, com.android.internal.R.attr.buttonStyle);
    }

    @Override
    public void onDraw(Canvas c){
        super.onDraw(c);
    }
}

然而卻報了錯:Error:(22, 60) 錯誤: 程序包com.android.internal.R不存在,網上查詢說這是個隱藏的類,看來不能直接使用。由于buttonStyle是一個屬性,因此我們也自定義一個屬性:

<declare-styleable name="MyView">
    <attr name="defaultStyleAttr" format="reference" />
</declare-styleable>

這里順便插一句,我這里declare-styleable的name寫的是我自定義View的名稱,android studio提示和官網相關教程都是寫的對應自定義類的名稱。事實上不對應view的名稱也是可以正常編譯,在本篇case中也能正常運行。其影響就是,在xml中使用自定義類時,無法在其中使用自定義的這個屬性。反之,和view對應起來,就可以通過app:屬性名引用到:

<com.tgtpp.themandsytle.MyView
   android:layout_width="match_parent"
   android:layout_height="wrap_content"
   app:defaultStyleAttr=""
/>

回到本次主題,屬性定義好,在我們的類中使用:

public class MyView extends View {
    public MyView(Context context) {
        super(context);
    }

    public MyView(Context context, AttributeSet attributeSet){
        super(context, attributeSet, R.attr.defaultStyleAttr);
    }

    @Override
    public void onDraw(Canvas c){
        super.onDraw(c);
    }
}

目前屬性定義了,還需要找個地方賦值:

  • 官方說明提到了theme,那么就先嘗試在應用主題中賦值,這里暫時借用button的默認樣式Widget.Button:
 <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
        <item name="defaultStyleAttr">@android:style/Widget.Button</item>
    </style>

在xml中使用自定義界面并設置合適的長寬:

<com.tgtpp.themandsytle.MyView
        android:layout_width="50dp"
        android:layout_height="50dp"
/>

界面效果如下,成功展示了在主題中設置的默認樣式:


構造函數3效果
  • 不在theme中設置,而是在使用view的時候設置此屬性:
<com.tgtpp.themandsytle.MyView
        android:layout_width="50dp"
        android:layout_height="50dp"
        app:defaultStyleAttr="@android:style/Widget.Button"
/>

然而卻報錯:Failed to find style 'defaultStyleAttr' in current theme。看來必須要在主題中設置這個屬性才可以。

總結下:View的第三個構造函數的第三個參數,接收一個在當前主題中指定的樣式,以此方式令所有View的實例具有相同的默認樣式。若想自定義,需在自定義屬性后,在當前主題中為此自定義屬性賦值,然后將此屬性的ID傳入View的構造函數的第三個參數。這樣自定義View便可如Button等具有默認樣式。

最后嘗試下如果只有第三個構造函數會怎樣。結果報錯:

java.lang.RuntimeException: Unable to start activity ComponentInfo{com.tgtpp.themandsytle/com.tgtpp.themandsytle.MainActivity}: android.view.InflateException: Binary XML file line #22: Binary XML file line #22: Error inflating class com.tgtpp.themandsytle.MyView

看來希望可以正確inflate自定義View必須要有前兩個構造函數,第三個構造函數是類似Button那樣使用的。

View (Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)

Perform inflation from XML and apply a class-specific base style from a theme attribute or style resource. This constructor of View allows subclasses to use their own base style when they are inflating.

也就是直接傳入一個style的id,使得自定義View使用這個樣式。這里隨便找一個樣式R.style.Widget_AppCompat_Button傳入,注意到這個構造函數只支持api 21以上:

public class MyView extends View {
    public MyView(Context context) {
        super(context);
    }

    @TargetApi(21)
    public MyView(Context context, AttributeSet attributeSet){
        super(context, attributeSet, 0, R.style.Widget_AppCompat_Button);
    }

    @Override
    public void onDraw(Canvas c){
        super.onDraw(c);
    }
}

然后在xml中使用MyView:

<com.tgtpp.themandsytle.MyView
    android:layout_width="50dp"
    android:layout_height="50dp"
/>

效果如下:


構造函數4效果

優先級

至此我們可以看到,View的構造函數的后三個參數都可以決定此view的樣式,但是它們之間是有優先級的,如官網所說:

When determining the final value of a particular attribute, there are four inputs that come into play:
1.Any attribute values in the given AttributeSet.
2.The style resource specified in the AttributeSet (named "style").
3.The default style specified by defStyleAttr.
4.The default style specified by defStyleRes.
5.The base values in this theme.
Each of these inputs is considered in-order, with the first listed taking precedence over the following ones. In other words, if in the AttributeSet you have supplied <Button * textColor="#ff000000"> , then the button's text will always be black, regardless of what is specified in any of the styles.

思考

那么如果利用這四個構造函數優雅地自定義view呢?我想可以這樣:先自定義一個默認樣式,將id傳遞個第四個defStyleRes參數,這樣view就有了默認樣式。然后自定義attr,并將此id傳遞給第三個參數defStyleAttr,這樣如果其他人使用這個自定義view,需要根據主題更改樣式,就可以直接在theme中重定義此attr即可。使用第四個參數的好處是在一些簡單的應用下,不需再編寫theme。可惜如果需要支持低版本api,是無法這樣做的。

Drawing


onDraw(android.graphics.Canvas)

這個接口提供了一個Canvas,也就是一個畫布,可以在其上繪制想要繪制的內容。為了不發散太多,就不細講這個類了。上文有一個寫字的例子可以參考下。現在來看下這邊繪制的內容和View及樣式的關系。依舊使用R.style.Widget_AppCompat_Button作為默認樣式,現在在onDraw中隨意畫個圖片:

 @Override
    public void onDraw(Canvas c){
        super.onDraw(c);
        Bitmap b = BitmapFactory.decodeResource(getResources(),R.mipmap.ic_launcher);
        c.drawBitmap(b,0,0,null);
    }

在xml里調大MyView,并設置下margin:

<com.tgtpp.themandsytle.MyView
        android:layout_width="200dp"
        android:layout_height="200dp"
        android:layout_margin="30dp"
/>

效果如下:

onDraw1

可以看到,設置的樣式還在,圖片也成功地繪制在了其上,因此二者應該是互不影響,系統先繪制樣式,之后在其上再繪制onDraw內容。而繪制的坐標是基于MyView的。在xml中縮小MyView的長寬到30dp,效果如下:


onDraw2

注意到繪制的圖片并沒有隨之縮小,因此onDraw中的繪制不會像設置的style那樣自動適配view,還需手動進行相關設置。

Layout


void onSizeChanged (int w, int h, int oldw, int oldh)

當第一次確定當前view的大小或者大小改變時,會調用到此函數。w、h表示當前長寬,oldw、oldh表示之前長寬,第一次確定大小時這兩個值為0。如果只是想簡單控制view的繪制內容,用這個即可。這邊幾個參數的單位都是像素。

void onLayout (boolean changed, int left, int top, int right, int bottom)

當第一次確定當前view的大小或者大小改變時,會調用到此函數。與onSizeChanged不同的是,這個函數提供的left\top\right\bottom分別是相對于父view的左上和右下的坐標。比如前文MyView長寬為30dp,magin為30dp,在dpi為480的情況下,傳入的left\top\right\bottom分別是90\90\180\180,單位為像素。

void onMeasure (int widthMeasureSpec, int heightMeasureSpec)

想要更精確地控制,則可使用此函數。兩個參數是“packed”,即一個整數表示很多值,需要解析才能獲得有意義的值。

View.MeasureSpec

使用此類來解析onMeasure中的兩個參數。可以解析出兩個內容:size和mode。size就是長寬的值,mode描述了view的parent對其的限制,包括AT_MOST\EXACTLY\UNSPECIFIED(在某個范圍內想多大多大\指定了具體值\沒有任何限制)。

針對前文MyView長寬為30dp的情況,調用onMeasure并解析,方式就是將onMeasure中獲得的參數傳入View.MeasureSpec的getSize、getMode等函數中:

@Override
    public void onMeasure (int widthMeasureSpec, int heightMeasureSpec){
        super.onMeasure(widthMeasureSpec,heightMeasureSpec);
        Log.i("TEST", "width:size,mode,string:"+View.MeasureSpec.getSize(widthMeasureSpec)+","+View.MeasureSpec.getMode(widthMeasureSpec)+","+View.MeasureSpec.toString(widthMeasureSpec));
        Log.i("TEST", "height:size,mode,string:"+View.MeasureSpec.getSize(heightMeasureSpec)+","+View.MeasureSpec.getMode(heightMeasureSpec)+","+View.MeasureSpec.toString(heightMeasureSpec));
    }

結果如下:

QQ截圖20170623162518.png

Event processing


這邊事件處理其實包括了對于多種硬件設備的處理。對于目前占絕大多數的觸屏智能機,處理點擊事件使用:
boolean onTouchEvent (MotionEvent event)
而對于那種老式有硬件鍵盤的設備,提供了對按鍵的響應,并且文檔中特別說明,此響應并不一定對軟鍵盤起作用,不要用它們處理軟鍵盤的點擊事件:
boolean onKeyDown (int keyCode, KeyEvent event)
boolean onKeyUp (int keyCode, KeyEvent event)
這里還有對“trackball”事件的處理,也就是那種滾輪樣設備:
boolean onTrackballEvent (MotionEvent event)

Focus


void onFocusChanged (boolean gainFocus, int direction, Rect previouslyFocusedRect)

focus就是類似那種輸入框出現的時候會直接有輸入提示在那邊,這就是獲得了焦點。在onCreate中使用requestFocus令MyView獲得焦點,然后打印onFocusChanaged的三個參數:

@Override
    public void onFocusChanged (boolean gainFocus,
                         int direction,
                         Rect previouslyFocusedRect){
        super.onFocusChanged(gainFocus,direction,previouslyFocusedRect);
        Log.i("TEST", "gainFocurs,direction,rect:"+gainFocus+","+direction+","+previouslyFocusedRect);
    }

然而奇怪的是,打印結果總是gainFocus先是true,然后馬上變為false,而previouslyFocusedRect一直為null:

06-23 18:36:21.482 30740-30740/com.tgtpp.themandsytle I/TEST: gainFocurs,direction,rect:true,130,null
06-23 18:36:21.487 30740-30740/com.tgtpp.themandsytle I/TEST: gainFocurs,direction,rect:false,0,null

嘗試在xml中為MyView添加android:focusable="true",沒有效果;添加android:focusableInTouchMode="true"后,focus不再變為false。應該是因為在touch模式下不能requestFocus,所以會自動置focus為false。而previouslyFocusedRect多次嘗試,一直為null,這個問題之后有空再研究。

void onWindowFocusChanged (boolean hasWindowFocus)

經試驗,在應用被完全展現出來的時候會被調用且hasWindowFocus為true;縮小或消失會被調用且hasWindowFocus為false。

Attaching


當view和window相連之前和之后調用如下方法,一般說明view已經擁有或失去了繪制的空間:
void onAttachedToWindow ()
void onDetachedFromWindow ()
當windows可見性變化時調用如下方法:
void onDetachedFromWindow ()

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容