android自定義控件一站式入門

TODO: 待整理

自定義控件

Android系統提供了一系列UI相關的類來幫助咱們構造app的界面,以及完成交互的處理。
通常的,全部能夠在窗口中被展現的UI對象類型,最終都是繼承自View的類,這包括展現最終內容的非佈局View子類和繼承自ViewGroup的佈局類。
其它的諸如Scroller、GestureDetector等android.view包下的輔助類簡化了有關視圖操做和交互處理。
不管如何,本身的app中總會遇到內建的類型沒法知足要求的場景,這時就必須實現本身的UI組件了。html

自定義控件的方式

根據須要,有如下幾個方式來完成自定義控件:java

  • 繼承View或ViewGroup類
    這種狀況是你須要徹底控制視圖的內容展現和交互處理的狀況下,直接繼承View類能夠得到最大限度的定製。android

  • 繼承特定的View子類
    若是內建的某個View子類基本符合使用要求,只是須要定製該View某些方面的功能時,選擇此種方式。
    例如繼承TextView爲其增長特殊的文字顯示效果,豎排顯示等。git

  • 組合已有View
    組合View實現自定義控件其實主要就是爲了完成組合成後的目標View的複用。這裏組合就是定義一個ViewGroup的子類,而後添加須要的childView。
    典型的有EditText + ListView實現Combox(下拉框)這樣的東西。作法就是繼承佈局類,而後inflate對應佈局文件or代碼中建立(蛋疼)做爲其包含的child views。
    統一的搜索欄,級聯菜單等,組合控件其實有點相似佈局中include這樣的作法,若是爲一個可複用的片斷layout配一個ViewManager,效果幾乎是同樣的。固然,自定義控件的好處就是能夠在xml中直接聲明,並且UI和對應邏輯是集中管理的。便於複用。算法

案例:PieChart控件

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

功能目標:app

將要實現的PieChart控件以下圖:
框架


PieChart示例圖

PieChart的圖形組成


具備如下主要功能目標:

  • PieChart須要展現一個由一或多個扇形組成的圓,一個在圓的固定位置的指示圓點,一個在圓的左側或右側固定位置的標籤。
  • 圓的每一個扇形表示一個顯示項(Item)。能夠添加任意多個Item,每一個Item有它的color、value、label來肯定扇形的顯示。全部扇形根據其添加順序順時針從0°開始組成整個圓。如上面的是包含紅、綠、藍,值分別爲一、二、3的三個Item組成的圓。
  • 手指滑動時轉動餅狀圖,滑動方向與圓心到滑動方向的直線決定了轉動方向。例如手指處在圓心下方時向左滑動時圓順時針轉動。
  • 圓轉動時,指示圓點落在那個扇形的區域,扇形對應的Item就是當前Item。它對應的label內容被顯示。
  • 手指快速劃事後(fling——具備flywheel效果),餅狀圖以動畫的方式慢慢中止而不是當即中止轉動。
  • 滑動(包括fling)結束後,居中當前項——指示點在當前項對應扇形角度中心。

以上是要實現的自定義控件PieChart須要知足的業務要求。下面就一步步設計和完成PieChart控件。編輯器

基礎工做

在開始實現控件的功能目標以前,須要作一些基礎工做,讓本身的控件能夠運行調試。以後再逐步完成顯示和交互功能。ide

1. 建立PieChart類:

1.1 ViewGroup和Viw的選擇

View只能顯示內容,而ViewGroup能夠包含其餘View或ViewGroup。ViewGroup自己也是View的子類,它也能夠顯示內容。
爲了讓PieChart能夠同時顯示標籤和圓,可使用一個單獨的View子類來繪製,可是,這裏選擇讓PieChart做爲一個ViewGroup,
它來顯示標籤和指示圓點,而後設計一個PieView類來完成圓的繪製。

這樣作有如下好處是:

  1. 在Android 3.0(API 11)以後,引入了硬件加速特性,在執行一些動畫時能夠提高UI體驗。可是啓用硬件加速須要更多的內存開銷。
    對於須要轉動和使用動畫效果的圓來講,在它執行動畫的時候能夠開啓硬件加速,動畫中止的時候取消硬件加速。分多個View能夠在獨立的硬件加速層繪製圓,又避免了標籤和指示圓點這樣寫圖形不須要加速的事實。
  2. 分開兩個View,可讓邏輯更加清晰,避免一個類過分複雜(出於演示目的)。
  3. PieChart繼承ViewGroup,PieView繼承View,這樣能夠在當前案例中同時介紹到自定義View相關的「測量、佈局和繪製」的知識。

1.2 構造器和佈局xml建立

控件對象應該能夠是經過代碼或xml方式建立。
經過xml方式定義的控件在建立時執行的是包含Context和AttributeSet兩個參數的構造器,爲了能夠在xml中定義控件對象,PieChart類就須要提供此構造器:

public class PieChart extends ViewGroup {
  public PieChart(Context context) {
      super(context);
      init();
  }

  public PieChart(Context context, AttributeSet attrs) {
      super(context, attrs);
      getAttributes(context, attrs);
      init();
  }
  ...
}

額外的AttributeSet參數攜帶了在xml中爲控件指定的attribute集合。attribute表示能夠在佈局xml文件中定義View時使用的xml元素名稱,例如layput_width,padding這樣的。這些attribute至關於在定義控件對象的時候提供的初始值,更直接點,相似於構造函數的參數。

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

  1. 爲控件定義xml中使用的attribute。
  2. 在佈局文件中爲控件使用這些attribute。
  3. 構造器經過AttributeSet參數得到xml中定義的這些attribute值。

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

attribute和property都翻譯爲屬性,attribute表示能夠在佈局xml文件中定義View時使用的xml元素名稱,例如layput_width,padding這樣的。而property表示類的getter/setter或者相似的對某個private字段的訪問方法。

2. 提供和使用自定義屬性

2.1 定義attribute

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

