天天看點

android自定義控件一站式入門

# 自定義控件

Android系統提供了一系列UI相關的類來幫助我們構造app的界面,以及完成互動的處理。

一般的,所有可以在視窗中被展示的UI對象類型,最終都是繼承自View的類,這包括展示最終内容的非布局View子類和繼承自ViewGroup的布局類。

其它的諸如Scroller、GestureDetector等android.view包下的輔助類簡化了有關視圖操作和互動處理。

無論如何,自己的app中總會遇到内建的類型無法滿足要求的場景,這時就必須實作自己的UI元件了。

根據需要,有以下幾個方式來完成自定義控件:

繼承View或ViewGroup類:

這種情況是你需要完全控制視圖的内容展示和互動處理的情況下,直接繼承View類可以獲得最大限度的定制。

繼承特定的View子類:

如果内建的某個View子類基本符合使用要求,隻是需要定制該View某些方面的功能時,選擇此種方式。

例如繼承TextView為其增加特殊的文字顯示效果,豎排顯示等。

組合已有View:

組合View實作自定義控件其實主要就是為了完成組合成後的目标View的複用。這裡組合就是定義一個ViewGroup的子類,然後添加需要的childView。

典型的有EditText + ListView實作Combox(下拉框)這樣的東西。做法就是繼承布局類,然後inflate對應布局檔案or代碼中建立(蛋疼)作為其包含的child views。

統一的搜尋欄,級聯菜單等,組合控件其實有點類似布局中include這樣的做法,如果為一個可複用的片段layout配一個ViewManager,效果幾乎是一樣的。當然,自定義控件的好處就是可以在xml中直接聲明,而且UI和對應邏輯是集中管理的。便于複用。

自定義控件的幾種方式中,直接繼承自View類的方式包含自定義View用到的完整的開發技巧。接下來将以官方文檔Develop > Training > Best Practices for User Interface > Creating Custom Views中講述的PieChart自定義控件為例,了解下自定義View的開發流程。

功能目标:

将要實作的PieChart控件如下圖:

android自定義控件一站式入門
android自定義控件一站式入門

具有以下主要功能目标:

PieChart需要展示一個由一或多個扇形組成的圓,一個在圓的固定位置的訓示圓點,一個在圓的左側或右側固定位置的标簽。

圓的每個扇形表示一個顯示項(Item)。可以添加任意多個Item,每個Item有它的color、value、label來确定扇形的顯示。所有扇形根據其添加順序順時針從0°開始組成整個圓。如上面的是包含紅、綠、藍,值分别為1、2、3的三個Item組成的圓。

手指滑動時轉動餅狀圖,滑動方向與圓心到滑動方向的直線決定了轉動方向。例如手指處在圓心下方時向左滑動時圓順時針轉動。

圓轉動時,訓示圓點落在那個扇形的區域,扇形對應的Item就是目前Item。它對應的label内容被顯示。

手指快速劃過後(fling——具有flywheel效果),餅狀圖以動畫的方式慢慢停止而不是立即停止轉動。

滑動(包括fling)結束後,居中目前項——訓示點在目前項對應扇形角度中心。

以上是要實作的自定義控件PieChart需要滿足的業務要求。下面就一步步設計和完成PieChart控件。

在開始實作控件的功能目标之前,需要做一些基礎工作,讓自己的控件可以運作調試。之後再逐漸完成顯示和互動功能。

View隻能顯示内容,而ViewGroup可以包含其他View或ViewGroup。ViewGroup本身也是View的子類,它也可以顯示内容。

為了讓PieChart可以同時顯示标簽和圓,可以使用一個單獨的View子類來繪制,但是,這裡選擇讓PieChart作為一個ViewGroup,

它來顯示标簽和訓示圓點,然後設計一個PieView類來完成圓的繪制。

這樣做有以下好處是:

在Android 3.0(API 11)之後,引入了硬體加速特性,在執行一些動畫時可以提升UI體驗。但是啟用硬體加速需要更多的記憶體開銷。

對于需要轉動和使用動畫效果的圓來說,在它執行動畫的時候可以開啟硬體加速,動畫停止的時候取消硬體加速。分多個View可以在獨立的硬體加速層繪制圓,又避免了标簽和訓示圓點這樣寫圖形不需要加速的事實。

分開兩個View,可以讓邏輯更加清晰,避免一個類過度複雜(出于示範目的)。

PieChart繼承ViewGroup,PieView繼承View,這樣可以在目前案例中同時介紹到自定義View相關的“測量、布局和繪制”的知識。

控件對象應該可以是通過代碼或xml方式建立。

