教你步步為營掌握自定義View

圖片來自互聯網,如侵刪.jpg

國內自定義View的文章汗牛充棟,但是,即使你全部看完它們也未必能掌握這一知識點(實際上,我就幾乎看完了所有的國內文章)。為什么?一言以蔽之,你是得其術不明其道。(本文不打算講自定義屬性和事件處理,因為太多的文章講這些了)

一、自定義View,你真的掌握了嗎?

什么?你說你掌握了自定義View?來來來,回答老衲如下問題:

  • Google提出View這個概念的目的是什么?
  • View這個概念與Activtiy、Fragment以及Drawable之間是一種什么樣的關系?
  • View能夠感知Activity的生命周期事件嗎?為什么?

什么?你說這些問題太抽象?來來來,繼續回答如下問題:

  • View的生命周期是什么?
  • 當View所在的Activity進入stop狀態后,View去哪了?如果我在一個后臺線程中持有一個View的引用,我此時能夠改變它的狀態嗎?為什么?
  • View能夠與其他的View交叉重疊嗎?重疊區域發生的點擊事件交給誰去處理呢?可不可以重疊的兩個View都處理?
  • View控制一個Drawable的方法途徑有哪些?Drawable能不能與View通信?如果能如何通信?
  • 假如View所在的ViewGroup中的子View減少了,View因此獲得了更大的空間,View如何及時有效地利用這些空間,改變自己的繪制?
  • 假如我要在View中動態地注冊與解除廣播接收器,應該在哪里完成呢?
  • 假如我的手機帶鍵盤(自帶或者外接),你的自定義View應該如何響應鍵盤事件。
  • AnimationDrawable作為View的背景,會自動進行動畫,View在其中扮演了怎樣的角色?

假如以上問題你都能準確地回答出來,那么,恭喜你!我覺得你的自定義View已經學到家了,如果有那么幾個問題你還搞不清楚,或者不是很確定,那么,請上終南山,閉關三個月,繼續參悟自定義View的內在玄機。

為什么看了那么多文章,還是無法愉快地與自定義View玩耍?是那些文章不好嗎?非也,是你沒有掌握學習自定義View的正確姿勢(即使你會很多姿勢,也木有用,嘎嘎)。你看那些作者,輕輕松松整出一個漂亮的自定義View,你依葫蘆畫瓢也整出一個,就覺得自己好像也會了,年輕人,你太傲嬌了!你想過沒有,寫這些文章的人是怎么掌握自定義View的?請把這個問題在心中默念三遍。以后讀任何技術文章,都問自己這樣的問題,相信不久的將來,你也會成為Android大牛的,至少也是小壯牛一頭!因為,你已經從學習別人的知識,進入到學習別人的方法的境界了,功力怎能不大增!

好了,說了這么多,到底怎樣才能學好自定義View?其實只需掌握三個問題,就可以輕松搞定它:

  • 問題一:從Android系統設計者的角度,View這個概念究竟是做什么的?
  • 問題二:Android系統中那個View類,它有哪些默認功能和行為,能干什么,不能干什么?(知己知彼,才好自定義!)
  • 問題三:我要改變這個View的行為,外觀,肯定是覆寫View類中的方法,但是怎么覆寫,覆寫哪些方法能夠改變哪些行為?

以上三個問題,從抽象到具體,我覺得適用于學習任何技術知識,只是每個問題的問法可能因具體技術而有所調整,總體上就是從概念上,從默認實現上,從自己定制上去提問,比如你學習RecyclerView,也可以問以上三個問題,按照這三個問題的順序一個一個搞懂了,也就完全掌握了這一知識點。

下面,我們就一個問題一個問題地來解答。

二、從Android系統設計者的角度,View這個概念究竟是做什么的?

關于這個問題,最權威的當然是官方文檔,如下:

This class represents the basic building block for user interface components. A View occupies a rectangular area on the screen and is responsible for drawing and event handling.