<resources>
   <declare-styleable name="PieChart">
       <attr name="showText" format="boolean" />
       <attr name="labelHeight" format="dimension"/>
       <attr name="pointerRadius" format="dimension"/>
       <attr name="labelPosition" format="enum">
           <enum name="left" value="0"/>
           <enum name="right" value="1"/>
       </attr>
   </declare-styleable>
</resources>

對應每一個View類,使用一個declare-styleable爲其定義相關的屬性。
相似color、string等資源那樣,每個使用attr標籤訂義的屬性,在R.styleable類中會生成一個對應的靜態只讀int類型的字段做爲其id。
例如上面的pointerRadius屬性在對應R.styleable.PieChart_pointerRadius屬性。

public static final int PieChart_pointerRadius = 8;

2.2 使用attribute

在attr.xml中定義好屬性後,佈局文件中,聲明控件的地方就能夠指定這些屬性值了:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:custom="http://schemas.android.com/apk/res/com.example.customviews">
 <com.example.customviews.charting.PieChart
     custom:showText="true"
     custom:pointerRadius="4dp"
     custom:labelPosition="left" />
</LinearLayout>

由於是引入的額外屬性,不是android內置的屬性(Android自身在sdk下資源attr.xml中定義好了內置各個View相關的屬性),須要使用一個不一樣的xml 命名空間來引用咱們的屬性。
上面xmlns:custom=的聲明是一種引入的方式,格式是

http://schemas.android.com/apk/res/[your package name]

另外一種簡單的方式是

xmlns:app="http://schemas.android.com/apk/res-auto"

這樣全部自定義屬性均可以使用app:attrName這樣的方式被使用了。

xml中定義控件對象的標籤必須是類全名稱,並且自定義控件類是內部類時,須要這樣使用:

<View
    class="com.android.notepad.NoteEditor$MyEditText"
    custom:showText="true"
    custom:labelPosition="left" />

3. 獲取並使用自定義屬性

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

public class PieChart extends ViewGroup {
    public PieChart(Context context, AttributeSet attrs) {
       super(context, attrs);
       getAttributes(context, attrs);
       init();
    }

    private void getAttributes(Context context, AttributeSet attrs) {
      TypedArray a = context.getTheme().obtainStyledAttributes(
           attrs, R.styleable.PieChart, 0, 0);

      try {
          mShowText = a.getBoolean(R.styleable.PieChart_showText, false);
          mTextPos = a.getInteger(R.styleable.PieChart_labelPosition, 0);
      } finally {
          a.recycle();
      }
    }
}

再次強調,xml中定義的對象最終被建立時所執行的構造器就是含Context和TypedArray兩個參數的構造器。
上面在構造方法中,必須調用super(context, attrs),由於父類View自己也有許多attribute須要解析。getAttributes方法首先得到一個TypedArray對象,根據R.styleable類中對應每一個attribute的id字段從TypedArray對象中獲取attribute的值。
解析到attribute值後,賦值給對應的字段,這樣就完成了在xml中爲控件對象提供初始值的目標。

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

4. 暴露property和事件

4.1 控件屬性(property)

一方面能夠經過xml中使用attribute來爲控件對象提供初始值,相似其它java類那樣,爲了在代碼中對控件相關狀態進行操做,須要提供這些屬性的訪問方法。
控件類是和屏幕顯示相關的類,它的不少狀態都和其顯示的最終內容相關。最佳實踐是:老是暴露那些影響控件外觀和行爲的屬性。

對於PieChart類,字段textHeigh用來控制顯示當前項對應標籤文本的高度,字段pointerRadius用來控制顯示的指示圓點的半徑。
爲了能控制其當前項標籤的文本高度,或者當前項指示圓點的半徑,須要公開對這些字段的訪問:

class PieChart extends ViewGroup {
  ...

  // 屬性
  public float getTextHeight() {
      return mTextHeight;
  }

  public void setTextHeight(float textHeight) {
     mTextHeight = textHeight;
     invalidate();
  }

  public float getPointerRadius() {
      return mPointerRadius;
  }

  public void setPointerRadius(float pointerRadius) {
      mPointerRadius = pointerRadius;
      invalidate();
  }
}

textHeigh和pointerRadius這樣的屬性的改變會致使控件外觀發生變化,這時須要同步其UI顯示和內容數據,invalidate方法通知系統此View的展現區域已經無效了須要從新繪製。當控件大小發生變化時,requestLayout請求從新佈局當前View對象的可見位置。
在關鍵屬性被修改後,應該重繪view,或者還要從新佈局view對象在屏幕的顯示區域。保證其狀態和顯示統一。

4.2 控件事件

控件會在交互過程當中產生各類事件,自定義控件根據須要也要暴露出專有的用戶交互事件被監聽處理。
PieChart類在轉動的時候,指示圓點指示的當前項會發生變化。
因此這裏定義接口OnCurrentItemChanged來供使用者來監聽當前項的變化:

class PieChart extends ViewGroup {
  ...

  // 事件
  private OnCurrentItemChangedListener mCurrentItemChangedListener = null;

  public interface OnCurrentItemChangedListener {
      void OnCurrentItemChanged(PieChart source, int currentItem);
  }

  public void setOnCurrentItemChangedListener(OnCurrentItemChangedListener listener) {
      mCurrentItemChangedListener = listener;
  }
}

5. 基礎工做小結

在定義了PieChart對象,爲其提供可attribute,在佈局中聲明瞭控件對象,提供了構造器中得到這些attribute的方法,以及簡單的幾個屬性和事件定義完成以後,如今能夠運行查看控件的運行效果了。
目前它尚未任何內容顯示和交互,但咱們完成了基礎工做。
接下來,將會不斷加入更多的字段、方法來實現PieChart控件的功能目標。

實現繪製過程

爲了實現PieChart的最終正確顯示涉及到好幾步操做,首先咱們嘗試(若是有遇到其它技術問題,會暫停,而後分析該問題的解決,以後再回到上級問題自己)從繪製其顯示內容的方法onDraw開始。