通過xml方式定義的控件在建立時執行的是包含Context和AttributeSet兩個參數的構造器,為了可以在xml中定義控件對象,PieChart類就需要提供此構造器:

額外的AttributeSet參數攜帶了在xml中為控件指定的attribute集合。attribute表示可以在布局xml檔案中定義View時使用的xml元素名稱,例如layput_width,padding這樣的。這些attribute相當于在定義控件對象的時候提供的初始值,更直接點,類似于構造函數的參數。

Android提供了統一的通過xml為建立的控件對象提供初始值的方式:

為控件定義xml中使用的attribute。

在布局檔案中為控件使用這些attribute。

構造器通過AttributeSet參數獲得xml中定義的這些attribute值。

接下來的1.2和1.3分别介紹如何定義attribute,以及如何使用attribute。

attribute和property都翻譯為屬性,attribute表示可以在布局xml檔案中定義View時使用的xml元素名稱,例如layput_width,padding這樣的。而property表示類的getter/setter或者類似的對某個private字段的通路方法。

首先,在res/values/attrs.xml檔案中定義屬性:

對應每個View類,使用一個declare-styleable為其定義相關的屬性。

類似color、string等資源那樣,每一個使用attr标簽定義的屬性,在R.styleable類中會生成一個對應的靜态隻讀int類型的字段作為其id。

例如上面的pointerRadius屬性在對應R.styleable.PieChart_pointerRadius屬性。

在attr.xml中定義好屬性後,布局檔案中,聲明控件的地方就可以指定這些屬性值了:

因為是引入的額外屬性,不是android内置的屬性(Android自身在sdk下資源attr.xml中定義好了内置各個View相關的屬性),需要使用一個不同的xml 命名空間來引用我們的屬性。

上面xmlns:custom=的聲明是一種引入的方式,格式是

另一種簡單的方式是

這樣所有自定義屬性都可以使用app:attrName這樣的方式被使用了。

xml中定義控件對象的标簽必須是類全名稱,而且自定義控件類是内部類時,需要這樣使用:

在控件類PieChart中,在構造器中通過AttributeSet參數獲得xml中定義的屬性值:

再次強調,xml中定義的對象最終被建立時所執行的構造器就是含Context和TypedArray兩個參數的構造器。

上面在構造方法中,必須調用super(context, attrs),因為父類View本身也有許多attribute需要解析。getAttributes方法首先獲得一個TypedArray對象,根據R.styleable類中對應每個attribute的id字段從TypedArray對象中擷取attribute的值。

解析到attribute值後,指派給對應的字段,這樣就完成了在xml中為控件對象提供初始值的目标。

TypedArray是一個共享的資源對象,使用完畢就立即執行recycle釋放對它的占用。

一方面可以通過xml中使用attribute來為控件對象提供初始值,類似其它java類那樣,為了在代碼中對控件相關狀态進行操作,需要提供這些屬性的通路方法。

控件類是和螢幕顯示相關的類,它的很多狀态都和其顯示的最終内容相關。最佳實踐是:總是暴露那些影響控件外觀和行為的屬性。

對于PieChart類,字段textHeigh用來控制顯示目前項對應标簽文本的高度,字段pointerRadius用來控制顯示的訓示圓點的半徑。

為了能控制其目前項标簽的文本高度,或者目前項訓示圓點的半徑,需要公開對這些字段的通路:

textHeigh和pointerRadius這樣的屬性的改變會導緻控件外觀發生變化,這時需要同步其UI顯示和内容資料,invalidate方法通知系統此View的展示區域已經無效了需要重新繪制。當控件大小發生變化時,requestLayout請求重新布局目前View對象的可見位置。

在關鍵屬性被修改後,應該重繪view,或者還要重新布局view對象在螢幕的顯示區域。保證其狀态和顯示統一。

控件會在互動過程中産生各種事件,自定義控件根據需要也要暴露出專有的使用者互動事件被監聽處理。

PieChart類在轉動的時候,訓示圓點訓示的目前項會發生變化。

是以這裡定義接口OnCurrentItemChanged來供使用者來監聽目前項的變化:

在定義了PieChart對象,為其提供可attribute,在布局中聲明了控件對象,提供了構造器中獲得這些attribute的方法,以及簡單的幾個屬性和事件定義完成之後,現在可以運作檢視控件的運作效果了。

目前它還沒有任何内容顯示和互動,但我們完成了基礎工作。

接下來,将會不斷加入更多的字段、方法來實作PieChart控件的功能目标。

為了實作PieChart的最終正确顯示涉及到好幾步操作,首先我們嘗試(如果有遇到其它技術問題,會暫停,然後分析該問題的解決,之後再回到上級問題本身)從繪制其顯示内容的方法onDraw開始。

