參考轉自郭霖博客帶你一步步深入瞭解View系列
-
Android LayoutInflater原理分析
相信接觸Android久一點的朋友對於LayoutInflater一定不會陌生,都會知道它主要是用於加載佈局的。而剛接觸Android的朋友可能對LayoutInflater不怎麼熟悉,因爲加載佈局的任務通常都是在Activity中調用setContentView()方法來完成的。其實setContentView()方法的內部也是使用LayoutInflater來加載佈局的,只不過這部分源碼是internal的,不太容易查看到。那麼今天我們就來把LayoutInflater的工作流程仔細地剖析一遍,也許還能解決掉某些困擾你心頭多年的疑惑。
先來看一下LayoutInflater的基本用法吧,它的用法非常簡單,首先需要獲取到LayoutInflater的實例,有兩種方法可以獲取到,第一種寫法如下:
當然,還有另外一種寫法也可以完成同樣的效果:
其實第一種就是第二種的簡單寫法,只是Android給我們做了一下封裝而已。得到了LayoutInflater的實例之後就可以調用它的inflate()方法來加載佈局了,如下所示:
inflate()方法一般接收兩個參數,第一個參數就是要加載的佈局id,第二個參數是指給該佈局的外部再嵌套一層父佈局,如果不需要就直接傳null。這樣就成功成功創建了一個佈局的實例,之後再將它添加到指定的位置就可以顯示出來了。
下面我們就通過一個非常簡單的小例子,來更加直觀地看一下LayoutInflater的用法。比如說當前有一個項目,其中MainActivity對應的佈局文件叫做activity_main.xml,代碼如下所示:
這個佈局文件的內容非常簡單,只有一個空的LinearLayout,裏面什麼控件都沒有,因此界面上應該不會顯示任何東西。
那麼接下來我們再定義一個佈局文件,給它取名爲button_layout.xml,代碼如下所示:
這個佈局文件也非常簡單,只有一個Button按鈕而已。現在我們要想辦法,如何通過LayoutInflater來將button_layout這個佈局添加到主佈局文件的LinearLayout中。根據剛剛介紹的用法,修改MainActivity中的代碼,如下所示:
可以看到,這裏先是獲取到了LayoutInflater的實例,然後調用它的inflate()方法來加載button_layout這個佈局,最後調用LinearLayout的addView()方法將它添加到LinearLayout中。
現在可以運行一下程序,結果如下圖所示:
Button在界面上顯示出來了!說明我們確實是藉助LayoutInflater成功將button_layout這個佈局添加到LinearLayout中了。LayoutInflater技術廣泛應用於需要動態添加View的時候,比如在ScrollView和ListView中,經常都可以看到LayoutInflater的身影。
當然,僅僅只是介紹瞭如何使用LayoutInflater顯然是遠遠無法滿足大家的求知慾的,知其然也要知其所以然,接下來我們就從源碼的角度上看一看LayoutInflater到底是如何工作的。
不管你是使用的哪個inflate()方法的重載,最終都會輾轉調用到LayoutInflater的如下代碼中:
從這裏我們就可以清楚地看出,LayoutInflater其實就是使用Android提供的pull解析方式來解析佈局文件的。不熟悉pull解析方式的朋友可以網上搜一下,教程很多,我就不細講了,這裏我們注意看下第23行,調用了createViewFromTag()這個方法,並把節點名和參數傳了進去。看到這個方法名,我們就應該能猜到,它是用於根據節點名來創建View對象的。確實如此,在createViewFromTag()方法的內部又會去調用createView()方法,然後使用反射的方式創建出View的實例並返回。
當然,這裏只是創建出了一個根佈局的實例而已,接下來會在第31行調用rInflate()方法來循環遍歷這個根佈局下的子元素,代碼如下所示:
可以看到,在第21行同樣是createViewFromTag()方法來創建View的實例,然後還會在第24行遞歸調用rInflate()方法來查找這個View下的子元素,每次遞歸完成後則將這個View添加到父佈局當中。
這樣的話,把整個佈局文件都解析完成後就形成了一個完整的DOM結構,最終會把最頂層的根佈局返回,至此inflate()過程全部結束。
比較細心的朋友也許會注意到,inflate()方法還有個接收三個參數的方法重載,結構如下:
那麼這第三個參數attachToRoot又是什麼意思呢?其實如果你仔細去閱讀上面的源碼應該可以自己分析出答案,這裏我先將結論說一下吧,感興趣的朋友可以再閱讀一下源碼,校驗我的結論是否正確。
1. 如果root爲null,attachToRoot將失去作用,設置任何值都沒有意義。
2. 如果root不爲null,attachToRoot設爲true,則會給加載的佈局文件的指定一個父佈局,即root。
3. 如果root不爲null,attachToRoot設爲false,則會將佈局文件最外層的所有layout屬性進行設置,當該view被添加到父view當中時,這些layout屬性會自動生效。
4. 在不設置attachToRoot參數的情況下,如果root不爲null,attachToRoot參數默認爲true。
好了,現在對LayoutInflater的工作原理和流程也搞清楚了,你該滿足了吧。額。。。。還嫌這個例子中的按鈕看起來有點小,想要調大一些?那簡單的呀,修改button_layout.xml中的代碼,如下所示:
這裏我們將按鈕的寬度改成300dp,高度改成80dp,這樣夠大了吧?現在重新運行一下程序來觀察效果。咦?怎麼按鈕還是原來的大小,沒有任何變化!是不是按鈕仍然不夠大,再改大一點呢?還是沒有用!
其實這裏不管你將Button的layout_width和layout_height的值修改成多少,都不會有任何效果的,因爲這兩個值現在已經完全失去了作用。平時我們經常使用layout_width和layout_height來設置View的大小,並且一直都能正常工作,就好像這兩個屬性確實是用於設置View的大小的。而實際上則不然,它們其實是用於設置View在佈局中的大小的,也就是說,首先View必須存在於一個佈局中,之後如果將layout_width設置成match_parent表示讓View的寬度填充滿布局,如果設置成wrap_content表示讓View的寬度剛好可以包含其內容,如果設置成具體的數值則View的寬度會變成相應的數值。這也是爲什麼這兩個屬性叫作layout_width和layout_height,而不是width和height。
再來看一下我們的button_layout.xml吧,很明顯Button這個控件目前不存在於任何佈局當中,所以layout_width和layout_height這兩個屬性理所當然沒有任何作用。那麼怎樣修改才能讓按鈕的大小改變呢?解決方法其實有很多種,最簡單的方式就是在Button的外面再嵌套一層佈局,如下所示:
可以看到,這裏我們又加入了一個RelativeLayout,此時的Button存在與RelativeLayout之中,layout_width和layout_height屬性也就有作用了。當然,處於最外層的RelativeLayout,它的layout_width和layout_height則會失去作用。現在重新運行一下程序,結果如下圖所示:
OK!按鈕的終於可以變大了,這下總算是滿足大家的要求了吧。
看到這裏,也許有些朋友心中會有一個巨大的疑惑。不對呀!平時在Activity中指定佈局文件的時候,最外層的那個佈局是可以指定大小的呀,layout_width和layout_height都是有作用的。確實,這主要是因爲,在setContentView()方法中,Android會自動在佈局文件的最外層再嵌套一個FrameLayout,所以layout_width和layout_height屬性纔會有效果。那麼我們來證實一下吧,修改MainActivity中的代碼,如下所示:
可以看到,這裏通過findViewById()方法,拿到了activity_main佈局中最外層的LinearLayout對象,然後調用它的getParent()方法獲取它的父佈局,再通過Log打印出來。現在重新運行一下程序,結果如下圖所示:
非常正確!LinearLayout的父佈局確實是一個FrameLayout,而這個FrameLayout就是由系統自動幫我們添加上的。
說到這裏,雖然setContentView()方法大家都會用,但實際上Android界面顯示的原理要比我們所看到的東西複雜得多。任何一個Activity中顯示的界面其實主要都由兩部分組成,標題欄和內容佈局。標題欄就是在很多界面頂部顯示的那部分內容,比如剛剛我們的那個例子當中就有標題欄,可以在代碼中控制讓它是否顯示。而內容佈局就是一個FrameLayout,這個佈局的id叫作content,我們調用setContentView()方法時所傳入的佈局其實就是放到這個FrameLayout中的,這也是爲什麼這個方法名叫作setContentView(),而不是叫setView()。
最後再附上一張Activity窗口的組成圖吧,以便於大家更加直觀地理解:
-
Android視圖繪製流程完全解析
Android中的任何一個佈局、任何一個控件其實都是直接或間接繼承自View的,如TextView、Button、ImageView、ListView等。這些控件雖然是Android系統本身就提供好的,我們只需要拿過來使用就可以了,但你知道它們是怎樣被繪製到屏幕上的嗎?多知道一些總是沒有壞處的,那麼我們趕快進入到本篇文章的正題內容吧。
要知道,任何一個視圖都不可能憑空突然出現在屏幕上,它們都是要經過非常科學的繪製流程後才能顯示出來的。每一個視圖的繪製過程都必須經歷三個最主要的階段,即onMeasure()、onLayout()和onDraw(),下面我們逐個對這三個階段展開進行探討。
一. onMeasure()
measure是測量的意思,那麼onMeasure()方法顧名思義就是用於測量視圖的大小的。View系統的繪製流程會從ViewRoot的performTraversals()方法中開始,在其內部調用View的measure()方法。measure()方法接收兩個參數,widthMeasureSpec和heightMeasureSpec,這兩個值分別用於確定視圖的寬度和高度的規格和大小。
MeasureSpec的值由specSize和specMode共同組成的,其中specSize記錄的是大小,specMode記錄的是規格。specMode一共有三種類型,如下所示:
1. EXACTLY
表示父視圖希望子視圖的大小應該是由specSize的值來決定的,系統默認會按照這個規則來設置子視圖的大小,開發人員當然也可以按照自己的意願設置成任意的大小。
2. AT_MOST
表示子視圖最多隻能是specSize中指定的大小,開發人員應該儘可能小得去設置這個視圖,並且保證不會超過specSize。系統默認會按照這個規則來設置子視圖的大小,開發人員當然也可以按照自己的意願設置成任意的大小。
3. UNSPECIFIED
表示開發人員可以將視圖按照自己的意願設置成任意的大小,沒有任何限制。這種情況比較少見,不太會用到。
那麼你可能會有疑問了,widthMeasureSpec和heightMeasureSpec這兩個值又是從哪裏得到的呢?通常情況下,這兩個值都是由父視圖經過計算後傳遞給子視圖的,說明父視圖會在一定程度上決定子視圖的大小。但是最外層的根視圖,它的widthMeasureSpec和heightMeasureSpec又是從哪裏得到的呢?這就需要去分析ViewRoot中的源碼了,觀察performTraversals()方法可以發現如下代碼:
可以看到,這裏調用了getRootMeasureSpec()方法去獲取widthMeasureSpec和heightMeasureSpec的值,注意方法中傳入的參數,其中lp.width和lp.height在創建ViewGroup實例的時候就被賦值了,它們都等於MATCH_PARENT。然後看下getRootMeasureSpec()方法中的代碼,如下所示:
可以看到,這裏使用了MeasureSpec.makeMeasureSpec()方法來組裝一個MeasureSpec,當rootDimension參數等於MATCH_PARENT的時候,MeasureSpec的specMode就等於EXACTLY,當rootDimension等於WRAP_CONTENT的時候,MeasureSpec的specMode就等於AT_MOST。並且MATCH_PARENT和WRAP_CONTENT時的specSize都是等於windowSize的,也就意味着根視圖總是會充滿全屏的。
介紹了這麼多MeasureSpec相關的內容,接下來我們看下View的measure()方法裏面的代碼吧,如下所示:
注意觀察,measure()這個方法是final的,因此我們無法在子類中去重寫這個方法,說明Android是不允許我們改變View的measure框架的。然後在第9行調用了onMeasure()方法,這裏纔是真正去測量並設置View大小的地方,默認會調用getDefaultSize()方法來獲取視圖的大小,如下所示:
這裏傳入的measureSpec是一直從measure()方法中傳遞過來的。然後調用MeasureSpec.getMode()方法可以解析出specMode,調用MeasureSpec.getSize()方法可以解析出specSize。接下來進行判斷,如果specMode等於AT_MOST或EXACTLY就返回specSize,這也是系統默認的行爲。之後會在onMeasure()方法中調用setMeasuredDimension()方法來設定測量出的大小,這樣一次measure過程就結束了。
當然,一個界面的展示可能會涉及到很多次的measure,因爲一個佈局中一般都會包含多個子視圖,每個視圖都需要經歷一次measure過程。ViewGroup中定義了一個measureChildren()方法來去測量子視圖的大小,如下所示:
這裏首先會去遍歷當前佈局下的所有子視圖,然後逐個調用measureChild()方法來測量相應子視圖的大小,如下所示:
可以看到,在第4行和第6行分別調用了getChildMeasureSpec()方法來去計算子視圖的MeasureSpec,計算的依據就是佈局文件中定義的MATCH_PARENT、WRAP_CONTENT等值,這個方法的內部細節就不再貼出。然後在第8行調用子視圖的measure()方法,並把計算出的MeasureSpec傳遞進去,之後的流程就和前面所介紹的一樣了。
當然,onMeasure()方法是可以重寫的,也就是說,如果你不想使用系統默認的測量方式,可以按照自己的意願進行定製,比如:
這樣的話就把View默認的測量流程覆蓋掉了,不管在佈局文件中定義MyView這個視圖的大小是多少,最終在界面上顯示的大小都將會是200*200。
需要注意的是,在setMeasuredDimension()方法調用之後,我們才能使用getMeasuredWidth()和getMeasuredHeight()來獲取視圖測量出的寬高,以此之前調用這兩個方法得到的值都會是0。
由此可見,視圖大小的控制是由父視圖、佈局文件、以及視圖本身共同完成的,父視圖會提供給子視圖參考的大小,而開發人員可以在XML文件中指定視圖的大小,然後視圖本身會對最終的大小進行拍板。
到此爲止,我們就把視圖繪製流程的第一階段分析完了。
二. onLayout()
measure過程結束後,視圖的大小就已經測量好了,接下來就是layout的過程了。正如其名字所描述的一樣,這個方法是用於給視圖進行佈局的,也就是確定視圖的位置。ViewRoot的performTraversals()方法會在measure結束後繼續執行,並調用View的layout()方法來執行此過程,如下所示:
layout()方法接收四個參數,分別代表着左、上、右、下的座標,當然這個座標是相對於當前視圖的父視圖而言的。可以看到,這裏還把剛纔測量出的寬度和高度傳到了layout()方法中。那麼我們來看下layout()方法中的代碼是什麼樣的吧,如下所示:
在layout()方法中,首先會調用setFrame()方法來判斷視圖的大小是否發生過變化,以確定有沒有必要對當前的視圖進行重繪,同時還會在這裏把傳遞過來的四個參數分別賦值給mLeft、mTop、mRight和mBottom這幾個變量。接下來會在第11行調用onLayout()方法,正如onMeasure()方法中的默認行爲一樣,也許你已經迫不及待地想知道onLayout()方法中的默認行爲是什麼樣的了。進入onLayout()方法,咦?怎麼這是個空方法,一行代碼都沒有?!
沒錯,View中的onLayout()方法就是一個空方法,因爲onLayout()過程是爲了確定視圖在佈局中所在的位置,而這個操作應該是由佈局來完成的,即父視圖決定子視圖的顯示位置。既然如此,我們來看下ViewGroup中的onLayout()方法是怎麼寫的吧,代碼如下:
可以看到,ViewGroup中的onLayout()方法竟然是一個抽象方法,這就意味着所有ViewGroup的子類都必須重寫這個方法。沒錯,像LinearLayout、RelativeLayout等佈局,都是重寫了這個方法,然後在內部按照各自的規則對子視圖進行佈局的。由於LinearLayout和RelativeLayout的佈局規則都比較複雜,就不單獨拿出來進行分析了,這裏我們嘗試自定義一個佈局,藉此來更深刻地理解onLayout()的過程。
自定義的這個佈局目標很簡單,只要能夠包含一個子視圖,並且讓子視圖正常顯示出來就可以了。那麼就給這個佈局起名叫做SimpleLayout吧,代碼如下所示:
代碼非常的簡單,我們來看下具體的邏輯吧。你已經知道,onMeasure()方法會在onLayout()方法之前調用,因此這裏在onMeasure()方法中判斷SimpleLayout中是否有包含一個子視圖,如果有的話就調用measureChild()方法來測量出子視圖的大小。
接着在onLayout()方法中同樣判斷SimpleLayout是否有包含一個子視圖,然後調用這個子視圖的layout()方法來確定它在SimpleLayout佈局中的位置,這裏傳入的四個參數依次是0、0、childView.getMeasuredWidth()和childView.getMeasuredHeight(),分別代表着子視圖在SimpleLayout中左上右下四個點的座標。其中,調用childView.getMeasuredWidth()和childView.getMeasuredHeight()方法得到的值就是在onMeasure()方法中測量出的寬和高。
這樣就已經把SimpleLayout這個佈局定義好了,下面就是在XML文件中使用它了,如下所示:
可以看到,我們能夠像使用普通的佈局文件一樣使用SimpleLayout,只是注意它只能包含一個子視圖,多餘的子視圖會被捨棄掉。這裏SimpleLayout中包含了一個ImageView,並且ImageView的寬高都是wrap_content。現在運行一下程序,結果如下圖所示:
OK!ImageView成功已經顯示出來了,並且顯示的位置也正是我們所期望的。如果你想改變ImageView顯示的位置,只需要改變childView.layout()方法的四個參數就行了。
在onLayout()過程結束後,我們就可以調用getWidth()方法和getHeight()方法來獲取視圖的寬高了。說到這裏,我相信很多朋友長久以來都會有一個疑問,getWidth()方法和getMeasureWidth()方法到底有什麼區別呢?它們的值好像永遠都是相同的。其實它們的值之所以會相同基本都是因爲佈局設計者的編碼習慣非常好,實際上它們之間的差別還是挺大的。
首先getMeasureWidth()方法在measure()過程結束後就可以獲取到了,而getWidth()方法要在layout()過程結束後才能獲取到。另外,getMeasureWidth()方法中的值是通過setMeasuredDimension()方法來進行設置的,而getWidth()方法中的值則是通過視圖右邊的座標減去左邊的座標計算出來的。
觀察SimpleLayout中onLayout()方法的代碼,這裏給子視圖的layout()方法傳入的四個參數分別是0、0、childView.getMeasuredWidth()和childView.getMeasuredHeight(),因此getWidth()方法得到的值就是childView.getMeasuredWidth() - 0 = childView.getMeasuredWidth() ,所以此時getWidth()方法和getMeasuredWidth() 得到的值就是相同的,但如果你將onLayout()方法中的代碼進行如下修改:
這樣getWidth()方法得到的值就是200 - 0 = 200,不會再和getMeasuredWidth()的值相同了。當然這種做法充分不尊重measure()過程計算出的結果,通常情況下是不推薦這麼寫的。getHeight()與getMeasureHeight()方法之間的關係同上,就不再重複分析了。
到此爲止,我們把視圖繪製流程的第二階段也分析完了。
三. onDraw()
measure和layout的過程都結束後,接下來就進入到draw的過程了。同樣,根據名字你就能夠判斷出,在這裏才真正地開始對視圖進行繪製。ViewRoot中的代碼會繼續執行並創建出一個Canvas對象,然後調用View的draw()方法來執行具體的繪製工作。draw()方法內部的繪製過程總共可以分爲六步,其中第二步和第五步在一般情況下很少用到,因此這裏我們只分析簡化後的繪製過程。代碼如下所示:
可以看到,第一步是從第9行代碼開始的,這一步的作用是對視圖的背景進行繪製。這裏會先得到一個mBGDrawable對象,然後根據layout過程確定的視圖位置來設置背景的繪製區域,之後再調用Drawable的draw()方法來完成背景的繪製工作。那麼這個mBGDrawable對象是從哪裏來的呢?其實就是在XML中通過android:background屬性設置的圖片或顏色。當然你也可以在代碼中通過setBackgroundColor()、setBackgroundResource()等方法進行賦值。
接下來的第三步是在第34行執行的,這一步的作用是對視圖的內容進行繪製。可以看到,這裏去調用了一下onDraw()方法,那麼onDraw()方法裏又寫了什麼代碼呢?進去一看你會發現,原來又是個空方法啊。其實也可以理解,因爲每個視圖的內容部分肯定都是各不相同的,這部分的功能交給子類來去實現也是理所當然的。
第三步完成之後緊接着會執行第四步,這一步的作用是對當前視圖的所有子視圖進行繪製。但如果當前的視圖沒有子視圖,那麼也就不需要進行繪製了。因此你會發現View中的dispatchDraw()方法又是一個空方法,而ViewGroup的dispatchDraw()方法中就會有具體的繪製代碼。
以上都執行完後就會進入到第六步,也是最後一步,這一步的作用是對視圖的滾動條進行繪製。那麼你可能會奇怪,當前的視圖又不一定是ListView或者ScrollView,爲什麼要繪製滾動條呢?其實不管是Button也好,TextView也好,任何一個視圖都是有滾動條的,只是一般情況下我們都沒有讓它顯示出來而已。繪製滾動條的代碼邏輯也比較複雜,這裏就不再貼出來了,因爲我們的重點是第三步過程。
通過以上流程分析,相信大家已經知道,View是不會幫我們繪製內容部分的,因此需要每個視圖根據想要展示的內容來自行繪製。如果你去觀察TextView、ImageView等類的源碼,你會發現它們都有重寫onDraw()這個方法,並且在裏面執行了相當不少的繪製邏輯。繪製的方式主要是藉助Canvas這個類,它會作爲參數傳入到onDraw()方法中,供給每個視圖使用。Canvas這個類的用法非常豐富,基本可以把它當成一塊畫布,在上面繪製任意的東西,那麼我們就來嘗試一下吧。
這裏簡單起見,我只是創建一個非常簡單的視圖,並且用Canvas隨便繪製了一點東西,代碼如下所示:
可以看到,我們創建了一個自定義的MyView繼承自View,並在MyView的構造函數中創建了一個Paint對象。Paint就像是一個畫筆一樣,配合着Canvas就可以進行繪製了。這裏我們的繪製邏輯比較簡單,在onDraw()方法中先是把畫筆設置成黃色,然後調用Canvas的drawRect()方法繪製一個矩形。然後在把畫筆設置成藍色,並調整了一下文字的大小,然後調用drawText()方法繪製了一段文字。
就這麼簡單,一個自定義的視圖就已經寫好了,現在可以在XML中加入這個視圖,如下所示:
將MyView的寬度設置成200dp,高度設置成100dp,然後運行一下程序,結果如下圖所示:
圖中顯示的內容也正是MyView這個視圖的內容部分了。由於我們沒給MyView設置背景,因此這裏看不出來View自動繪製的背景效果。
當然了Canvas的用法還有很多很多,這裏我不可能把Canvas的所有用法都列舉出來,剩下的就要靠大家自行去研究和學習了
-
Android視圖狀態及重繪流程分析
大家在平時使用View的時候都會發現它是有狀態的,比如說有一個按鈕,普通狀態下是一種效果,但是當手指按下的時候就會變成另外一種效果,這樣纔會給人產生一種點擊了按鈕的感覺。當然了,這種效果相信幾乎所有的Android程序員都知道該如何實現,但是我們既然是深入瞭解View,那麼自然也應該知道它背後的實現原理應該是什麼樣的,今天就讓我們來一起探究一下吧。
一、視圖狀態
視圖狀態的種類非常多,一共有十幾種類型,不過多數情況下我們只會使用到其中的幾種,因此這裏我們也就只去分析最常用的幾種視圖狀態。
1. enabled
表示當前視圖是否可用。可以調用setEnable()方法來改變視圖的可用狀態,傳入true表示可用,傳入false表示不可用。它們之間最大的區別在於,不可用的視圖是無法響應onTouch事件的。
2. focused
表示當前視圖是否獲得到焦點。通常情況下有兩種方法可以讓視圖獲得焦點,即通過鍵盤的上下左右鍵切換視圖,以及調用requestFocus()方法。而現在的Android手機幾乎都沒有鍵盤了,因此基本上只可以使用requestFocus()這個辦法來讓視圖獲得焦點了。而requestFocus()方法也不能保證一定可以讓視圖獲得焦點,它會有一個布爾值的返回值,如果返回true說明獲得焦點成功,返回false說明獲得焦點失敗。一般只有視圖在focusable和focusable in touch mode同時成立的情況下才能成功獲取焦點,比如說EditText。
3. window_focused
表示當前視圖是否處於正在交互的窗口中,這個值由系統自動決定,應用程序不能進行改變。
4. selected
表示當前視圖是否處於選中狀態。一個界面當中可以有多個視圖處於選中狀態,調用setSelected()方法能夠改變視圖的選中狀態,傳入true表示選中,傳入false表示未選中。
5. pressed
表示當前視圖是否處於按下狀態。可以調用setPressed()方法來對這一狀態進行改變,傳入true表示按下,傳入false表示未按下。通常情況下這個狀態都是由系統自動賦值的,但開發者也可以自己調用這個方法來進行改變。
我們可以在項目的drawable目錄下創建一個selector文件,在這裏配置每種狀態下視圖對應的背景圖片。比如創建一個compose_bg.xml文件,在裏面編寫如下代碼:
這段代碼就表示,當視圖處於正常狀態的時候就顯示compose_normal這張背景圖,當視圖獲得到焦點或者被按下的時候就顯示compose_pressed這張背景圖。
創建好了這個selector文件後,我們就可以在佈局或代碼中使用它了,比如將它設置爲某個按鈕的背景圖,如下所示:
現在運行一下程序,這個按鈕在普通狀態和按下狀態的時候就會顯示不同的背景圖片,如下圖所示:
這樣我們就用一個非常簡單的方法實現了按鈕按下的效果,但是它的背景原理到底是怎樣的呢?這就又要從源碼的層次上進行分析了。
我們都知道,當手指按在視圖上的時候,視圖的狀態就已經發生了變化,此時視圖的pressed狀態是true。每當視圖的狀態有發生改變的時候,就會回調View的drawableStateChanged()方法,代碼如下所示:
在這裏的第一步,首先是將mBGDrawable賦值給一個Drawable對象,那麼這個mBGDrawable是什麼呢?觀察setBackgroundResource()方法中的代碼,如下所示:
可以看到,在第7行調用了Resource的getDrawable()方法將resid轉換成了一個Drawable對象,然後調用了setBackgroundDrawable()方法並將這個Drawable對象傳入,在setBackgroundDrawable()方法中會將傳入的Drawable對象賦值給mBGDrawable。
而我們在佈局文件中通過android:background屬性指定的selector文件,效果等同於調用setBackgroundResource()方法。也就是說drawableStateChanged()方法中的mBGDrawable對象其實就是我們指定的selector文件。
接下來在drawableStateChanged()方法的第4行調用了getDrawableState()方法來獲取視圖狀態,代碼如下所示:
在這裏首先會判斷當前視圖的狀態是否發生了改變,如果沒有改變就直接返回當前的視圖狀態,如果發生了改變就調用onCreateDrawableState()方法來獲取最新的視圖狀態。視圖的所有狀態會以一個整型數組的形式返回。
在得到了視圖狀態的數組之後,就會調用Drawable的setState()方法來對狀態進行更新,代碼如下所示:
這裏會調用Arrays.equals()方法來判斷視圖狀態的數組是否發生了變化,如果發生了變化則調用onStateChange()方法,否則就直接返回false。但你會發現,Drawable的onStateChange()方法中其實就只是簡單返回了一個false,並沒有任何的邏輯處理,這是爲什麼呢?這主要是因爲mBGDrawable對象是通過一個selector文件創建出來的,而通過這種文件創建出來的Drawable對象其實都是一個StateListDrawable實例,因此這裏調用的onStateChange()方法實際上調用的是StateListDrawable中的onStateChange()方法,那麼我們趕快看一下吧:
可以看到,這裏會先調用indexOfStateSet()方法來找到當前視圖狀態所對應的Drawable資源下標,然後在第9行調用selectDrawable()方法並將下標傳入,在這個方法中就會將視圖的背景圖設置爲當前視圖狀態所對應的那張圖片了。
那你可能會有疑問,在前面一篇文章中我們說到,任何一個視圖的顯示都要經過非常科學的繪製流程的,很顯然,背景圖的繪製是在draw()方法中完成的,那麼爲什麼selectDrawable()方法能夠控制背景圖的改變呢?這就要研究一下視圖重繪的流程了。
二、視圖重繪
雖然視圖會在Activity加載完成之後自動繪製到屏幕上,但是我們完全有理由在與Activity進行交互的時候要求動態更新視圖,比如改變視圖的狀態、以及顯示或隱藏某個控件等。那在這個時候,之前繪製出的視圖其實就已經過期了,此時我們就應該對視圖進行重繪。
調用視圖的setVisibility()、setEnabled()、setSelected()等方法時都會導致視圖重繪,而如果我們想要手動地強制讓視圖進行重繪,可以調用invalidate()方法來實現。當然了,setVisibility()、setEnabled()、setSelected()等方法的內部其實也是通過調用invalidate()方法來實現的,那麼就讓我們來看一看invalidate()方法的代碼是什麼樣的吧。
View的源碼中會有數個invalidate()方法的重載和一個invalidateDrawable()方法,當然它們的原理都是相同的,因此我們只分析其中一種,代碼如下所示:
在這個方法中首先會調用skipInvalidate()方法來判斷當前View是否需要重繪,判斷的邏輯也比較簡單,如果View是不可見的且沒有執行任何動畫,就認爲不需要重繪了。之後會進行透明度的判斷,並給View添加一些標記位,然後在第22和29行調用ViewParent的invalidateChild()方法,這裏的ViewParent其實就是當前視圖的父視圖,因此會調用到ViewGroup的invalidateChild()方法中,代碼如下所示:
可以看到,這裏在第10行進入了一個while循環,當ViewParent不等於空的時候就會一直循環下去。在這個while循環當中會不斷地獲取當前佈局的父佈局,並調用它的invalidateChildInParent()方法,在ViewGroup的invalidateChildInParent()方法中主要是來計算需要重繪的矩形區域,這裏我們先不管它,當循環到最外層的根佈局後,就會調用ViewRoot的invalidateChildInParent()方法了,代碼如下所示:
這裏的代碼非常簡單,僅僅是去調用了invalidateChild()方法而已,那我們再跟進去瞧一瞧吧:
這個方法也不長,它在第6行又調用了scheduleTraversals()這個方法,那麼我們繼續跟進:
可以看到,這裏調用了sendEmptyMessage()方法,並傳入了一個DO_TRAVERSAL參數。瞭解Android異步消息處理機制的朋友們都會知道,任何一個Handler都可以調用sendEmptyMessage()方法來發送消息,並且在handleMessage()方法中接收消息,而如果你看一下ViewRoot的類定義就會發現,它是繼承自Handler的,也就是說這裏調用sendEmptyMessage()方法出的消息,會在ViewRoot的handleMessage()方法中接收到。那麼趕快看一下handleMessage()方法的代碼吧,如下所示:
熟悉的代碼出現了!這裏在第7行調用了performTraversals()方法,這不就是我們在前面一篇文章中學到的視圖繪製的入口嗎?雖然經過了很多輾轉的調用,但是可以確定的是,調用視圖的invalidate()方法後確實會走到performTraversals()方法中,然後重新執行繪製流程。之後的流程就不需要再進行描述了吧,可以參考 Android視圖繪製流程完全解析,帶你一步步深入瞭解View(二) 這一篇文章。
瞭解了這些之後,我們再回過頭來看看剛纔的selectDrawable()方法中到底做了什麼才能夠控制背景圖的改變,代碼如下所示:
這裏前面的代碼我們可以都不管,關鍵是要看到在第54行一定會調用invalidateSelf()方法,這個方法中的代碼如下所示:
可以看到,這裏會先調用getCallback()方法獲取Callback接口的回調實例,然後再去調用回調實例的invalidateDrawable()方法。那麼這裏的回調實例又是什麼呢?觀察一下View的類定義其實你就知道了,如下所示:
View類正是實現了Callback接口,所以剛纔其實調用的就是View中的invalidateDrawable()方法,之後就會按照我們前面分析的流程執行重繪邏輯,所以視圖的背景圖才能夠得到改變的。
另外需要注意的是,invalidate()方法雖然最終會調用到performTraversals()方法中,但這時measure和layout流程是不會重新執行的,因爲視圖沒有強制重新測量的標誌位,而且大小也沒有發生過變化,所以這時只有draw流程可以得到執行。而如果你希望視圖的繪製流程可以完完整整地重新走一遍,就不能使用invalidate()方法,而應該調用requestLayout()了。這個方法中的流程比invalidate()方法要簡單一些,但中心思想是差不多的,這裏也就不再詳細進行分析了。
-
Android自定義View的實現方法
如果說要按類型來劃分的話,自定義View的實現方式大概可以分爲三種,自繪控件、組合控件、以及繼承控件。那麼下面我們就來依次學習一下,每種方式分別是如何自定義View的。
一、自繪控件
自繪控件的意思就是,這個View上所展現的內容全部都是我們自己繪製出來的。繪製的代碼是寫在onDraw()方法中的,而這部分內容我們已經在 Android視圖繪製流程完全解析,帶你一步步深入瞭解View(二) 中學習過了。
下面我們準備來自定義一個計數器View,這個View可以響應用戶的點擊事件,並自動記錄一共點擊了多少次。新建一個CounterView繼承自View,代碼如下所示:
可以看到,首先我們在CounterView的構造函數中初始化了一些數據,並給這個View的本身註冊了點擊事件,這樣當CounterView被點擊的時候,onClick()方法就會得到調用。而onClick()方法中的邏輯就更加簡單了,只是對mCount這個計數器加1,然後調用invalidate()方法。通過 Android視圖狀態及重繪流程分析,帶你一步步深入瞭解View(三) 這篇文章的學習我們都已經知道,調用invalidate()方法會導致視圖進行重繪,因此onDraw()方法在稍後就將會得到調用。
既然CounterView是一個自繪視圖,那麼最主要的邏輯當然就是寫在onDraw()方法裏的了,下面我們就來仔細看一下。這裏首先是將Paint畫筆設置爲藍色,然後調用Canvas的drawRect()方法繪製了一個矩形,這個矩形也就可以當作是CounterView的背景圖吧。接着將畫筆設置爲黃色,準備在背景上面繪製當前的計數,注意這裏先是調用了getTextBounds()方法來獲取到文字的寬度和高度,然後調用了drawText()方法去進行繪製就可以了。
這樣,一個自定義的View就已經完成了,並且目前這個CounterView是具備自動計數功能的。那麼剩下的問題就是如何讓這個View在界面上顯示出來了,其實這也非常簡單,我們只需要像使用普通的控件一樣來使用CounterView就可以了。比如在佈局文件中加入如下代碼:
可以看到,這裏我們將CounterView放入了一個RelativeLayout中,然後可以像使用普通控件來給CounterView指定各種屬性,比如通過layout_width和layout_height來指定CounterView的寬高,通過android:layout_centerInParent來指定它在佈局里居中顯示。只不過需要注意,自定義的View在使用的時候一定要寫出完整的包名,不然系統將無法找到這個View。
好了,就是這麼簡單,接下來我們可以運行一下程序,並不停地點擊CounterView,效果如下圖所示。
怎麼樣?是不是感覺自定義View也並不是什麼高級的技術,簡單幾行代碼就可以實現了。當然了,這個CounterView功能非常簡陋,只有一個計數功能,因此只需幾行代碼就足夠了,當你需要繪製比較複雜的View時,還是需要很多技巧的。
二、組合控件
組合控件的意思就是,我們並不需要自己去繪製視圖上顯示的內容,而只是用系統原生的控件就好了,但我們可以將幾個系統原生的控件組合到一起,這樣創建出的控件就被稱爲組合控件。
舉個例子來說,標題欄就是個很常見的組合控件,很多界面的頭部都會放置一個標題欄,標題欄上會有個返回按鈕和標題,點擊按鈕後就可以返回到上一個界面。那麼下面我們就來嘗試去實現這樣一個標題欄控件。
新建一個title.xml佈局文件,代碼如下所示:
在這個佈局文件中,我們首先定義了一個RelativeLayout作爲背景佈局,然後在這個佈局裏定義了一個Button和一個TextView,Button就是標題欄中的返回按鈕,TextView就是標題欄中的顯示的文字。
接下來創建一個TitleView繼承自FrameLayout,代碼如下所示:
TitleView中的代碼非常簡單,在TitleView的構建方法中,我們調用了LayoutInflater的inflate()方法來加載剛剛定義的title.xml佈局,這部分內容我們已經在 Android LayoutInflater原理分析,帶你一步步深入瞭解View(一) 這篇文章中學習過了。
接下來調用findViewById()方法獲取到了返回按鈕的實例,然後在它的onClick事件中調用finish()方法來關閉當前的Activity,也就相當於實現返回功能了。
另外,爲了讓TitleView有更強地擴展性,我們還提供了setTitleText()、setLeftButtonText()、setLeftButtonListener()等方法,分別用於設置標題欄上的文字、返回按鈕上的文字、以及返回按鈕的點擊事件。
到了這裏,一個自定義的標題欄就完成了,那麼下面又到了如何引用這個自定義View的部分,其實方法基本都是相同的,在佈局文件中添加如下代碼:
這樣就成功將一個標題欄控件引入到佈局文件中了,運行一下程序,效果如下圖所示:
現在點擊一下Back按鈕,就可以關閉當前的Activity了。如果你想要修改標題欄上顯示的內容,或者返回按鈕的默認事件,只需要在Activity中通過findViewById()方法得到TitleView的實例,然後調用setTitleText()、setLeftButtonText()、setLeftButtonListener()等方法進行設置就OK了。
三、繼承控件
繼承控件的意思就是,我們並不需要自己重頭去實現一個控件,只需要去繼承一個現有的控件,然後在這個控件上增加一些新的功能,就可以形成一個自定義的控件了。這種自定義控件的特點就是不僅能夠按照我們的需求加入相應的功能,還可以保留原生控件的所有功能,比如 Android PowerImageView實現,可以播放動畫的強大ImageView 這篇文章中介紹的PowerImageView就是一個典型的繼承控件。
爲了能夠加深大家對這種自定義View方式的理解,下面我們再來編寫一個新的繼承控件。ListView相信每一個Android程序員都一定使用過,這次我們準備對ListView進行擴展,加入在ListView上滑動就可以顯示出一個刪除按鈕,點擊按鈕就會刪除相應數據的功能。
首先需要準備一個刪除按鈕的佈局,新建delete_button.xml文件,代碼如下所示:
這個佈局文件很簡單,只有一個按鈕而已,並且我們給這個按鈕指定了一張刪除背景圖。
接着創建MyListView繼承自ListView,這就是我們自定義的View了,代碼如下所示:
由於代碼邏輯比較簡單,我就沒有加註釋。這裏在MyListView的構造方法中創建了一個GestureDetector的實例用於監聽手勢,然後給MyListView註冊了touch監聽事件。然後在onTouch()方法中進行判斷,如果刪除按鈕已經顯示了,就將它移除掉,如果刪除按鈕沒有顯示,就使用GestureDetector來處理當前手勢。
當手指按下時,會調用OnGestureListener的onDown()方法,在這裏通過pointToPosition()方法來判斷出當前選中的是ListView的哪一行。當手指快速滑動時,會調用onFling()方法,在這裏會去加載delete_button.xml這個佈局,然後將刪除按鈕添加到當前選中的那一行item上。注意,我們還給刪除按鈕添加了一個點擊事件,當點擊了刪除按鈕時就會回調onDeleteListener的onDelete()方法,在回調方法中應該去處理具體的刪除操作。
好了,自定義View的功能到此就完成了,接下來我們需要看一下如何才能使用這個自定義View。首先需要創建一個ListView子項的佈局文件,新建my_list_view_item.xml,代碼如下所示:
然後創建一個適配器MyAdapter,在這個適配器中去加載my_list_view_item佈局,代碼如下所示:
到這裏就基本已經完工了,下面在程序的主佈局文件裏面引入MyListView這個控件,如下所示:
最後在Activity中初始化MyListView中的數據,並處理了onDelete()方法的刪除邏輯,代碼如下所示:
這樣就把整個例子的代碼都完成了,現在運行一下程序,會看到MyListView可以像ListView一樣,正常顯示所有的數據,但是當你用手指在MyListView的某一行上快速滑動時,就會有一個刪除按鈕顯示出來,如下圖所示:
-
下面是對之前的複習與總結的一個小例子:
1.自定義View
首先我們要明白,爲什麼要自定義View?主要是Android系統內置的View無法實現我們的需求,我們需要針對我們的業務需求定製我們想要的View。自定義View我們大部分時候只需重寫兩個函數:onMeasure()、onDraw()。onMeasure負責對當前View的尺寸進行測量,onDraw負責把當前這個View繪製出來。當然了,你還得寫至少寫2個構造函數:
public MyView(Context context) {
super(context);
}
public MyView(Context context, AttributeSet attrs) {
super(context, attrs);
}
1.1.onMeasure
我們自定義的View,首先得要測量寬高尺寸。爲什麼要測量寬高尺寸?我在剛學自定義View的時候非常無法理解!因爲我當時覺得,我在xml文件中已經指定好了寬高尺寸了,我自定義View中有必要再次獲取寬高並設置寬高嗎?既然我自定義的View是繼承自View類,google團隊直接在View類中直接把xml設置的寬高獲取,並且設置進去不就好了嗎?那google爲啥讓我們做這樣的“重複工作”呢?客官別急,馬上給您上茶~
在學習Android的時候,我們就知道,在xml佈局文件中,我們的layout_width
和layout_height
參數可以不用寫具體的尺寸,而是wrap_content
或者是match_parent
。其意思我們都知道,就是將尺寸設置爲“包住內容”和“填充父佈局給我們的所有空間”。這兩個設置並沒有指定真正的大小,可是我們繪製到屏幕上的View必須是要有具體的寬高的,正是因爲這個原因,我們必須自己去處理和設置尺寸。當然了,View類給了默認的處理,但是如果View類的默認處理不滿足我們的要求,我們就得重寫onMeasure函數啦~。這裏舉個例子,比如我們希望我們的View是個正方形,如果在xml中指定寬高爲wrap_content
,如果使用View類提供的measure處理方式,顯然無法滿足我們的需求~。
先看看onMeasure函數原型:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
參數中的widthMeasureSpec
和heightMeasureSpec
是個什麼鬼?看起來很像width和height,沒錯,這兩個參數就是包含寬和高的信息。什麼?包含?難道還要其他信息?是的!它還包含測量模式,也就是說,一個int整數,裏面放了測量模式和尺寸大小。那麼一個數怎麼放兩個信息呢?我們知道,我們在設置寬高時有3個選擇:wrap_content
、match_parent
以及指定固定尺寸
,而測量模式也有3種:UNSPECIFIED
,EXACTLY
,AT_MOST
,當然,他們並不是一一對應關係哈,這三種模式後面我會詳細介紹,但測量模式無非就是這3種情況,而如果使用二進制,我們只需要使用2個bit就可以做到,因爲2個bit取值範圍是[0,3]裏面可以存放4個數足夠我們用了。那麼Google是怎麼把一個int同時放測量模式和尺寸信息呢?我們知道int型數據佔用32個bit,而google實現的是,將int數據的前面2個bit用於區分不同的佈局模式,後面30個bit存放的是尺寸的數據。
那我們怎麼從int數據中提取測量模式和尺寸呢?放心,不用你每次都要寫一次移位<<
和取且&
操作,Android內置類MeasureSpec幫我們寫好啦~,我們只需按照下面方法就可以拿到啦:
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
愛思考的你肯定會問,既然我們能通過widthMeasureSpec拿到寬度尺寸大小,那我們還要測量模式幹嘛?測量模式會不會是多餘的?請注意:這裏的的尺寸大小並不是最終我們的View的尺寸大小,而是父View提供的參考大小。我們看看測量模式,測量模式是幹啥用的呢?
測量模式 | 表示意思 |
---|---|
UNSPECIFIED | 父容器沒有對當前View有任何限制,當前View可以任意取尺寸 |
EXACTLY | 當前的尺寸就是當前View應該取的尺寸 |
AT_MOST | 當前尺寸是當前View能取的最大尺寸 |
而上面的測量模式跟我們的佈局時的wrap_content
、match_parent
以及寫成固定的尺寸有什麼對應關係呢?
match_parent
--->EXACTLY。怎麼理解呢?match_parent
就是要利用父View給我們提供的所有剩餘空間,而父View剩餘空間是確定的,也就是這個測量模式的整數裏面存放的尺寸。
wrap_content
--->AT_MOST。怎麼理解:就是我們想要將大小設置爲包裹我們的view內容,那麼尺寸大小就是父View給我們作爲參考的尺寸,只要不超過這個尺寸就可以啦,具體尺寸就根據我們的需求去設定。
固定尺寸(如100dp)
--->EXACTLY。用戶自己指定了尺寸大小,我們就不用再去幹涉了,當然是以指定的大小爲主啦。
1.2.動手重寫onMeasure函數
上面講了太多理論,我們實際操作一下吧,感受一下onMeasure的使用,假設我們要實現這樣一個效果:將當前的View以正方形的形式顯示,即要寬高相等,並且默認的寬高值爲100像素。就可以這些編寫:
private int getMySize(int defaultSize, int measureSpec) {
int mySize = defaultSize;
int mode = MeasureSpec.getMode(measureSpec);
int size = MeasureSpec.getSize(measureSpec);
switch (mode) {
case MeasureSpec.UNSPECIFIED: {//如果沒有指定大小,就設置爲默認大小
mySize = defaultSize;
break;
}
case MeasureSpec.AT_MOST: {//如果測量模式是最大取值爲size
//我們將大小取最大值,你也可以取其他值
mySize = size;
break;
}
case MeasureSpec.EXACTLY: {//如果是固定的大小,那就不要去改變它
mySize = size;
break;
}
}
return mySize;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = getMySize(100, widthMeasureSpec);
int height = getMySize(100, heightMeasureSpec);
if (width < height) {
height = width;
} else {
width = height;
}
setMeasuredDimension(width, height);
}
我們設置一下佈局
<com.hc.studyview.MyView
android:layout_width="match_parent"
android:layout_height="100dp"
android:background="#ff0000" />
看看使用了我們自己定義的onMeasure函數後的效果:
而如果我們不重寫onMeasure,效果則是如下:
1.3.重寫onDraw
上面我們學會了自定義尺寸大小,那麼尺寸我們會設定了,接下來就是把我們想要的效果畫出來吧~繪製我們想要的效果很簡單,直接在畫板Canvas對象上繪製就好啦,過於簡單,我們以一個簡單的例子去學習:假設我們需要實現的是,我們的View顯示一個圓形,我們在上面已經實現了寬高尺寸相等的基礎上,繼續往下做:
@Override
protected void onDraw(Canvas canvas) {
//調用父View的onDraw函數,因爲View這個類幫我們實現了一些
// 基本的而繪製功能,比如繪製背景顏色、背景圖片等
super.onDraw(canvas);
int r = getMeasuredWidth() / 2;//也可以是getMeasuredHeight()/2,本例中我們已經將寬高設置相等了
//圓心的橫座標爲當前的View的左邊起始位置+半徑
int centerX = getLeft() + r;
//圓心的縱座標爲當前的View的頂部起始位置+半徑
int centerY = getTop() + r;
Paint paint = new Paint();
paint.setColor(Color.GREEN);
//開始繪製
canvas.drawCircle(centerX, centerY, r, paint);
}
1.4.自定義佈局屬性
如果有些屬性我們希望由用戶指定,只有當用戶不指定的時候才用我們硬編碼的值,比如上面的默認尺寸,我們想要由用戶自己在佈局文件裏面指定該怎麼做呢?那當然是通我們自定屬性,讓用戶用我們定義的屬性啦~
首先我們需要在res/values/styles.xml
文件(如果沒有請自己新建)裏面聲明一個我們自定義的屬性:
<resources>
<!--name爲聲明的"屬性集合"名,可以隨便取,但是最好是設置爲跟我們的View一樣的名稱-->
<declare-styleable name="MyView">
<!--聲明我們的屬性,名稱爲default_size,取值類型爲尺寸類型(dp,px等)-->
<attr name="default_size" format="dimension" />
</declare-styleable>
</resources>
接下來就是在佈局文件用上我們的自定義的屬性啦~
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:hc="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.hc.studyview.MyView
android:layout_width="match_parent"
android:layout_height="100dp"
hc:default_size="100dp" />
</LinearLayout>
注意:需要在根標籤(LinearLayout)裏面設定命名空間,命名空間名稱可以隨便取,比如hc
,命名空間後面取得值是固定的:"http://schemas.android.com/apk/res-auto"
最後就是在我們的自定義的View裏面把我們自定義的屬性的值取出來,在構造函數中,還記得有個AttributeSet屬性嗎?就是靠它幫我們把佈局裏面的屬性取出來:
private int defalutSize;
public MyView(Context context, AttributeSet attrs) {
super(context, attrs);
//第二個參數就是我們在styles.xml文件中的<declare-styleable>標籤
//即屬性集合的標籤,在R文件中名稱爲R.styleable+name
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MyView);
//第一個參數爲屬性集合裏面的屬性,R文件名稱:R.styleable+屬性集合名稱+下劃線+屬性名稱
//第二個參數爲,如果沒有設置這個屬性,則設置的默認的值
defalutSize = a.getDimensionPixelSize(R.styleable.MyView_default_size, 100);
//最後記得將TypedArray對象回收
a.recycle();
}
最後,把MyView的完整代碼附上:
package com.hc.studyview;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
/**
* Package com.hc.studyview
* Created by HuaChao on 2016/6/3.
*/
public class MyView extends View {
private int defalutSize;
public MyView(Context context) {
super(context);
}
public MyView(Context context, AttributeSet attrs) {
super(context, attrs);
//第二個參數就是我們在styles.xml文件中的<declare-styleable>標籤
//即屬性集合的標籤,在R文件中名稱爲R.styleable+name
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MyView);
//第一個參數爲屬性集合裏面的屬性,R文件名稱:R.styleable+屬性集合名稱+下劃線+屬性名稱
//第二個參數爲,如果沒有設置這個屬性,則設置的默認的值
defalutSize = a.getDimensionPixelSize(R.styleable.MyView_default_size, 100);
//最後記得將TypedArray對象回收
a.recycle();
}
private int getMySize(int defaultSize, int measureSpec) {
int mySize = defaultSize;
int mode = MeasureSpec.getMode(measureSpec);
int size = MeasureSpec.getSize(measureSpec);
switch (mode) {
case MeasureSpec.UNSPECIFIED: {//如果沒有指定大小,就設置爲默認大小
mySize = defaultSize;
break;
}
case MeasureSpec.AT_MOST: {//如果測量模式是最大取值爲size
//我們將大小取最大值,你也可以取其他值
mySize = size;
break;
}
case MeasureSpec.EXACTLY: {//如果是固定的大小,那就不要去改變它
mySize = size;
break;
}
}
return mySize;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = getMySize(defalutSize, widthMeasureSpec);
int height = getMySize(defalutSize, heightMeasureSpec);
if (width < height) {
height = width;
} else {
width = height;
}
setMeasuredDimension(width, height);
}
@Override
protected void onDraw(Canvas canvas) {
//調用父View的onDraw函數,因爲View這個類幫我們實現了一些
// 基本的而繪製功能,比如繪製背景顏色、背景圖片等
super.onDraw(canvas);
int r = getMeasuredWidth() / 2;//也可以是getMeasuredHeight()/2,本例中我們已經將寬高設置相等了
//圓心的橫座標爲當前的View的左邊起始位置+半徑
int centerX = getLeft() + r;
//圓心的縱座標爲當前的View的頂部起始位置+半徑
int centerY = getTop() + r;
Paint paint = new Paint();
paint.setColor(Color.GREEN);
//開始繪製
canvas.drawCircle(centerX, centerY, r, paint);
}
}
2 自定義ViewGroup
自定義View的過程很簡單,就那幾步,可自定義ViewGroup可就沒那麼簡單啦~,因爲它不僅要管好自己的,還要兼顧它的子View。我們都知道ViewGroup是個View容器,它裝納child View並且負責把child View放入指定的位置。我們假象一下,如果是讓你負責設計ViewGroup,你會怎麼去設計呢?
1.首先,我們得知道各個子View的大小吧,只有先知道子View的大小,我們才知道當前的ViewGroup該設置爲多大去容納它們。
2.根據子View的大小,以及我們的ViewGroup要實現的功能,決定出ViewGroup的大小
3.ViewGroup和子View的大小算出來了之後,接下來就是去擺放了吧,具體怎麼去擺放呢?這得根據你定製的需求去擺放了,比如,你想讓子View按照垂直順序一個挨着一個放,或者是按照先後順序一個疊一個去放,這是你自己決定的。
4.已經知道怎麼去擺放還不行啊,決定了怎麼擺放就是相當於把已有的空間"分割"成大大小小的空間,每個空間對應一個子View,我們接下來就是把子View對號入座了,把它們放進它們該放的地方去。
現在就完成了ViewGroup的設計了,我們來個具體的案例:將子View按從上到下垂直順序一個挨着一個擺放,即模仿實現LinearLayout的垂直佈局。
首先重寫onMeasure,實現測量子View大小以及設定ViewGroup的大小:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//將所有的子View進行測量,這會觸發每個子View的onMeasure函數
//注意要與measureChild區分,measureChild是對單個view進行測量
measureChildren(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int childCount = getChildCount();
if (childCount == 0) {//如果沒有子View,當前ViewGroup沒有存在的意義,不用佔用空間
setMeasuredDimension(0, 0);
} else {
//如果寬高都是包裹內容
if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
//我們將高度設置爲所有子View的高度相加,寬度設爲子View中最大的寬度
int height = getTotleHeight();
int width = getMaxChildWidth();
setMeasuredDimension(width, height);
} else if (heightMode == MeasureSpec.AT_MOST) {//如果只有高度是包裹內容
//寬度設置爲ViewGroup自己的測量寬度,高度設置爲所有子View的高度總和
setMeasuredDimension(widthSize, getTotleHeight());
} else if (widthMode == MeasureSpec.AT_MOST) {//如果只有寬度是包裹內容
//寬度設置爲子View中寬度最大的值,高度設置爲ViewGroup自己的測量值
setMeasuredDimension(getMaxChildWidth(), heightSize);
}
}
}
/***
* 獲取子View中寬度最大的值
*/
private int getMaxChildWidth() {
int childCount = getChildCount();
int maxWidth = 0;
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
if (childView.getMeasuredWidth() > maxWidth)
maxWidth = childView.getMeasuredWidth();
}
return maxWidth;
}
/***
* 將所有子View的高度相加
**/
private int getTotleHeight() {
int childCount = getChildCount();
int height = 0;
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
height += childView.getMeasuredHeight();
}
return height;
}
代碼中的註釋我已經寫得很詳細,不再對每一行代碼進行講解。上面的onMeasure將子View測量好了,以及把自己的尺寸也設置好了,接下來我們去擺放子View吧~
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int count = getChildCount();
//記錄當前的高度位置
int curHeight = t;
//將子View逐個擺放
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
int height = child.getMeasuredHeight();
int width = child.getMeasuredWidth();
//擺放子View,參數分別是子View矩形區域的左、上、右、下邊
child.layout(l, curHeight, l + width, curHeight + height);
curHeight += height;
}
}
我們測試一下,將我們自定義的ViewGroup裏面放3個Button ,將這3個Button的寬度設置不一樣,把我們的ViewGroup的寬高都設置爲包裹內容wrap_content
,爲了看的效果明顯,我們給ViewGroup加個背景:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.hc.studyview.MyViewGroup
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#ff9900">
<Button
android:layout_width="100dp"
android:layout_height="wrap_content"
android:text="btn" />
<Button
android:layout_width="200dp"
android:layout_height="wrap_content"
android:text="btn" />
<Button
android:layout_width="50dp"
android:layout_height="wrap_content"
android:text="btn" />
</com.hc.studyview.MyViewGroup>
</LinearLayout>
看看最後的效果吧~
是不是很激動~我們自己也可以實現LinearLayout的效果啦~~~~
最後附上MyViewGroup的完整源碼:
package com.hc.studyview;
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
/**
* Package com.hc.studyview
* Created by HuaChao on 2016/6/3.
*/
public class MyViewGroup extends ViewGroup {
public MyViewGroup(Context context) {
super(context);
}
public MyViewGroup(Context context, AttributeSet attrs) {
super(context, attrs);
}
/***
* 獲取子View中寬度最大的值
*/
private int getMaxChildWidth() {
int childCount = getChildCount();
int maxWidth = 0;
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
if (childView.getMeasuredWidth() > maxWidth)
maxWidth = childView.getMeasuredWidth();
}
return maxWidth;
}
/***
* 將所有子View的高度相加
**/
private int getTotleHeight() {
int childCount = getChildCount();
int height = 0;
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
height += childView.getMeasuredHeight();
}
return height;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//將所有的子View進行測量,這會觸發每個子View的onMeasure函數
//注意要與measureChild區分,measureChild是對單個view進行測量
measureChildren(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int childCount = getChildCount();
if (childCount == 0) {//如果沒有子View,當前ViewGroup沒有存在的意義,不用佔用空間
setMeasuredDimension(0, 0);
} else {
//如果寬高都是包裹內容
if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
//我們將高度設置爲所有子View的高度相加,寬度設爲子View中最大的寬度
int height = getTotleHeight();
int width = getMaxChildWidth();
setMeasuredDimension(width, height);
} else if (heightMode == MeasureSpec.AT_MOST) {//如果只有高度是包裹內容
//寬度設置爲ViewGroup自己的測量寬度,高度設置爲所有子View的高度總和
setMeasuredDimension(widthSize, getTotleHeight());
} else if (widthMode == MeasureSpec.AT_MOST) {//如果只有寬度是包裹內容
//寬度設置爲子View中寬度最大的值,高度設置爲ViewGroup自己的測量值
setMeasuredDimension(getMaxChildWidth(), heightSize);
}
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int count = getChildCount();
//記錄當前的高度位置
int curHeight = t;
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
int height = child.getMeasuredHeight();
int width = child.getMeasuredWidth();
child.layout(l, curHeight, l + width, curHeight + height);
curHeight += height;
}
}
}
好啦~自定義View的學習到此結束,是不是發現自定義View如此簡單呢?