6. 理解onDraw方法

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

protected void onDraw(Canvas canvas);

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對象的建立以及一些其它的初始化任務:

public class PieChart extends ViewGroup {
  private void init() {
     mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
     mTextPaint.setColor(mTextColor);
     if (mTextHeight == 0) {
         mTextHeight = mTextPaint.getTextSize();
     } else {
         mTextPaint.setTextSize(mTextHeight);
     }

     mPiePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
     mPiePaint.setStyle(Paint.Style.FILL);
     mPiePaint.setTextSize(mTextHeight);
     ...
}

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

理解了Android框架爲咱們提供了Paint和Canvas用來繪製內容以後,那麼接下來就分析下如何實現PieChart的內容繪製。
下面在更具體地提出一個個問題、要完成的功能時,有時會直接對PieChart類引入新的字段、方法、類等來做爲實現。

8. PieView圓的繪製

根據以前小結《1.1 ViewGroup和View的選擇》的討論,PieChart的圓的繪製是經過另外一個類PieView完成的。
這裏PieView類做爲PieChart的內部類,方便一些字段的訪問。
PieView繪製的圓是由多個扇形組成的,每一個扇形對應一個顯示項。這裏定義Item類表示此扇形:

// Item是PieChart的內部類
private class Item {
    public String mLabel;
    public float mValue;
    public int mColor;

    // 在添加顯示項的時候,每一個顯示項會根據全部顯示項來計算它的角度範圍
    public int mStartAngle;
    public int mEndAngle;
}

對於PieChart類的使用者,能夠經過下面的addItem方法添加任意多個數據項:

public class PieChart extends ViewGroup {
  ...
  private ArrayList<Item> mData = new ArrayList<Item>();
  private float mTotal = 0.0f;

  public int addItem(String label, float value, int color) {
      Item it = new Item();
      it.mLabel = label;
      it.mColor = color;
      it.mValue = value;

      mTotal += value;
      mData.add(it);

      onDataChanged();
      return mData.size() - 1; // 返回添加的數據在數據集合的索引
  }
}

能夠看到,每一個Item有它的顏色、標籤和值。每一個Item最終展現成一個扇形,扇形的角度大小和它的value在全部Item的value總和的佔比成正比。全部扇形從0°開始依次造成一個360°的圓。
角度的計算很簡單,添加新數據項的時候,顯示項集合發生變化,方法PieChart.onDataChanged()從新計算了全部Item的startAngle和endAngle:

public class PieChart extends ViewGroup {
  private void onDataChanged() {    
      int currentAngle = 0;
      for (int i = 0; i < mData.size(); i++) {
          Item it = mData.get(i);
          it.mStartAngle = currentAngle;
          it.mEndAngle = (int) ((float) currentAngle + it.mValue * 360.0f / mTotal);
          currentAngle = it.mEndAngle;
      }

      calcCurrentItem();
      onScrollFinished();
  }
}

獲得了全部要顯示的扇形Item對象集合mData以後,繪製圓的工做就是從0°開始依次把每一個扇形繪製就能夠了。
這裏在PieView.onDraw方法中,使用Canvas提供的繪製一個圓弧的方法drawArc來繪製各個扇形:

/**
  * Internal child class that draws the pie chart onto a separate hardware layer
  * when necessary.
  * PieView做爲PieChart的內部類,它在必要的時候(執行動畫)在獨立的硬件層來繪製內容。
  */
private class PieView extends View {
  RectF mBounds;