控件繪制其内容是在onDraw方法中進行的,方法原型:

Canvas類表示畫布:它定義了一系列方法用來繪制文本、線段、位圖和一些基本圖形。自定義View根據需要使用Canvas來完成自己的UI繪制。

另一個繪制需要用到的類是Paint。

android.graphics包下衍生出了兩個方向:

Canvas處理繪制什麼的問題。

Paint處理怎麼繪制的問題。

例如,Canvas定義了一個方法用來畫線段,而Paint可以定義線段的顔色。Canvas定義了方法畫矩形,而Paint可以定義是否以固定顔色填充矩形或保持矩形内部為空。簡而言之,Canvas定義了可以在螢幕上繪制的圖形,Paint定義了繪制使用的顔色、字型、風格、以及和圖形相關的其它屬性。

是以,為了在onDraw()方法傳遞的Canvas畫布上繪制内容之前,需要準備好畫筆對象。

根據需要,可以建立多個畫筆來繪制不同的圖形。因為繪圖相關對象的建立都比較耗費性能,而onDraw方法調用頻率很gao(PieChart是可以轉動的,每次轉動都需要重新執行onDraw)。是以對Paint對象的建立放在PieChart對象建立時——也就是構造器中執行。下面定義了init()方法完成Paint對象的建立以及一些其它的初始化任務:

在init方法中,依次定義了mTextPaint和mPiePaint兩個畫筆對象。mTextPaint用來繪制PieChart中的标簽文本,訓示圓點,圓點和标簽之間的線段。mPiePaint用來繪制餅狀圖的各個扇形。

了解了Android架構為我們提供了Paint和Canvas用來繪制内容之後,那麼接下來就分析下如何實作PieChart的内容繪制。 下面在更具體地提出一個個問題、要完成的功能時,有時會直接對PieChart類引入新的字段、方法、類等來作為實作。

根據之前小結《1.1 ViewGroup和View的選擇》的讨論,PieChart的圓的繪制是通過另一個類PieView完成的。

這裡PieView類作為PieChart的内部類,友善一些字段的通路。

PieView繪制的圓是由多個扇形組成的,每個扇形對應一個顯示項。這裡定義Item類表示此扇形:

對于PieChart類的使用者,可以通過下面的addItem方法添加任意多個資料項:

可以看到,每個Item有它的顔色、标簽和值。每個Item最終展示成一個扇形,扇形的角度大小和它的value在所有Item的value總和的占比成正比。所有扇形從0°開始依次形成一個360°的圓。

角度的計算很簡單,添加新資料項的時候,顯示項集合發生變化,方法PieChart.onDataChanged()重新計算了所有Item的startAngle和endAngle:

得到了所有要顯示的扇形Item對象集合mData之後,繪制圓的工作就是從0°開始依次把每個扇形繪制就可以了。

這裡在PieView.onDraw方法中,使用Canvas提供的繪制一個圓弧的方法drawArc來繪制各個扇形:

畫筆對象mPiePaint在每次繪制時扇形時會改變其顔色為要繪制的Item對應扇形的顔色。

注意,上面drawArc的第一個參數RectF oval:

它表示繪制的扇形所在的圓的邊界矩形。

由于PieChart本身繪制标簽、訓示圓點和連接配接标簽與圓點的線段,它添加PieView對象作為其childView完成繪制圓,PieView.onDraw方法裡使用的mBounds是繪制圓用到的邊界參數。使用PieChart時,PieView是PieChart的内部類,無法指定它的大小。而是為PieChart指定大小。

接下來分析PieChart繪制标簽和繪制圓所涉及到的邊界大小的計算邏輯,以及PieChart作為布局容器,它如何配置設定給PieView需要的顯示區域。

為了繪制标簽和圓,首先需要知道它們的位置和大小,這裡就是需要确定PieChart和PieView對象的位置和大小。

Android UI架構中,所有View在螢幕上占據一個矩形區域,可以用類RectF(RectF holds four float coordinates for a rectangle.)來表示此區域。View最終顯示前,它的位置和大小需要确定下來(也就是它的顯示區域),可以通過LayoutParams來指定有個View的大小和相對父容器(parent ViewGroup)的位置資訊。

LayoutParams是ViewGroup的靜态内部類,它是ViewGroup用到的有關childView布局資訊的封裝。

這裡布局資訊就是childView提供的有關自身大小的資料。

LayoutParams的内容可以是兩種:

具體數值

layout_width/layout_height設定的是具體的像素值,很明顯隻能是正數。布局中可以是dp,px等。代碼中設定數值就直接是像素,必要的時候需要換算下。

枚舉值