這句話言簡意賅,高屋建瓴,一針見血,力透紙背,入木三分,令人銷魂佩服!需要我們認真體會,它包含三層含義:

  • View是用戶接口組件的基本構建塊。通俗講,在Android中,一個用戶與一個應用的交互,其實就是與這個應用中的許許多多的View的交互,這些View既可以是簡單的View,也可以是若干View組合而成的一個復合View。由此我們可以明白,所謂View是基本構件塊,原因就在于它是復合View(就是ViewGroup)的基本組成單元。這層含義,就是告訴你,View就是用來與用戶交互的,那么很自然地,我們要問,我們用戶在哪里與View交互,以及怎樣與View交互呢?
  • View在屏幕上占據一個矩形區域。這是說,既然View是用戶與應用交互的基本構建塊,而用戶使用Android設備時,主要是通過一個觸摸屏來交互的,相應的,Andorid的設計者們,就讓一個View就在屏幕上占據一個矩形區域,用戶在這個區域中發生的交互動作(點擊、滑動、拖動等),就是與這個View的交互。什么?為什么不讓View占據一個圓形區域或者五角星區域呢?當然是為了簡單。這就解決了在哪里與View交互的問題。很自然地,我們又想問,View在屏幕上占據一個矩形區域,這個區域的大小、位置怎么確定,它們會不會變化,誰來決定這個變化呢?如果這個變化不是由View自己來決定的,而是其他外界因素決定的,View又要怎樣響應這種變化呢?不要急,后面都會有答案。
  • View通過繪制自己與事件處理兩種方式與用戶交互。這是解決了如何交互的問題。簡單講,View與用戶交互就兩個辦法,一個是改變自己的模樣,也就是通過繪制自己與用戶交互,比如,當用戶點擊自己時,就改變自己的背景顏色,以此來告訴用戶:“本View已經響應你的點擊了!”第二個方式就是事件處理,比如,當用戶點擊View時,就完成一定的任務,然后彈出一個Toast,告訴用戶該View完成了什么任務,這樣,用戶也就知道這次交互結果如何。

看到沒,這就是官方文檔的魅力,短短一句話,勝君讀千篇水文。現在我們明白了,設計View,主要是為了讓應用能夠與用戶交互,要想完成交互,這個View就要在屏幕上占據一個矩形區域,然后利用這塊屏幕區域與用戶交互,交互的方式就兩種,繪制自己與事件處理。

三、Android系統中那個View類,它有哪些默認功能和行為,能干什么,不能干什么?

解決了第一個問題,我們很可能有更多的疑問,我們想知道:

View是怎樣被顯示到屏幕上的?

View在屏幕上的位置是怎樣決定的?

View所占據的矩形大小是怎樣決定的?

屏幕上肯定不止一個View,View之間互相知道對方嗎?它們之間能協作嗎?

View完成與用戶的交互后,能夠自動隱藏,在需要交互的時候重新顯示在屏幕上嗎?

......

現在我們就一點點來講,學習的同時,最好能夠用心體會Google工程師設計時的思路。

這樣學習效果最好。

首先,一個用戶界面,上面有許多View,既有基本View,也有復合View,把它們組織起來還讓它們很好地協作確實是一個難題,Google的解決方案是:首先,一套完整的用戶界面用一個Window來表示,Window這個概念和我們在計算機上所說的Window很相似。Window負責管理所有的View們,怎么管理?很簡單,借鑒復合View的思路,Window首先加載一個超級復合View,用它來包含住所有的其他View,這個超級復合View就叫做DecorView。但是這個DecorView除了包含我們的用戶界面上那些View,還包含了作為一個Window特有的View,叫做titlebar,這個我們就不細說了。

這樣,在Window中的View們被組織起來了,一個巨大的ViewGroup(以后,我們不再用復合View這個說法,而代之以ViewGroup,二者是一回事),下面有若干ViewGroup和若干View,每個ViewGroup下面又有若干ViewGroup和若干View,很像數據結構中的樹,葉子節點就是基本View。

好了,這些View已經被組織起來了,DecorView已經能夠完全控制它們了,同時,DecorView掌握著能夠分配給這些View的屏幕區域,包括區域的大小和位置。我們知道,屏幕的大小是有限的,一個Window的DecorView能夠控制的屏幕區域更加有限,AndroidN中引入多Window機制后,DecorView能掌控的屏幕區域更加小了,因為屏幕上有多個Window將成為常態。這些有限的區域還要被Window特有的View(titlebar)占去一小部分,剩下的才是留給用戶界面上的View們分的,如果你是DecorView,你肯定為難了,如何將這些有限的屏幕區域分給這些View們?分給他們后還得為每個View排好在屏幕上的位置,難上加難。

停一停,想一想,如果是你,你怎么解決這個問題?