  protected void onDraw(Canvas canvas) {
              super.onDraw(canvas);
      // drawArc(RectF oval, float startAngle, float sweepAngle, boolean useCenter, Paint paint)
      // drawArc在給定的oval(橢圓,圓是特殊的橢圓)中從角度startAngle開始繪製角度爲sweepAngle的圓弧。
      // 繪製方向爲順時針,圓弧和兩條半徑組成了每一個數據項展現的扇形。
      for (Item it : mData) {
          mPiePaint.setColor(it.mColor);
          canvas.drawArc(mBounds,
                  it.mStartAngle,
                  it.mEndAngle - it.mStartAngle,
                  true, mPiePaint);
      }
  }
}

畫筆對象mPiePaint在每次繪製時扇形時會改變其顏色爲要繪製的Item對應扇形的顏色。
注意,上面drawArc的第一個參數RectF oval:

* @param oval  The bounds of oval used to define the shape and size
*              of the arc.

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

因爲PieChart自己繪製標籤、指示圓點和鏈接標籤與圓點的線段,它添加PieView對象做爲其childView完成繪製圓,PieView.onDraw方法裏使用的mBounds是繪製圓用到的邊界參數。使用PieChart時,PieView是PieChart的內部類,沒法指定它的大小。而是爲PieChart指定大小。
接下來分析PieChart繪製標籤和繪製圓所涉及到的邊界大小的計算邏輯,以及PieChart做爲佈局容器,它如何分配給PieView須要的顯示區域。

9. 繪製區域計算

爲了繪製標籤和圓,首先須要知道它們的位置和大小,這裏就是須要肯定PieChart和PieView對象的位置和大小。
Android UI框架中,全部View在屏幕上佔據一個矩形區域,能夠用類RectF(RectF holds four float coordinates for a rectangle.)來表示此區域。View最終顯示前,它的位置和大小須要肯定下來(也就是它的顯示區域),能夠經過LayoutParams來指定有個View的大小和相對父容器(parent ViewGroup)的位置信息。

9.1 LayoutParams

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從建立到顯示的過程作分析。

9.2 View對象的建立

整個Activity最終展現的界面是一個由View和ViewGroup對象組成的view hierarchy結構,這裏稱它爲ViewTree(視圖樹)。可使用佈局xml或徹底經過代碼建立好全部的View對象。將ViewTree指定給Activity是經過執行Activity的setContentView方法,它有幾個重載方法,最完整的是:

/**
 * Set the activity content to an explicit view.  This view is placed
 * directly into the activity's view hierarchy.  It can itself be a complex
 * view hierarchy.
 *
 * @param view The desired content to display.
 * @param params Layout parameters for the view.
 *
 * @see #setContentView(android.view.View)
 * @see #setContentView(int)
 */
public void setContentView(View view, ViewGroup.LayoutParams params) {
    getWindow().setContentView(view, params);
    initWindowDecorActionBar();
}

Android中對屏幕的表示就是一個Window,Activity的內容是經過Window來渲染的。
在咱們爲Activity設置內容視圖View對象時,它實際上被設置給Window對象,上面Window.setContentView方法
將傳遞的View對象做爲當前Screen要顯示的內容。

一般,咱們所建立的界面內容是由多個View和ViewGroup對象組成的樹結構,能夠經過hierarchy viewer工具來直觀查看:


ViewTree示例

對應的佈局xml以下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:custom="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent" android:layout_height="match_parent"
    android:orientation="vertical">
    <LinearLayout
        android:layout_width="match_parent" android:layout_height="wrap_content"
        android:orientation="horizontal">
        <com.idlestar.androiddocs.view.widget.PieChart
            android:id="@+id/Pie"
            android:layout_width="140dp"
            android:layout_height="140dp"
            ... />
        <com.idlestar.androiddocs.view.widget.PieChart
            android:id="@+id/Pie2"
            android:layout_width="140dp"
            android:layout_height="140dp"
            ... />
    </LinearLayout>
    <Button
        android:id="@+id/Reset"
        android:layout_width="match_parent" android:layout_height="wrap_content"
        android:text="@string/reset_button" />
</LinearLayout>

這裏咱們的根佈局(root view)是LinearLayout對象,在ViewTree中,它是被添加到id爲content的FrameLayout的,而後ViewTree向上一直到DecorView。可見,即使只爲Activity指定一個View對象,最終的View仍是和框架建立的其它View對象造成了ViewTree。

9.3 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類自身須要實現這三個和它顯示內容相關的方法。

9.4 View的測量

每一個View都要實現其測量方法來正確提供自身大小信息。

9.4.1 measure和onMeasure

ViewTree遍歷測量每一個View,是經過調用其方法measure來完成的:

public final void measure(int widthMeasureSpec, int heightMeasureSpec)

調用measure方法後,就執行了對此childView的測量。
widthMeasureSpec和heightMeasureSpec是調用者(通常就是包含此View的ViewGroup)所傳遞的有關寬高的限制信息。
measure方法是一個final方法,子類是沒法重寫的。這裏是應用了模板方法的模式,measure裏面執行了一些統一操做,而後在內部調了抽象方法onMeasure,這樣的設計是爲了讓子類在onMeasure中根據一樣的寬高約束來完成measure中剩餘的必須由子類去完成的計算大小的工做:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
            getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}

onMeasure的默認實現是將寬設置爲View所指定的minWidth和其背景Drawable對象所容許的最小寬度中的較大的值,對於高的設置也是如此。

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

  1. 此方法無返回值,子類完成測量後,須要執行setMeasuredDimension(int, int)來保存其測量結果。
  2. 子類在測量完成後,應該保證所計算出的width和height的值知足大於getSuggestedMinimumHeight()和getSuggestedMinimumWidth()的值。
  3. 繪製區域大小的計算邏輯應該考慮padding的設置。

9.4.2 widthMeasureSpec和heightMeasureSpec

在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呢?

9.4.3 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)

/**
 * Ask all of the children of this view to measure themselves, taking into
 * account both the MeasureSpec requirements for this view and its padding.
 * We skip children that are in the GONE state The heavy lifting is done in
 * getChildMeasureSpec.
 *
 * @param widthMeasureSpec The width requirements for this view
 * @param heightMeasureSpec The height requirements for this view
 */
protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) {
    final int size = mChildrenCount;
    final View[] children = mChildren;
    for (int i = 0; i < size; ++i) {
        final View child = children[i];
        if ((child.mViewFlags & VISIBILITY_MASK) != GONE) {
            measureChild(child, widthMeasureSpec, heightMeasureSpec);
        }
    }
}

方法measureChildren的參數依然是measureSpec,它是當前ViewGroup的父佈局傳遞的。很顯然,這個參數也是父佈局根據當前ViewGroup對象所提供的LayoutParams肯定的。
方法自己很簡單,依次對全部未隱藏的childView執行下面的方法:

/**
 * Ask one of the children of this view to measure itself, taking into
 * account both the MeasureSpec requirements for this view and its padding.
 * The heavy lifting is done in getChildMeasureSpec.
 *
 * @param child The child to measure
 * @param parentWidthMeasureSpec The width requirements for this view
 * @param parentHeightMeasureSpec The height requirements for this view
 */
protected void measureChild(View child, int parentWidthMeasureSpec,
        int parentHeightMeasureSpec) {
    final LayoutParams lp = child.getLayoutParams();

    final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
            mPaddingLeft + mPaddingRight, lp.width);
    final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
            mPaddingTop + mPaddingBottom, lp.height);

    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}

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

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

/**
 * Does the hard part of measureChildren: figuring out the MeasureSpec to
 * pass to a particular child. This method figures out the right MeasureSpec
 * for one dimension (height or width) of one child view.
 *
 * The goal is to combine information from our MeasureSpec with the
 * LayoutParams of the child to get the best possible results. For example,
 * if the this view knows its size (because its MeasureSpec has a mode of
 * EXACTLY), and the child has indicated in its LayoutParams that it wants
 * to be the same size as the parent, the parent should ask the child to
 * layout given an exact size.
 *
 * @param spec The requirements for this view
 * @param padding The padding of this view for the current dimension and
 *        margins, if applicable
 * @param childDimension How big the child wants to be in the current
 *        dimension
 * @return a MeasureSpec integer for the child
 */