MATCH_PARENT和WRAP_CONTENT兩個常量是負數。它們表示目前View對自身所需大小的要求,不是具體的數值,分别表示填充父布局和包裹内容。

在具體的ViewGroup子類中,可以提供它專有的LayoutParams子類來增加更多有關布局的資訊。比如像LinearLayout.LayoutParams中增加了margin屬性,可以讓childView指定和LinearLayout的間隙。

一個View的大小可以在代碼中使用setLayoutParams指定(預設的addView添加的childView使用的寬高均為LayoutParams.WRAP_CONTENT的LayoutParams),而在布局xml中定義View時,必須使用layout_height和layout_width。

LayoutParams是指定View布局大小的唯一方式,不像View.setPadding方法那樣是為View本身設定有關其顯示相關的尺寸資訊,它是指定給View的父布局ViewGroup對象的屬性,

而不是針對View本身的屬性。最終View的大小和位置是其父布局ViewGroup對象決定的,它使用View提供的LayoutParams參數作為參考,但并不會一定滿足childView提供的LayoutParams的布局要求。

為了明白LayoutParams這樣設計的原因,接下來對View從建立到顯示的過程做分析。

整個Activity最終展示的界面是一個由View和ViewGroup對象組成的view hierarchy結構,這裡稱它為ViewTree(視圖樹)。可以使用布局xml或完全通過代碼建立好所有的View對象。将ViewTree指定給Activity是通過執行Activity的setContentView方法,它有幾個重載方法,最完整的是:

Android中對螢幕的表示就是一個Window,Activity的内容是通過Window來渲染的。

在我們為Activity設定内容視圖View對象時,它實際上被設定給Window對象,上面Window.setContentView方法

将傳遞的View對象作為目前Screen要顯示的内容。

通常,我們所建立的界面内容是由多個View和ViewGroup對象組成的樹結構,可以通過hierarchy viewer工具來直覺檢視:

android自定義控件一站式入門

對應的布局xml如下:

```xml

```

這裡我們的根布局(root view)是LinearLayout對象,在ViewTree中,它是被添加到id為content的FrameLayout的,然後ViewTree向上一直到DecorView。可見,即便隻為Activity指定一個View對象,最終的View還是和架構建立的其它View對象形成了ViewTree。

在Activity.onCreate中建立好ViewTree之後,直到各個View對象最終顯示到螢幕,整個ViewTree需要依次經曆三個執行階段:

Measure測量

測量所有View的大小。要知道這些View、ViewGroup對象在顯示關系上是一個個矩形區域的包含和某種排列關系,要把它們根據關系确定其在螢幕上的區域之前,首先得知道其大小,也就是确定每個View所占據螢幕的矩形區域。

Layout布局

每個View的區域确定後,從根布局開始,每個ViewGroup負責根據其性質和childViews的大小正确放置每個View到螢幕坐标系中。很明顯,布局這些View對象是ViewGroup特有的職責。非ViewGroup的View對象因為不包含childView,它隻需要正确提供自身大小即可。

Draw繪制

所有View在螢幕上的區域确定後,最終的,就是界面渲染了。此時,每個View的繪制方法被執行。前面已經接觸了onDraw方法,正是在這裡每個View完成其内容的繪制。

ViewTree是典型的樹結構,對它的三個操作分别周遊操作了其中每個View對象。具體ViewTree是和每個Activity所指定的View對象集合決定的,而ViewTree這種結構本身反應了界面架構對View的處理過程——就是依次對ViewTree中的所有View對象執行其Measure、Layout和Draw。

這個周遊操作自頂向下(從DecorView開始)循環通路每個View對象,為了完成ViewTree的正确顯示,具體的View類自身需要實作這三個和它顯示内容相關的方法。

每個View都要實作其測量方法來正确提供自身大小資訊。

ViewTree周遊測量每個View,是通過調用其方法measure來完成的:

調用measure方法後,就執行了對此childView的測量。

widthMeasureSpec和heightMeasureSpec是調用者(一般就是包含此View的ViewGroup)所傳遞的有關寬高的限制資訊。

measure方法是一個final方法,子類是無法重寫的。這裡是應用了模闆方法的模式,measure裡面執行了一些統一操作,然後在内部調了抽象方法onMeasure,這樣的設計是為了讓子類在onMeasure中根據同樣的寬高限制來完成measure中剩餘的必須由子類去完成的計算大小的工作:

onMeasure的預設實作是将寬設定為View所指定的minWidth和其背景Drawable對象所允許的最小寬度中的較大的值,對于高的設定也是如此。

View子類在根據其顯示邏輯來重寫此方法時,需要注意兩點:

此方法無傳回值,子類完成測量後,需要執行setMeasuredDimension(int, int)來儲存其測量結果。