首先,不同的View是為了完成特定的交互任務的,比如,Button就是用來點擊的,TextView就是用來顯示字符的,等等。DecorView知道,不同的View為了完成自己的交互任務所需要的屏幕區域大小是不同的,所以DecorView在確定給每個View分配的屏幕區域大小時,是允許View參與進來,與它一起商量的。但是每個View在屏幕區域中的位置就不能讓View自己來決定了,而是由DecorView一手操辦,這個比較簡單,我們就先來看看DecorView是怎樣決定每個View的位置的吧。

1、確定每個View的位置

我們在Activity中,調用了setContentView(View),實際上就是將用戶界面的所有的View交給了DecorView中的一個FrameLayout,這個FrameLayou代表著可以分配給用戶界面使用的屏幕區域。而用戶界面View既可以是一個簡單的View,也可以是一個ViewGroup,如果是一個簡單的View,比如就是一個TextView,那么這個TextView就會占據整個FrameLayout的屏幕區域,也就是說,此時用戶在FrameLayout的屏幕區域內的所有交互都是與這個TextView交互。但是更常見的情況時,我們的用戶界面是一個ViewGroup(想想常用的布局五大金剛),里面包含著其他的ViewGroup和View。這個時候,首先這個ViewGroup就會占據FrameLayout所代表的屏幕區域,剩下的任務,就是這個ViewGroup給它內部的小弟們(各種ViewGroup和各種View)分配區域了。至于怎么分,不同的ViewGroup有不同的分法,總體來看,可說是有總有分。所謂總,舉例來講,像vertical的LinearLayout,它按照
自己的小弟數量,把自己豎向裁成不同的區域,如下圖所示:

LinearLayout-sample.png

雖然View無法決定自己在ViewGroup中的位置,但是開發者在使用View時,可以向ViewGroup表達自己所用的View要放在哪里,以vertical LinearLayout為例,開發者書寫布局文件時,子View在LinearLayout中的出現順序將決定它們在屏幕上的上下順序,同時還可以借助layout_margin ,layout_gravity等配置進一步調整子View在分給自己的矩形區域中的位置。到這里,我們可以理解,layout_*之類的配置雖然在書寫上與View的屬性在一起,但它們并不是View的屬性,它們只是使用該View的使用者用來細化調整該View在ViewGroup中的位置的,同時,這些值在Inflate時,是由ViewGroup讀取,然后生成一個ViewGroup特定的LayoutParams對象,再把這個對象存入子View中的,這樣,ViewGroup在為該子View安排位置時,就可以參考這個LayoutParams中的信息了。進一步思考,我們發現,調用inflate時,除了輸入布局文件的id外,一般要求傳入parent ViewGroup,傳入這個參數的目的,就是為了讀取布局文件中的layout配置信息,如果沒有傳入,這些信息將會丟失,感興趣的同學可以自己試驗驗證下,這里就不展開了。
不同的ViewGroup擁有不同的LayoutParams內部類,這是因為,它們所允許的子View微微調整自己的位置的方式是不一樣的,具體講就是配置子View時,允許使用的layout_*是不一樣的,比如,RelativeLayout就允許layout_toRightOf等配置,其他的ViewGroup沒有這些配置。
這些確定View的位置的過程,被包裝在View 的layout方法中,這樣我們也很容易理解,對于基本View而言,這個方法是沒有用的,所以都是空的,你可以查看下ImageView、TextView等的源代碼,驗證下這一點。對于ViewGroup而言,它們會用該方法為自己的子View安排位置。

2、確定View大小

下面,是要確定View的大小了,這是一個開發者、View與ViewGroup三方相互商量的過程。(這里的講解可能與一般的文章不同,是我個人的理解,一般的文章都不會說是三方商量,而是直接說View與ViewGroup兩方商量)

第一步,開發者在書寫布局文件時,會為一個View寫上android:layout_width="***"android:layout_height="***"兩個配置,這是開發者向ViewGroup表達的,我這個View需要的大小是多少。星號的取值有三種:

  • 具體值,如50dp,很簡單,不多講
  • match_parent ,表示開發者向ViewGroup說,把你所有的屏幕區域都給這個View吧。
  • wrap_parent,表示開發者向ViewGroup說,只要給這個View夠他展示自己的空間就行,至于到底給多少,你直接跟View溝通吧,看它怎么說。

第二步,ViewGroup收到了開發者對View大小的說明,然后ViewGroup會綜合考慮自己的空間大小以及開發者的請求,然后生成兩個MeasureSpec對象(width與height)傳給View,這兩個對象是ViewGroup向子View提出的要求,就相當于告訴子View:“我已經與你的使用者(開發者)商量過了,現在把我們商量確定的結果告訴你,你的寬度不能違反width MeasureSpec對象的要求,你的高度不能違反height MeasureSpec對象的要求,現在,你趕緊根據這個要求確定下自己要多大空間,只許少,不許多哦。”