public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
    int specMode = MeasureSpec.getMode(spec);
    int specSize = MeasureSpec.getSize(spec);

    int size = Math.max(0, specSize - padding);

    int resultSize = 0;
    int resultMode = 0;

    switch (specMode) {
    // Parent has imposed an exact size on us
    case MeasureSpec.EXACTLY:
        if (childDimension >= 0) {
            resultSize = childDimension;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.MATCH_PARENT) {
            // Child wants to be our size. So be it.
            resultSize = size;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.WRAP_CONTENT) {
            // Child wants to determine its own size. It can't be
            // bigger than us.
            resultSize = size;
            resultMode = MeasureSpec.AT_MOST;
        }
        break;

    // Parent has imposed a maximum size on us
    case MeasureSpec.AT_MOST:
        if (childDimension >= 0) {
            // Child wants a specific size... so be it
            resultSize = childDimension;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.MATCH_PARENT) {
            // Child wants to be our size, but our size is not fixed.
            // Constrain child to not be bigger than us.
            resultSize = size;
            resultMode = MeasureSpec.AT_MOST;
        } else if (childDimension == LayoutParams.WRAP_CONTENT) {
            // Child wants to determine its own size. It can't be
            // bigger than us.
            resultSize = size;
            resultMode = MeasureSpec.AT_MOST;
        }
        break;

    // Parent asked to see how big we want to be
    case MeasureSpec.UNSPECIFIED:
        if (childDimension >= 0) {
            // Child wants a specific size... let him have it
            resultSize = childDimension;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.MATCH_PARENT) {
            // Child wants to be our size... find out how big it should
            // be
            resultSize = 0;
            resultMode = MeasureSpec.UNSPECIFIED;
        } else if (childDimension == LayoutParams.WRAP_CONTENT) {
            // Child wants to determine its own size.... find out how
            // big it should be
            resultSize = 0;
            resultMode = MeasureSpec.UNSPECIFIED;
        }
        break;
    }
    return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}

它的原型是:

public static int getChildMeasureSpec(int spec, int padding, int childDimension)

第一個參數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。

代碼上面列出了,根據其規則,能夠獲得下面的表格:


getChildMeasureSpec邏輯

另外一個測量childView的方法將childView的margins和其它childView已佔用當前ViewGroup的空間也考慮進去了:

protected void measureChildWithMargins(View child,
        int parentWidthMeasureSpec, int widthUsed,
        int parentHeightMeasureSpec, int heightUsed) {
    final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

    final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
            mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
                    + widthUsed, lp.width);
    final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
            mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin
                    + heightUsed, lp.height);

    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}

以上就是measure和onMeasure方法所接收的measureSpec參數的產生方式。在自定義ViewGroup時,須要在onMeasure中完成對自身childViews的測量才能夠正確獲得自身的大小。

在測量階段完成後,每一個View的mMeasuredWidth、mMeasuredHeight被肯定下來了。能夠經過getMeasuredHeight等方法獲取測量結果數據。

9.5 View的佈局

ViewTree佈局操做也是自頂向下的遍歷操做,它是ViewTree中全部ViewGroup依次針對其childViews的位置的放置,非ViewGroup的View類只須要實現測量和繪製。相似measure和onMeasure方法的關係,layout方法執行了一些統一操做,全部View子類不該該重寫它,而父佈局執行childView的layout方法完成對它的放置。
layout方法內部調用了onLayout方法,包含childView的ViewGroup子類須要重寫此方法完成對全部childViews的放置。

ViewGroup類是佈局類的基類,它稍微修改了layout方法,加入了對layout調用時機的控制。而後將onLayout方法轉爲抽象方法——強制子類去實現。
像FrameLayout、LinearLayout等這些框架提供的佈局類,它們有各自的佈局特性,總言之,每一個ViewGroup子類的onLayout方法的實現可能有很大的差距。measure以後生成的View的測量寬高是在ViewGroup放置childView時用到的核心數據。

佈局階段完成後,全部View的left,top,right,bottom被肯定下來了。因此必須在layout階段完成以後,才能夠調用getHeight、getWidth得到View實際的寬高。

9.6 View的繪製

View對象的繪製是經過調用其draw方法:

public void draw(Canvas canvas);

相似measure和onMeasure的關係,ViewTree在繪製階段,會傳遞一個Canvas對象,而後調用每一個View的draw方法。
draw方法自己作了一些統一操做,它內部調用了onDraw方法,前面已經接觸了onDraw方法。全部View子類在onDraw方法中完成自身顯示內容的繪製。
View子類幾乎都有它的繪製邏輯。而ViewGroup根據須要能夠繪製一些佈局的裝飾內容。

9.7 PieChart的測量和佈局

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

首先,每一個View子類須要實現onMeasure方法來提供自身大小。通常地,若是本身的View類不須要對它的大小計算作額外的控制,那麼只須要重寫onSizeChanged(),這時View的大小的肯定邏輯是基類View默認的行爲,這時依然能夠對它指定準確的大小或MATCH_PARENT和WRAP_CONTENT。

PieChart要顯示的內容包括標籤和圓,以及指示點。這裏只有標籤和圓須要平分繪製空間,而
指示點自己是繪製在圓內的,
標籤和指示點的連線也是由標籤和圓的相對位置決定的。
能夠回顧案例介紹中的示例圖片,標籤的顯示是在圓的左邊或右邊。
爲了在最終顯示時,讓圓的直徑很多於標籤的寬度,這裏須要重寫下面2個方法:

@Override
protected int getSuggestedMinimumWidth() {
    return (int) mTextWidth * 2;
}

@Override
protected int getSuggestedMinimumHeight() {
    return (int) mTextWidth;
}

這兩個方法表達了PieChart控件在寬高方面的最低要求。
相應地,重寫onMeasure方法完成顯示要求的大小的計算:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
  int minw = getPaddingLeft() + getPaddingRight() + getSuggestedMinimumWidth();
  int w = getDefaultSize(minw, widthMeasureSpec);

  int minh = (w - (int) mTextWidth) + getPaddingBottom() + getPaddingTop();
  int h = getDefaultSize(minh, heightMeasureSpec);

  setMeasuredDimension(w, h);
}

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