子類在測量完成後,應該保證所計算出的width和height的值滿足大于getSuggestedMinimumHeight()和getSuggestedMinimumWidth()的值。

繪制區域大小的計算邏輯應該考慮padding的設定。

在onMeasure方法中,它所接收的2個int參數widthMeasureSpec和heightMeasureSpec分别是父布局傳遞給它的寬和高的限制資訊,稱作測量規格。這個兩個整數是通過View.MeasureSpec工具類處理好的資料,其中封裝關于寬、高的大小和模式,采取這種設計是為了節約記憶體。

舉例來說,對于widthMeasureSpec,它是32位int整數,其前2位包含的資料表示測量規格的模式,後30位用來表示測量規格所提供的準确寬度。

總共有3種模式:

UNSPECIFIED

不對目前View做任何限制,這時View可以要求任意大小的寬高。

EXACTLY

對目前View的寬或高指定了明确的大小,這時此View應該根據此大小繪制内容,因為大小無法改變了。

AT_MOST

限定了目前View的寬或高的最大值。

對于傳遞的測量規格數值,可以使用View.MeasureSpec類的getMode(int measureSpec)和getSize(int measureSpec)分别擷取裡面封裝的模式和大小。

方法makeMeasureSpec(int size, int mode)可以根據指定的模式和大小構造新的測量規格。

了解了上面的測量規格,那麼可以在onMeasure中根據參數widthMeasureSpec和heightMeasureSpec來獲得父布局有關寬和高的大小、模式的限制。

如果自己的View是非ViewGroup類,那麼隻需要根據measureSpec來傳回View自身顯示内容時合适的大小。如果定義的View是ViewGroup子類,這時就需要根據childViews來确定自身大小了。此時需要調用childView的measure方法,方法需要針對childView的measureSpec參數,那麼如何生成合适的measureSpec呢?

要知道,我們對View大小的控制是指定其布局參數LayoutParams,前面解釋過,布局參數layout_width和layout_height是View為其ViewGroup提供的有關它自身布局大小的資訊,在ViewTree的測量階段,每個View的onMeasure所獲得的measureSpec資料,正是ViewGroup根據其LayoutParams所計算出的。LayoutParams可以是具體的數值,或者MATCH_PARENT和WRAP_CONTENT标志,很明顯,它和上面的measureSpec的資料設計不是一緻的,那麼就存在一個從LayoutParams得到measureSpec的轉換邏輯。

是以隻有在設計ViewGroup子類時需要知道如何根據父布局ViewGroup所傳遞measureSpec,再結合childView的LayoutParams,為調用childView.measure生成正确的measureSpec。

另一方面,了解這個轉換邏輯,在一些布局嵌套的情況下,就可以更容易決定采取什麼樣的LayoutParams是正确的。

既然根據LayoutParams得到measureSpec的邏輯隻是ViewGroup的工作,我們可以通過檢視ViewGroup相關的代碼來獲得其中的細節。可以想象,ViewGroup的子類完全可能會根據自身設計目标改變這個生成measureSpec的邏輯。這裡分析下ViewGroup類本身提供的有關測量childView時的一般處理。

在抽象類ViewGroup中,它為子類提供了一些通用的測量childView的方法,下面一一分析。

方法:measureChildren(int widthMeasureSpec, int heightMeasureSpec)

方法measureChildren的參數依然是measureSpec,它是目前ViewGroup的父布局傳遞的。很顯然,這個參數也是父布局根據目前ViewGroup對象所提供的LayoutParams确定的。

方法本身很簡單,依次對所有未隐藏的childView執行下面的方法:

這個方法正是ViewGroup測量每個childView的一般實作,它獲得childView的LayoutParams對象,調用方法getChildMeasureSpec生成測量此childView需要的新的MeasureSpec,最後調用child.measure完成對childView的測量。

方法getChildMeasureSpec的實作是測量childView的核心:

它的原型是:

第一個參數spec是目前ViewGroup本身的父布局傳遞給它的,由于ViewTree的自頂向下的周遊操作,最頂部DecorView生成的measureSpec跟着周遊測量的過程傳遞到每個下級的childView,當然,每次傳遞時,作為ViewGroup的childView可能會根據此measureSpec生成新的measureSpec給下級childView——這就是自定義ViewGroup需要做的。

參數padding表示目前ViewGroup不可使用的内邊距,這個可以包括padding,childView提供的margin,以及其它childView已經使用了的空間。

參數childDimension是要測量的childView所提供的期望尺寸。

getChildMeasureSpec方法所做的工作,就是根據目前ViewGroup的measureSpec,、childView提供LayoutParams,為childView生成合适的其寬、高的childMeasureSpec。