然后,這兩個對象將會傳到子View的protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)方法中。子View能怎么辦呢?它肯定是要先看看ViewGroup的要求是什么吧,于是,它從傳入的兩個對象中解譯出如下信息:

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize =  MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize =  MeasureSpec.getSize(heightMeasureSpec);

Mode與Size一起,準確表達出了ViewGroup的要求。下面我們舉例說明,假設Size是100dp,
Mode的取值有三種,它們代表了ViewGroup的總體態度:

  1. EXACTLY 表示,ViewGroup對View說,你只能用100dp,原因是多樣的,可能是你的使用者說要你完全占據我的空間,而我只有100dp。也可能這是你的使用者的要求,他需要你占這么大的空間,而我恰好也有這么多的空間,你的使用者讓你占這么大的空間,肯定有他自己的考慮,你不能不理不顧,不然你達不到他的要求,他可能就不用你了。
  2. AT_MOST表示,你最多只能用100dp,這是因為你的使用者說讓你占據wrap_content的大小,讓我跟你商量,我又不知道你到底要占多大區域,但是我告訴你,我只有100dp,你最多也只能用這么多哈。(這里,可以看出,當使用者在布局文件中要求一個View是wrap_content時,此時,View的大小決定權就交給View自己了,默認的View類中的實現,比較粗暴,就是將此時ViewGroup提供的空間全占據,完全沒有真正根據自己的內容來確定大小,為什么這么粗暴?因為View是一個基類,所有的組件都是它的子類,每個子類的content都各不相同,View怎么可能知道content的大小呢,所以,它把wrap_content情況下,自己尺寸大小的決定權下放給了不同的子組件,讓它們自己根據自己的內容去決定自己的大小,同樣,我們自定義View時,也要考慮這一點)
  3. UNSPECIFIED表示,你自己看著辦,把你最理想的大小告訴我,我考慮考慮。

第三步,好了,子View已經清楚地理解了ViewGroup和它的使用者對它的大小的期望和要求了。下步就要在該要求下來確定自己的大小并告訴ViewGroup了。(廢話,不告訴ViewGroup大小,它怎么給你安排位置(layout),無法給你layout,你也就占據不了一塊屏幕區域,占不了屏幕區域,你就無法與用戶交互,無法與用戶交互,要你何用啊!)

關于子View怎么確定自己的大小,不同的View有不同的態度,但是有幾點基本的規矩是要遵守的:
規矩一就是,不要違反ViewGroup的規定,最后設置的尺寸一定要在ViewGroup要求的范圍內(不論是寬度還是高度),但是你說,假如我就是想要更大的空間,難道就沒有辦法了嗎,我能不能遵守要求的情況下,同時告訴ViewGroup,雖然我告訴你的我要求的尺寸是遵照你的旨意來的,但實際上我是委屈求全的,我真實想要的大小不是這樣的,你能不能再考慮一下。答案是:有。那就是如下調用:

    resolveSizeAndState((int)(wantedWidth), widthMeasureSpec, 0),    
    resolveSizeAndState((int) (wantedHeight), heightMeasureSpec, 0);```

View可以把自己想要的寬和高進行一個resolveSizeAndState處理,就可以達到上述目的。即如果想要的大小沒超過要求,一切都Ok,如果超過了,在該方法內部,就會把尺寸調整成符合ViewGroup要求的,但是也會在尺寸中設置一個標記,告訴ViewGroup,這個大小是子View委屈求全的結果。至于ViewGroup會不會理會這一標記,要看不同的ViewGroup了。如果你實現自己的ViewGroup,最好還是關注下這個標記,畢竟作為大哥的你,最主要的職責就是把自己的小弟(子View)安排好,讓它們都滿意嘛。(這一點,我沒有看到任何一篇講解自定義View的文章提到過!)
什么?好奇的你想看看究竟是怎樣設置標記的?來來來,滿足你:
```java
public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState) {  
      final int specMode = MeasureSpec.getMode(measureSpec);  
      final int specSize = MeasureSpec.getSize(measureSpec);  
      final int result;  
      switch (specMode) {     
         case MeasureSpec.AT_MOST:         
             if (specSize < size) {            
                  result = specSize | MEASURED_STATE_TOO_SMALL;         
             } else {            
                  result = size;      
             }         
             break;      
         case MeasureSpec.EXACTLY:          
              result = specSize;      
              break;       
         case MeasureSpec.UNSPECIFIED:   
         default:        
              result = size;   
       }   
       return result | (childMeasuredState & MEASURED_STATE_MASK);
}