public static int getDefaultSize(int size, int measureSpec);
public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState);

做爲優化,因爲PieChart類沒有複雜的佈局邏輯。因此PieChart類沒有在onLayout中作任何邏輯,而是重寫onSizeChanged方法在自身大小發生變化時從新計算並放置用來繪製圓和指示圖形的PieView和PointerView兩個childView對象。

在方法onSizeChanged中,PieChart根據自身大小完成了全部要顯示內容的大小計算和佈局:

public class PieChart extends ViewGroup {
  protected void onSizeChanged(int w, int h, int oldw, int oldh) {
      super.onSizeChanged(w, h, oldw, oldh);
      // 計算padding
      float xpad = (float) (getPaddingLeft() + getPaddingRight());
      float ypad = (float) (getPaddingTop() + getPaddingBottom());

      // 水平寬度上padding值加上標籤文本佔用的寬度
      if (mShowText) xpad += mTextWidth;

      float ww = (float) w - xpad;
      float hh = (float) h - ypad;

      // 由於餅狀圖是圓,因此直徑應該是剩餘矩形空間的寬高中較小的值
      float diameter = Math.min(ww, hh);
      mPieBounds = new RectF(0.0f, 0.0f, diameter, diameter);
      mPieBounds.offsetTo(getPaddingLeft(), getPaddingTop());

      // 計算當前項指示點的Y
      mPointerY = mTextY - (mTextHeight / 2.0f);
      float pointerOffset = mPieBounds.centerY() - mPointerY;

      // 標籤能夠在左邊或者右邊,這裏根據文本位置和指示圓點在圓心的上下高度,
      // 將它放在合適的角度:45,135,225,315裏面選一個。
      if (mTextPos == TEXTPOS_LEFT) {
          mTextPaint.setTextAlign(Paint.Align.RIGHT);
          if (mShowText) mPieBounds.offset(mTextWidth, 0.0f);
          mTextX = mPieBounds.left;

          if (pointerOffset < 0) {
              pointerOffset = -pointerOffset;
              mCurrentItemAngle = 135;
          } else {
              mCurrentItemAngle = 225;
          }
          mPointerX = mPieBounds.centerX() - pointerOffset;
      } else {
          mTextPaint.setTextAlign(Paint.Align.LEFT);
          mTextX = mPieBounds.right;

          if (pointerOffset < 0) {
              pointerOffset = -pointerOffset;
              mCurrentItemAngle = 45;
          } else {
              mCurrentItemAngle = 315;
          }
          mPointerX = mPieBounds.centerX() + pointerOffset;
      }

      // 佈局PieView和PointerView.它們用來畫圓和指示點。
      mPieView.layout((int) mPieBounds.left,
              (int) mPieBounds.top,
              (int) mPieBounds.right,
              (int) mPieBounds.bottom);
      mPieView.setPivot(mPieBounds.width() / 2, mPieBounds.height() / 2);

      mPointerView.layout(0, 0, w, h);
      onDataChanged();
  }
}

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

10. PieChart的繪製

完成畫筆的建立和設置,自身大小的測量和各部分佈局以後,就是自定義View最主要的工做繪製了。

PieChart做爲佈局類,它本身onDraw方法中繪製了標籤。自身添加一個PieView用來繪製圓,PointerView用來繪製指示點和指示點到標籤文本的線。

這樣作的緣由是,圓須要轉動因此爲了能夠獨立地開啓硬件加速,繪製圓的工做放在了單獨的類PieView中。標籤和圓是不會重合的,因此標籤能夠在PieChart自身中繪製。最後,爲了讓指示點和線段繪製在圓的上面,再使用PointerView來完成繪製。

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


PieChart的圖形組成

各部分分別在onDraw方法中完成繪製。前面介紹了使用Canvas.drawArc繪製圓的方式。
標籤、線段、指示點分別使用Canvas的drawText、drawLine和drawCircle進行繪製,具體代碼很簡單這裏不列出了。

11. 處理滑動轉動

如今可使用PieChart調用其addItem方法添加幾個要展現的數據,運行程序就能夠看到示例中的效果圖了。
接下來響應用戶交互:實現滑動手指轉動圓的效果。

相似其它的軟件平臺的UI框架那樣,Android支持輸入事件這樣的模型。用戶操做最後被轉變爲不一樣的事件,它們觸發各類回調方法。而後咱們能夠重寫這些回調方法來響應用戶。

View類中提供了對各類不一樣的交互事件的回調方法:

  • onScrollChanged:View水平或垂直方向上滾動自身內容後發生。
  • onDragEvent:拖拽事件。
  • onTouchEvent:觸摸事件。
  • onInterceptTouchEvent、onGenericMotionEvent...

根本上看,屏幕上的手勢操做幾乎都是遵循onTouchEvent的事件流程的。
本身去重寫onTouchEvent方法完成對滑動和flywheel等顯示世界中的動做的監聽處理是能夠的,但無疑是很繁瑣的——須要考慮多少像素的移動算是滑動,多久的觸摸算是長按,多快的速度會引發flywheel等。Android提供好了一些輔助類來簡化這些通用的交互操做的監聽。

GestureDetector類將原始的觸摸事件轉變爲不一樣的手勢操做。
在使用時,實例化一個GestureDetector對象,而後在onTouchEvent中讓它處理MotionEvent:

// 在PieChart類中
@Override
public boolean onTouchEvent(MotionEvent event) {
    boolean result = mDetector.onTouchEvent(event);
    if (!result) {
        if (event.getAction() == MotionEvent.ACTION_UP) {
            stopScrolling();
            result = true;
        }
    }
    return result;
}

GestureDetector.onTouchEvent返回值表示此事件是否被處理,若是沒有則能夠選擇繼續處理原始的MotionEvent事件。