代碼上面列出了,根據其規則,可以得到下面的表格:

android自定義控件一站式入門

另一個測量childView的方法将childView的margins和其它childView已占用目前ViewGroup的空間也考慮進去了:

```java

protected void measureChildWithMargins(View child,

int parentWidthMeasureSpec, int widthUsed,

int parentHeightMeasureSpec, int heightUsed) {

final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

}

類似measure和onMeasure的關系,ViewTree在繪制階段,會傳遞一個Canvas對象,然後調用每個View的draw方法。

draw方法本身做了一些統一操作,它内部調用了onDraw方法,前面已經接觸了onDraw方法。所有View子類在onDraw方法中完成自身顯示内容的繪制。

View子類幾乎都有它的繪制邏輯。而ViewGroup根據需要可以繪制一些布局的裝飾内容。

以上詳細分析了Android中View顯示的整個流程,介紹了自定義View和ViewGroup需要重寫的一些關鍵的方法。下來就看下PieChart類是如何實作自身區域的計算,以及它包含的PieView和PointerView兩個childView的布局邏輯。

首先,每個View子類需要實作onMeasure方法來提供自身大小。一般地,如果自己的View類不需要對它的大小計算做額外的控制,那麼隻需要重寫onSizeChanged(),這時View的大小的确定邏輯是基類View預設的行為,這時依然可以對它指定準确的大小或MATCH_PARENT和WRAP_CONTENT。

PieChart要顯示的内容包括标簽和圓,以及訓示點。這裡隻有标簽和圓需要平分繪制空間,而

訓示點本身是繪制在圓内的,

标簽和訓示點的連線也是由标簽和圓的相對位置決定的。

可以回顧案例介紹中的示例圖檔,标簽的顯示是在圓的左邊或右邊。

為了在最終顯示時,讓圓的直徑不少于标簽的寬度,這裡需要重寫下面2個方法:

這兩個方法表達了PieChart控件在寬高方面的最低要求。

相應地,重寫onMeasure方法完成顯示要求的大小的計算:

View類提供了靜态工具方法來處理和measureSpec相關的計算:

作為優化,由于PieChart類沒有複雜的布局邏輯。是以PieChart類沒有在onLayout中做任何邏輯,而是重寫onSizeChanged方法在自身大小發生變化時重新計算并放置用來繪制圓和訓示圖形的PieView和PointerView兩個childView對象。

在方法onSizeChanged中,PieChart根據自身大小完成了所有要顯示内容的大小計算和布局:

具體的計算邏輯代碼裡的注釋簡單說明了下,方法最終執行mPieView和mPointerView的layout方法,将它們放置在PieChart中合适的位置。

完成畫筆的建立和設定,自身大小的測量和各部分布局之後,就是自定義View最主要的工作繪制了。

PieChart作為布局類,它自己onDraw方法中繪制了标簽。自身添加一個PieView用來繪制圓,PointerView用來繪制訓示點和訓示點到标簽文本的線。

這樣做的原因是,圓需要轉動是以為了可以獨立地開啟硬體加速,繪制圓的工作放在了單獨的類PieView中。标簽和圓是不會重合的,是以标簽可以在PieChart自身中繪制。最後,為了讓訓示點和線段繪制在圓的上面,再使用PointerView來完成繪制。

下面的示例圖示注了PieChart的圖形組成:

android自定義控件一站式入門

各部分分别在onDraw方法中完成繪制。前面介紹了使用Canvas.drawArc繪制圓的方式。

标簽、線段、訓示點分别使用Canvas的drawText、drawLine和drawCircle進行繪制,具體代碼很簡單這裡不列出了。

現在可以使用PieChart調用其addItem方法添加幾個要展示的資料,運作程式就可以看到示例中的效果圖了。

接下來響應使用者互動:實作滑動手指轉動圓的效果。

類似其它的軟體平台的UI架構那樣,Android支援輸入事件這樣的模型。使用者操作最後被轉變為不同的事件,它們觸發各種回調方法。然後我們可以重寫這些回調方法來響應使用者。

View類中提供了對各種不同的互動事件的回調方法:

onScrollChanged:View水準或垂直方向上滾動自身内容後發生。

onDragEvent:拖拽事件。

onTouchEvent:觸摸事件。

onInterceptTouchEvent、onGenericMotionEvent...

根本上看,螢幕上的手勢操作幾乎都是遵循onTouchEvent的事件流程的。

自己去重寫onTouchEvent方法完成對滑動和flywheel等顯示世界中的動作的監聽處理是可以的,但無疑是很繁瑣的——需要考慮多少像素的移動算是滑動,多久的觸摸算是長按,多快的速度會引起flywheel等。Android提供好了一些輔助類來簡化這些通用的互動操作的監聽。

GestureDetector類将原始的觸摸事件轉變為不同的手勢操作。

在使用時,執行個體化一個GestureDetector對象,然後在onTouchEvent中讓它處理MotionEvent:

GestureDetector.onTouchEvent傳回值表示此事件是否被處理,如果沒有則可以選擇繼續處理原始的MotionEvent事件。

然後通過提供GestureDetector.OnGestureListener回調對象給GestureDetector對象來監聽它支援的手勢事件。

在GestureListener.onScroll方法中,我們對滑動轉動進行處理。

根據需求,手指滑動後形成一個向量,考慮此向量和圓心到它的垂直線段:

android自定義控件一站式入門

O為圓心,AB為滑動向量。

OH為O到AB的垂直向量。

假設半徑為OH的圓,那麼AB和BA的滑動力會引起不同的轉動方向:

自然地,A到B是逆時針,B到A是順時針。

因為取OH比較麻煩,下面的思路是取OA的垂直向量,然後求和AB的點乘,根據結果判定相對方向:

點乘公式:

a·b=|a||b|·cosθ

結果的正負可以用來判斷兩個向量之間的夾角θ。

如下圖,AB是滑動向量。

O為圓心。A是觸摸起點。

取得OA的垂直向量AH。

根據點乘,得到AB和AH之間的角度θ,就可以判斷AB和AH的相對方向。

若AB和AH都在直線OA的一邊,那麼逆時針。反之,若AB在OA的另一邊,順時針。

android自定義控件一站式入門

代碼實作:

圓是PieView繪制的,轉動圓的操作可以通過執行View.setRotation來轉動PieView本身完成。(這是API 11中View類引入的方法,之前的版本可以通過canvas.rotate完成,但這樣的話操作就需要在onDraw中執行,為了通知系統執行某個View的onDraw方法,執行View.invalidate即可)。

PieChart類提供了方法setPieRotation讓使用者改變它的旋轉角度:

值得一提的是,PieChart計算角度的時候會将角度轉換為0360度之間的值,這樣是因為PieView繪制的各個扇形的角度分别占據了0360度之間的各段。在繪制效果不變的情況下,這樣(角度不為負數,不會大于360)會使得角度的處理簡單很多。

在要顯示的扇形發生變化或者轉動之後,訓示點對應的目前扇形會發生變化,這時需要重新計算目前項:

因為轉動後的角度mPieRotation是0~360之間,mCurrentItemAngle是訓示點對應的角度:在繪制它的時候已經計算好了,隻能是45,135,225,315四個角度之一。

上面計算轉動後訓示點落在哪個扇形的思路是:

假設所有扇形還是依次從0度開始的——也就是未轉動的情形,讓訓示點本身的角度減去mPieRotation度,得到的角度相當于“未轉動扇形時訓示點的角度”。然後計算此角度pointerAngle處在哪個扇形的角度範圍。

計算出目前扇形後,執行setCurrentItem方法:

其中最主要的是調用了OnCurrentItemChanged方法,這也是PieChart控件唯一暴露給外界的根據功能特有的事件。scrollIntoView參數控制是否讓訓示點在目前扇形中角度居中。這個centerOnCurrentItem的邏輯後面會介紹的。

根據需求,使用者手指快速滑過螢幕PieChart的區域後,在手指離開螢幕後,圓的轉動不會立即停止,而是像現實世界中那樣,當你轉動一個類似固定位置的圓形輪胎之類的東西那樣,它需要再繼續轉動慢慢地停止下來。這個效果就是一直提到的flywheel效果。

要實作上述的flyWheel效果,需要分析兩件事情:

flyWheel效果明顯是一個随時間遞減的過程,那麼需要提供一個邏輯來計算停下了需要的時間,以及随時間減少時轉動的角度。

提供效果持續時重新整理界面的方式。

通過上面對flyWheel效果的描述,它其實就是一個PieView上進行的一個“動畫”。

動畫是關于時間和值的一個概念,就是在一段時間,或者是時間不做限制時,随着時間的推進,對應的某個值不斷發生變化。

這裡,根據需求,要對PieView做的事情就是,在使用者快速滑動結束後,讓它以動畫的方式繼續轉動直至停止。

為了實作這個目标有下面幾個方法:

自己實作定時旋轉PieView:這種方式最大的問題是時間間隔不好确定,因為不同裝置性能不同,最終界面重新整理頻率不一樣。無法給出一個體驗良好的數值。如果是API 11之前,旋轉隻能通過canvas.rotate就需要定時去執行pieView.invalidate讓它執行onDraw。API 11以上就執行pieView.setRotation。

onDraw中根據條件繼續調用invalidate:這個不是定時去執行onDraw,而是每次onDraw之後如果發現還需要執行動畫就繼續觸發下一次onDraw。這樣在結束繪制動畫前,onDraw的執行是由裝置本身允許的重新整理頻率決定的,時間間隔是比對裝置本身的繪制能力的,可以取得很好的動畫效果。

使用屬性動畫,在API 11之後可以通過新增的屬性動畫來實作動畫效果。屬性動畫本身負責根據每一幀的回調,無需我們自己去考慮重新整理頻率的問題。

以上三種方式,屬性動畫是最簡單的。屬性動畫提供了ValueAnimator和ObjectAnimator,值動畫可以在限定的多個值之間生成動畫值。對象動畫是值動畫的子類,可以直接将動畫值應用到目标對象。轉動動畫的值的計算是Scroller完成的,這裡使用ValueAnimator來獲得每一幀的回調。

在解決了如何實作讓PieView不斷繪制的問題後,下一個要解決的是每次繪制多少度的問題。

為了取得顯示中轉動停止的效果,動畫應該是一個轉動減速直到停止的過程,而且一開始的轉動速度是和手指離開時的轉動速度相關的。可以想到使用插值算法來完成這種模拟,不過Android提供了Scroller類來模拟真實的滑動效果,注意這裡的滑動和圓的轉動實質是一樣的,最終都是速度(線速度、角速度)問題。可以通過它來模拟滑動減速的效果。

Scroller是一個持有位置資料,并提供操作改變這些資料的類,具體的執行頻率是調用者的事情,可以使用handler、動畫等方式實作周期性來不斷調用它的computeScrollOffset來獲得更新後的位置。通過Scroller.isFinished來判斷滑動動畫是否停止。

在前面的GestureListener.onFling中收集當時的轉動速度。

因為Scroller是同時處理X、Y上的滑動的,這裡角速度隻需要對應X或Y中一個就可以了。

這裡選擇讓角速度作為Scroller.fling時的Y軸的加速度,角度就是起始Y值。

mScroller.fling開啟了滑動。

同時,mScrollAnimator也被啟動,它是一個值動畫,這裡并不使用它修改某個View的屬性,而是依靠它來獲得定時重新整理的回調。在動畫的更新回調方法中執行旋轉操作。

每次可以繪制界面的時候,執行tickScrollAnimation執行滑動動畫:

方法中根據mScroller計算了新的Y——也就是角速度,然後改變圓的旋轉角度。

一直到mScroller.isFinished()位true的時候,轉動動畫就結束了。

根據需求,使用者手指離開螢幕,滑動結束後,應該可以繼續執行轉動動畫,讓訓示點落在所在的目前扇形的角度範圍中間。也就是目前扇形的(startAngle + endAngle) / 2 的值等于訓示點的角度值。

動畫的效果這裡選擇使用ObjectAnimator完成,它是上面轉動動畫使用的ValueAnimator的子類。

ObjectAnimator可以針對目标對象的一些屬性執行動畫,随着時間行進,屬性值被實際改變。

這裡用來對PieChart類的PieRotation屬性進行動畫,使得滑動結束後繼續轉動圓讓訓示點居中在目前扇形。

動畫的方案确定了,另一個問題就是計算居中需要轉動到的目标角度:

方法計算居中後圓的轉動角度時采取了和計算目前扇形類似的思路。

就是假設轉動圓的效果是保持圓不動,然後訓示點的角度減去mPieRotation即可。

上面relativePointerAngle是居中前目前PieChart轉動角度為mPieRotation時,讓mPieRotation為0時訓示點和各個扇形的相對位置。

這樣,計算relativePointerAngle到目标扇形的中間角度originPieMiddleAngle的內插補點delta,之後給mPieRotation補上這個差就可以得到居中時最終的轉動角度。

以上長篇大論,以官方的PieChart案例來分析,一步步完成了自定義控件的設計和開發,中間對涉及到的API進行了介紹。

自定義控件的實踐是沒有盡頭的,給你畫布和畫筆,唯一的限制隻有你的想象力。

更多的API的學習,如屬性動畫,事件分發,可以參考sdk文檔,查閱android.view包下提供的各種類型。對架構類進行學習是很不錯的開始。

源碼位址:https://git.oschina.net/idlestar/AndroidSample

Creating Custom Views

目錄:Develop > Training > Best Practices for User Interface > Creating Custom Views

檔案路徑:/sdk/docs/training/custom-views/create-view.html

Custom Components

目錄:Develop > API Guides > User Interface > Custom Components

檔案路徑:/sdk/docs/guide/topics/ui/custom-components.html

(本文使用Atom編輯器編寫)