上面的代碼中的MEASURED_STATE_TOO_SMALL就是在子View想要的空間太大時設置的標記了。

規矩二就是要在該方法中調整自己的繪制參數,這一點很好理解,畢竟ViewGroup提出了尺寸要求,要及時根據這一要求調整自己的繪制,比如,如果自己的背景圖片太大,那就算算要縮放多少才合適,并且設置一個合理的縮放值。
規矩三就是一定要設置自己考慮后的尺寸,如果不設置就相當于沒有告訴ViewGroup自己想要的大小,這會導致ViewGroup無法正常工作,設置的辦法就是在onMeasure方法的最后,調用
setMeasuredDimension方法。為什么調用這個方法就可以了呢?這只是一個約定,沒有必要深究了。

關于View的繪制,非常簡單,就是一個方法onDraw,后面的自定義View實戰部分會細說,這里先略過了。

以上,View的三個基本知識點,我們都了解了,即View 的位置如何確定,大小如何確定以及如何繪制自己。這都是默認的View類中為我們準備好的。

四、我要改變這個View的行為,外觀,肯定是覆寫View類中的方法,但是怎么覆寫,覆寫哪些方法能夠改變哪些行為?

好了,View的位置和大小怎么確定我們都清楚了,現在,是時候開始自定義View了。
首先,關于View所要具備的一般功能,View類中都有了基本的實現,比如確定位置,它有layout方法,當然,這個只適用于ViewGroup,實現自己的ViewGroup時,才需要修改該方法。確定大小,它有onMeasure方法,如果你不滿意默認的確認大小的方法,也可以自己定義。改變默認的繪制,就覆寫onDraw方法。下面,我們通過一張圖,來看看,自定義View時,我們最可能需要修改的方法是哪些:

View-Method-For-Override.png

把這些方法都搞明白了,你也就理解了View的生命周期了。

比如View被inflated出來后,系統會回調該View的onFinishInflate方法,你的View可以在這個方法中,做一些準備工作。

如果你的View所屬的Window可見性發生了變化,系統會回調該View的onWindowVisibilityChanged方法,你也可以根據需要,在該方法中完成一定的工作,比如,當Window顯示時,注冊一個監聽器,根據監聽到的廣播事件改變自己的繪制,當Window不可見時,解除注冊,因為此時改變自己的繪制已經沒有意義了,自己也要跟著Window變成不可見了。

當ViewGroup中的子View數量增加或者減少,導致ViewGroup給自己分配的屏幕區域大小發生變化時,系統會回調View的onSizeChanged方法,該方法中,View可以獲取自己最新的尺寸,然后根據這個尺寸相應調整自己的繪制。

當用戶在View所占據的屏幕區域發生了觸摸交互,系統會將用戶的交互動作分解成如DOWN、MOVE、UP等一系列的MotionEvent,并且把這些事件傳遞給View的onTouchEvent方法,View可以在這個方法中進行與用戶的交互處理。當然這個是基本的流程,實際的流程會稍復雜些,你可以閱讀我的另一篇文章,是專門講解事件分發的,文章非常經典,你讀了一定不后悔。

除了這些方法,View還實現了三個接口,如下:

View-Hierachy.png

三個接口是:
Drawable.Callback
KeyEvent.Callback
AccessibilityEventSource

每個接口都有自己的作用。

KeyEvent回調接口,是用來處理鍵盤事件的,這與onTouchEvent用來處理觸摸事件是相對的。

Drawable回調接口是用來讓View中的Drawable能夠與View通信的,尤其是AnimationDrawable,更是必須依賴該回調才能實現動畫效果,關于這一點,我深入地研究了FrameWork的源碼,對AnimationDrawable如何實現動畫,有了深入徹底的掌握,我也在考慮要不要就此寫一篇文章,看大家需要吧,如果本文贊數過百,我就寫,絕不食言。

第三個回調接口,我沒有細致研究,不便多說。

寫到這里你應該發現,我們的第三個問題,自定義View,應該覆寫哪些方法,能夠實現哪些功能也已經解決了。

五、光說不練假把式,實戰自定義View

說了這么多,不自定一個View,怎么對的起你辛苦讀到這里呢。好,我們現在就來自定義一個鐘表,而且可以自己走的。如下圖所示:

screenshot0.png

這個時鐘可是能夠走動的哈。下面我們就開始吧。首先,準備三張圖片資源,如下:

clock_dial.png
clock_hand_hour.png
clock_hand_minute.png

聰明如你,一看就應該知道這是做什么用的了。準備圖片時,使用了一個小技巧,就是時針和分針,你所看到的圖像只是圖片的一半,在圖像的下方,還有同樣大小的空白,這個是做什么用的呢?主要是為了繪制圖片時的方便,待會兒就可以明白了。

材料齊全,開工!

public class AnalogClock extends View {   

      private Time mCalendar;    //用來記錄當前時間

      //用來存放三張圖片資源
      private Drawable mHourHand;  
      private Drawable mMinuteHand; 
      private Drawable mDial;   

    //用來記錄表盤圖片的寬和高,
    //以便幫助我們在onMeasure中確定View的大
    //小,畢竟,我們的View中最大的一個Drawable就是它了。
       private int mDialWidth; 
       private int mDialHeight;   


//用來記錄View是否被加入到了Window中,我們在View attached到
//Window時注冊監聽器,監聽時間的變更,并根據時間的變更,改變自己
//的繪制,在View從Window中剝離時,解除注冊,因為我們不需要再監聽
//時間變更了,沒人能看得到我們的View了。
       private boolean mAttached;    
  
//看名字
        private float mMinutes;    
        private float mHour;    

//用來跟蹤我們的View 的尺寸的變化,
//當發生尺寸變化時,我們在繪制自己
//時要進行適當的縮放。
        private boolean mChanged;
...
}

下面,我們來確定自定義View 的構造方法,查看View類,我們知道,View類有四個構造方法,我們相應地,也寫四個構造方法,并且初始化相關變量:

//第一個構造方法
public AnalogClock(Context context) {   
     this(context, null);
}
//第二個構造方法
public AnalogClock(Context context, AttributeSet attrs) {  
      this(context, attrs, 0);
}
//第三個構造方法
public AnalogClock(Context context, AttributeSet attrs, int defStyleAttr) { 
      this(context, attrs, defStyleAttr, 0);
}
//第四個構造方法
public AnalogClock(Context context, AttributeSet attrs, 
int defStyleAttr, int defStyleRes) {    

    super(context, attrs, defStyleAttr, defStyleRes);    
    final Resources r = context.getResources();  
    if (mDial == null) {    
          mDial = context.getDrawable(R.drawable.clock_dial);  
    }  
    if (mHourHand == null) {        
        mHourHand = context.getDrawable(R.drawable.clock_hand_hour);   
    }     
    if (mMinuteHand == null) {      
          mMinuteHand = 
                context.getDrawable(R.drawable.clock_hand_minute);   
     }  
  
     mCalendar = new Time(); 

    mDialWidth = mDial.getIntrinsicWidth();   
    mDialHeight = mDial.getIntrinsicHeight();}

請注意,以上為自定義View設置的構造方法是適用性最廣的一種寫法,這樣寫,可以確保我們的自定義View能夠被最大多數的開發者使用,是一種最佳實踐。
接下來,確定我們的自定義View 的大小,也就是改寫onMeasure方法:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {   

         int widthMode = MeasureSpec.getMode(widthMeasureSpec);  
         int widthSize =  MeasureSpec.getSize(widthMeasureSpec);  

         int heightMode = MeasureSpec.getMode(heightMeasureSpec);  
         int heightSize =  MeasureSpec.getSize(heightMeasureSpec); 
  
         float hScale = 1.0f;  
         float vScale = 1.0f;   
 
         if (widthMode != MeasureSpec.UNSPECIFIED && widthSize < mDialWidth) {       
             hScale = (float) widthSize / (float) mDialWidth;   
         }   
         if (heightMode != MeasureSpec.UNSPECIFIED && heightSize < mDialHeight) {       
             vScale = (float )heightSize / (float) mDialHeight;  
          }    
         float scale = Math.min(hScale, vScale);    
        setMeasuredDimension(
              resolveSizeAndState((int) (mDialWidth * scale), widthMeasureSpec, 0),           
             resolveSizeAndState((int) (mDialHeight * scale), heightMeasureSpec, 0)
        );
}