而後經過提供GestureDetector.OnGestureListener回調對象給GestureDetector對象來監聽它支持的手勢事件。

// 在PieChart類中
mDetector = new GestureDetector(PieChart.this.getContext(), new GestureListener());
...

private class GestureListener extends GestureDetector.SimpleOnGestureListener {
    @Override
    public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
        // 用戶滑動手指時當即轉動圓
        float scrollTheta = vectorToScalarScroll(distanceX, distanceY,
                e2.getX() - mPieBounds.centerX(),
                e2.getY() - mPieBounds.centerY());
        setPieRotation(getPieRotation() - (int) scrollTheta / FLING_VELOCITY_DOWNSCALE);
        return true;
    }

    @Override
    public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
        ...
        return true;
    }

    @Override
    public boolean onDown(MotionEvent e) {
        // 用戶開始觸摸屏幕,開啓硬件加速
        mPieView.accelerate();
        if (isAnimationRunning()) {
            stopScrolling();
        }
        return true;
    }
}

在GestureListener.onScroll方法中,咱們對滑動轉動進行處理。
根據需求,手指滑動後造成一個向量,考慮此向量和圓心到它的垂直線段:

轉動方向判斷

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的另外一邊,順時針。

vectorToScalarScroll

代碼實現:

/**
     * 把滑動造成的向量轉換爲圓轉動的方向和角度大小。
     *
     * @param dx 當前滑動向量的x份量.
     * @param dy 當前滑動向量的y份量.
     * @param x  相對於pie圓心的x座標.
     * @param y  相對於pie圓心的y座標.
     * @return 表示轉動角度的標量。
     */
private static float vectorToScalarScroll(float dx, float dy, float x, float y) {
    // 滑動造成的向量(dx,dy)的大小
    float length = (float) Math.sqrt(dx * dx + dy * dy);

    // (crossX,crossY)是(x,y)的垂直向量
    float crossX = -y;
    float crossY = x;

    // 點乘,獲得轉動方向。
    float dot = (crossX * dx + crossY * dy);
    float sign = Math.signum(dot);

    return length * sign;
}

圓是PieView繪製的,轉動圓的操做能夠經過執行View.setRotation來轉動PieView自己完成。(這是API 11中View類引入的方法,以前的版本能夠經過canvas.rotate完成,但這樣的話操做就須要在onDraw中執行,爲了通知系統執行某個View的onDraw方法,執行View.invalidate便可)。

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

public class PieChart extends ViewGroup {
  private int mPieRotation;
  private PieView mPieView;
  ...

  public void setPieRotation(int rotation) {
      rotation = (rotation % 360 + 360) % 360;
      mPieRotation = rotation;
      // 角度順時針轉動時度數增長
      mPieView.rotateTo(rotation);

      calcCurrentItem();
  }
  ...
}

值得一提的是,PieChart計算角度的時候會將角度轉換爲0~360度之間的值,這樣是由於PieView繪製的各個扇形的角度分別佔據了0~360度之間的各段。在繪製效果不變的狀況下,這樣(角度不爲負數,不會大於360)會使得角度的處理簡單不少。

在要顯示的扇形發生變化或者轉動以後,指示點對應的當前扇形會發生變化,這時須要從新計算當前項:

// 在PieChart類中
private void calcCurrentItem() {
    // 順時針轉動圓圈,那麼指示點至關於各個餅狀圖逆時針了mPieRotation
    int pointerAngle = (mCurrentItemAngle + 360 - mPieRotation) % 360;
    for (int i = 0; i < mData.size(); ++i) {
        Item it = mData.get(i);
        if (it.mStartAngle <= pointerAngle && pointerAngle <= it.mEndAngle) {
            if (i != mCurrentItem) {
                setCurrentItem(i, false);
            }
            break;
        }
    }
}

由於轉動後的角度mPieRotation是0~360之間,mCurrentItemAngle是指示點對應的角度:在繪製它的時候已經計算好了,只能是45,135,225,315四個角度之一。
上面計算轉動後指示點落在哪一個扇形的思路是:
假設全部扇形仍是依次從0度開始的——也就是未轉動的情形,讓指示點自己的角度減去mPieRotation度,獲得的角度至關於「未轉動扇形時指示點的角度」。而後計算此角度pointerAngle處在哪一個扇形的角度範圍。

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

// 在PieChart類中:
private void setCurrentItem(int currentItem, boolean scrollIntoView) {
    mCurrentItem = currentItem;
    if (mCurrentItemChangedListener != null) {
        mCurrentItemChangedListener.OnCurrentItemChanged(this, currentItem);
    }
    if (scrollIntoView) {
        centerOnCurrentItem();
    }
    invalidate();
}

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

12. 快速轉動後的flywheel效果

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

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

  1. flyWheel效果明顯是一個隨時間遞減的過程,那麼須要提供一個邏輯來計算停下了須要的時間,以及隨時間減小時轉動的角度。
  2. 提供效果持續時刷新界面的方式。

12.1 幾種實現動畫方式

經過上面對flyWheel效果的描述,它其實就是一個PieView上進行的一個「動畫」。
動畫是關於時間和值的一個概念,就是在一段時間,或者是時間不作限制時,隨着時間的推動,對應的某個值不斷髮生變化。

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

爲了實現這個目標有下面幾個方法:

  1. 本身實現定時旋轉PieView:這種方式最大的問題是時間間隔很差肯定,由於不一樣設備性能不一樣,最終界面刷新頻率不同。沒法給出一個體驗良好的數值。若是是API 11以前,旋轉只能經過canvas.rotate就須要定時去執行pieView.invalidate讓它執行onDraw。API 11以上就執行pieView.setRotation。
  2. onDraw中根據條件繼續調用invalidate:這個不是定時去執行onDraw,而是每次onDraw以後若是發現還須要執行動畫就繼續觸發下一次onDraw。這樣在結束繪製動畫前,onDraw的執行是由設備自己容許的刷新頻率決定的,時間間隔是匹配設備自己的繪製能力的,能夠取得很好的動畫效果。
  3. 使用屬性動畫,在API 11以後能夠經過新增的屬性動畫來實現動畫效果。屬性動畫自己負責根據每一幀的回調,無需咱們本身去考慮刷新頻率的問題。

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

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

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

Scroller是一個持有位置數據,並提供操做改變這些數據的類,具體的執行頻率是調用者的事情,可使用handler、動畫等方式實現週期性來不斷調用它的computeScrollOffset來得到更新後的位置。經過Scroller.isFinished來判斷滑動動畫是否中止。

12.2 具體實現flyWheel的流程

12.2.1 開始fling

在前面的GestureListener.onFling中收集當時的轉動速度。
由於Scroller是同時處理X、Y上的滑動的,這裏角速度只須要對應X或Y中一個就能夠了。
這裏選擇讓角速度做爲Scroller.fling時的Y軸的加速度,角度就是起始Y值。

// 在GestureListener類中
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
    // Set up the Scroller for a fling
    float scrollTheta = vectorToScalarScroll(
            velocityX,
            velocityY,
            e2.getX() - mPieBounds.centerX(),
            e2.getY() - mPieBounds.centerY());
    mScroller.fling(
            0,
            (int) getPieRotation(),
            0,
            (int) scrollTheta / FLING_VELOCITY_DOWNSCALE,
            0,
            0,
            Integer.MIN_VALUE,
            Integer.MAX_VALUE);
    // Start the animator and tell it to animate for the expected duration of the fling.
    if (Build.VERSION.SDK_INT >= 11) {
        mScrollAnimator.setDuration(mScroller.getDuration());
        mScrollAnimator.start();
    } else {
        mPieView.invalidate();
    }
    return true;
}

mScroller.fling開啓了滑動。
同時,mScrollAnimator也被啓動,它是一個值動畫,這裏並不使用它修改某個View的屬性,而是依靠它來得到定時刷新的回調。在動畫的更新回調方法中執行旋轉操做。

mScrollAnimator = ValueAnimator.ofFloat(0, 1);
mScrollAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    public void onAnimationUpdate(ValueAnimator valueAnimator) {
        tickScrollAnimation();
    }
});

12.2.2 刷新界面

每次能夠繪製界面的時候,執行tickScrollAnimation執行滑動動畫:

private void tickScrollAnimation() {
  if (!mScroller.isFinished()) {
      mScroller.computeScrollOffset();
      setPieRotation(mScroller.getCurrY());
  } else {
      if (Build.VERSION.SDK_INT >= 11) {
          mScrollAnimator.cancel();
      }
      onScrollFinished();
  }
}

方法中根據mScroller計算了新的Y——也就是角速度,而後改變圓的旋轉角度。
一直到mScroller.isFinished()位true的時候,轉動動畫就結束了。

13. 自動居中當前扇形

根據需求,用戶手指離開屏幕,滑動結束後,應該能夠繼續執行轉動動畫,讓指示點落在所在的當前扇形的角度範圍中間。也就是當前扇形的(startAngle + endAngle) / 2 的值等於指示點的角度值。
動畫的效果這裏選擇使用ObjectAnimator完成,它是上面轉動動畫使用的ValueAnimator的子類。
ObjectAnimator能夠針對目標對象的一些屬性執行動畫,隨着時間行進,屬性值被實際改變。
這裏用來對PieChart類的PieRotation屬性進行動畫,使得滑動結束後繼續轉動圓讓指示點居中在當前扇形。

動畫的方案肯定了,另外一個問題就是計算居中須要轉動到的目標角度:

// 在PieChart類中
private void init () {
  ...
  mAutoCenterAnimator = ObjectAnimator.ofInt(PieChart.this, "PieRotation", 0);
  ...
}

private void centerOnCurrentItem() {
    Item current = mData.get(getCurrentItem());
    int originPieMiddleAngle = current.mStartAngle + (current.mEndAngle - current.mStartAngle) / 2;
    int relativePointerAngle = mCurrentItemAngle - mPieRotation;
    if (relativePointerAngle < 0) relativePointerAngle += 360;

    if (originPieMiddleAngle == relativePointerAngle) return;

    int newRotation = 0;
    int delta = Math.abs(originPieMiddleAngle - relativePointerAngle);
    if (originPieMiddleAngle > relativePointerAngle) {
        newRotation = mPieRotation - delta;
    } else {
        newRotation = mPieRotation + delta;
    }

    if (Build.VERSION.SDK_INT >= 11) {
        // Fancy animated version
        mAutoCenterAnimator.setIntValues(newRotation);
        mAutoCenterAnimator.setDuration(AUTOCENTER_ANIM_DURATION).start();
    } else {
        // Dull non-animated version
        mPieView.rotateTo(newRotation);
    }
}

方法計算居中後圓的轉動角度時採起了和計算當前扇形相似的思路。
就是假設轉動圓的效果是保持圓不動,而後指示點的角度減去mPieRotation便可。
上面relativePointerAngle是居中前當前PieChart轉動角度爲mPieRotation時,讓mPieRotation爲0時指示點和各個扇形的相對位置。
這樣,計算relativePointerAngle到目標扇形的中間角度originPieMiddleAngle的差值delta,以後給mPieRotation補上這個差就能夠獲得居中時最終的轉動角度。

14. 總結

以上長篇大論,以官方的PieChart案例來分析,一步步完成了自定義控件的設計和開發,中間對涉及到的API進行了介紹。
自定義控件的實踐是沒有盡頭的,給你畫布和畫筆,惟一的約束只有你的想象力。
更多的API的學習,如屬性動畫,事件分發,能夠參考sdk文檔,查閱android.view包下提供的各類類型。對框架類進行學習是很不錯的開始。

參考文檔

資料

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

Android sdk文檔:

  • 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編輯器編寫)

相關文章
相關標籤/搜索