在該方法中,我們的View想要的尺寸當然就是與表盤一樣大的尺寸,這樣可以保證我們的View有最佳的展示,可是如果ViewGroup給的尺寸比較小,我們就根據表盤圖片的尺寸,進行適當的按比例縮放。注意,這里我們沒有直接使用ViewGroup給我們的較小的尺寸,而是對我們的表盤圖片的寬高進行相同比例的縮放后,設置的尺寸,這樣的好處是,可以防止表盤圖片繪制時的拉伸或者擠壓變形。

確定了大小,是不是就可以繪制了,先不著急,我們先要處理兩件事,一件就是讓我們的自定義View能夠感知自己尺寸的變化,這樣每次繪制時,可以先判斷下尺寸是否發生了變化,如果有變化,就及時調整我們的繪制策略。代碼如下:

protected void onSizeChanged(int w, int h, int oldw, int oldh) {    
       super.onSizeChanged(w, h, oldw, oldh);   
       mChanged = true;
}

我們會在onDraw使用mChanged變量的。

第二件事就是讓我們的View能夠監聽時間變化,并及時更新該View中的mCalendar變量,然后根據它來更新自身的繪制。為此,我們先寫一個更新時間的方法,代碼如下:


private void onTimeChanged() {    
        mCalendar.setToNow();  

        int hour = mCalendar.hour;   
        int minute = mCalendar.minute;  
        int second = mCalendar.second;   
        /*這里我們為什么不直接把minute設置給mMinutes,而是要加上
            second /60.0f呢,這個值不是應該一直為0嗎?
            這里又涉及到Calendar的 一個知識點,
            也就是它可以是Linient模式,
            此模式下,second和minute是可能超過60和24的,具體這里就不展開了,
            如果不是很清楚,建議看看Google的官方文檔中講Calendar的部分*/
         mMinutes = minute + second / 60.0f;    
         mHour = hour + mMinutes / 60.0f;   
         mChanged = true;
}

然后我們還要實現一個廣播接收器,接收系統發出的時間變化廣播,然后更新該View的mCalendar,如下:

private final BroadcastReceiver mIntentReceiver = new BroadcastReceiver() {   
       @Override  
        public void onReceive(Context context, Intent intent) {    
            //這個if判斷主要是用來在時區發生變化時,更新mCalendar的時區的,這
            //樣,我們的自定義View在全球都可以使用了。
            if (intent.getAction().equals(Intent.ACTION_TIMEZONE_CHANGED)) {            
                  String tz = intent.getStringExtra("time-zone");         
                   mCalendar = new Time(TimeZone.getTimeZone(tz).getID());    
            }     
          //進行時間的更新  
             onTimeChanged();     
          //invalidate當然是用來引發重繪了。
           invalidate();   
         }
};

現在,我們要給我們的View動態地注冊廣播接收器,沒錯,我們就是要在
onAttachedToWindow和onDetachedFromWindow中完成這一功能。代碼如下:

@Override
protected void onAttachedToWindow() {   
       super.onAttachedToWindow();    
      if (!mAttached) {      
          mAttached = true;      
          IntentFilter filter = new IntentFilter();        
        //這里確定我們要監聽的三種系統廣播
          filter.addAction(Intent.ACTION_TIME_TICK);   
          filter.addAction(Intent.ACTION_TIME_CHANGED);        
          filter.addAction(Intent.ACTION_TIMEZONE_CHANGED);        
          getContext().registerReceiver(mIntentReceiver,   filter); 
       }   
       
        mCalendar = new Time();   
        onTimeChanged();
}

@Override
protected void onDetachedFromWindow() {    
          super.onDetachedFromWindow();  
          if (mAttached) {     
               getContext().unregisterReceiver(mIntentReceiver);     
               mAttached = false;   
           }
}

萬事具備,只欠東風,開始繪制我們的View吧。代碼如下:

@Override
protected void onDraw(Canvas canvas) {   
         super.onDraw(canvas);  

      //View尺寸變化后,我們用changed變量記錄下來,
    //同時,恢復mChanged為false,以便繼續監聽View的尺寸變化。
          boolean changed = mChanged;   
          if (changed) {      
                mChanged = false;   
           }   
        /* 請注意,這里的availableWidth和availableHeight,
           每次繪制時是可能變化的,
           我們可以從mChanged變量的值判斷它是否發生了變化,
           如果變化了,說明View的尺寸發生了變化,
           那么就需要重新為時針、分針設置Bounds,
           因為我們需要時針,分針始終在View的中心。*/
           int availableWidth = super.getRight() - super.getLeft();   
           int availableHeight = super.getBottom() - super.getTop();  


        /* 這里的x和y就是View的中心點的坐標,
          注意這個坐標是以View的左上角為0點,向右x,向下y的坐標系來計算的。
          這個坐標系主要是用來為View中的每一個Drawable確定位置。
          就像View的坐標是用parent的左上角為0點的坐標系計算得來的一樣。
          簡單來講,就是ViewGroup用自己左上角為0點的坐標系為
          各個子View安排位置,
          View同樣用自己左上角為0點的坐標系
          為它里面的Drawable安排位置。
          注意不要搞混了。*/

           int x = availableWidth / 2;    
           int y = availableHeight / 2;   

           final Drawable dial = mDial;  
           int w = dial.getIntrinsicWidth();   
           int h = dial.getIntrinsicHeight();   
            boolean scaled = false;   

        /*如果可用的寬高小于表盤圖片的寬高,
           就要進行縮放,不過這里,我們是通過坐標系的縮放來實現的。
          而且,這個縮放效果影響是全局的,
          也就是下面繪制的表盤、時針、分針都會受到縮放的影響。*/
           if (availableWidth < w || availableHeight < h) {     
                 scaled = true;      
                  float scale = Math.min((float) availableWidth / (float) w,   
                              (float) availableHeight / (float) h);     
                 canvas.save();    
                 canvas.scale(scale, scale, x, y);  
             }    

         /*如果尺寸發生變化,我們要重新為表盤設置Bounds。
           這里的Bounds就相當于是為Drawable在View中確定位置,
           只是確定的方式更直接,直接在View中框出一個與Drawable大小
           相同的矩形,
           Drawable就在這個矩形里繪制自己。
           這里框出的矩形,是以(x,y)為中心的,寬高等于表盤圖片的寬高的一個矩形,
           不用擔心表盤圖片太大繪制不完整,
            因為我們已經提前進行了縮放了。*/
          if (changed) {       
                 dial.setBounds(x - (w / 2), y - (h / 2), x + (w / 2), y + (h / 2)); 
           }    
          dial.draw(canvas);    

          canvas.save();   
          /*根據小時數,以點(x,y)為中心旋轉坐標系。
            如果你對來回旋轉的坐標系感到頭暈,摸不著頭腦,
            建議你看一下**徐宜生**《安卓群英傳》中講解2D繪圖部分中的Canvas一節。*/

           canvas.rotate(mHour / 12.0f * 360.0f, x, y);  
           final Drawable hourHand = mHourHand;   

          //同樣,根據變化重新設置時針的Bounds
           if (changed) {     
                   w = hourHand.getIntrinsicWidth();    
                   h = hourHand.getIntrinsicHeight();      
          
            /* 仔細體會這里設置的Bounds,我們所畫出的矩形,
                同樣是以(x,y)為中心的
                矩形,時針圖片放入該矩形后,時針的根部剛好在點(x,y)處,
                因為我們之前做時針圖片時,
                已經讓圖片中的時針根部在圖片的中心位置了,
                雖然,看起來浪費了一部分圖片空間(就是時針下半部分是空白的),
                但卻換來了建模的簡單性,還是很值的。*/
                  hourHand.setBounds(x - (w / 2), y - (h / 2), x + (w / 2), y + (h / 2));  
             }    
              hourHand.draw(canvas);  
              canvas.restore();  
  
              canvas.save();    
            //根據分針旋轉坐標系
              canvas.rotate(mMinutes / 60.0f * 360.0f, x, y);   
              final Drawable minuteHand = mMinuteHand;   

              if (changed) {     
                       w = minuteHand.getIntrinsicWidth();    
                       h = minuteHand.getIntrinsicHeight();    
                       minuteHand.setBounds(x - (w / 2), y - (h / 2), x + (w / 2), y + (h / 2)); 
               }   
               minuteHand.draw(canvas);    
                canvas.restore();    
            //最后,我們把縮放的坐標系復原。
              if (scaled) {      
                   canvas.restore();   
              }

}

大功告成,現在我們的時鐘終于完成了,任何開發者都可以使用我們的View,獲得一個不斷走動的模擬時鐘。該View的完整代碼已經上傳到Github,猛戳https://github.com/like4hub/CustomViewForClock。(注:該時鐘的實現,主要參考了AOSP中模擬時鐘)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,197評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,415評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,104評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,884評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,647評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,130評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,208評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,366評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,887評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,737評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,939評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,478評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,174評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,586評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,827評論 1 283
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,608評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,914評論 2 372

推薦閱讀更多精彩內容