android自定义控件一站式入门

TODO: 待整理

自定义控件

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和对应逻辑是集中管理的。便于复用。

案例:PieChart控件

自定义控件的几种方式中,直接继承自View类的方式包含自定义View用到的完整的开发技巧。接下来将以官方文档Develop > Training > Best Practices for User Interface > Creating Custom Views中讲述的PieChart自定义控件为例,了解下自定义View的开发流程。

功能目标:

将要实现的PieChart控件如下图:

具有以下主要功能目标:

  • PieChart需要展示一个由一或多个扇形组成的圆,一个在圆的固定位置的指示圆点,一个在圆的左侧或右侧固定位置的标签。
  • 圆的每个扇形表示一个显示项(Item)。可以添加任意多个Item,每个Item有它的color、value、label来确定扇形的显示。所有扇形根据其添加顺序顺时针从0°开始组成整个圆。如上面的是包含红、绿、蓝,值分别为1、2、3的三个Item组成的圆。
  • 手指滑动时转动饼状图,滑动方向与圆心到滑动方向的直线决定了转动方向。例如手指处在圆心下方时向左滑动时圆顺时针转动。
  • 圆转动时,指示圆点落在那个扇形的区域,扇形对应的Item就是当前Item。它对应的label内容被显示。
  • 手指快速划过后(fling——具有flywheel效果),饼状图以动画的方式慢慢停止而不是立即停止转动。
  • 滑动(包括fling)结束后,居中当前项——指示点在当前项对应扇形角度中心。

以上是要实现的自定义控件PieChart需要满足的业务要求。下面就一步步设计和完成PieChart控件。

基础工作

在开始实现控件的功能目标之前,需要做一些基础工作,让自己的控件可以运行调试。之后再逐步完成显示和交互功能。

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工具来直观查看:

对应的布局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。

代码上面列出了,根据其规则,可以得到下面的表格:

另一个测量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的图形组成:

各部分分别在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的另一边,顺时针。

代码实现:

    /**
     * 把滑动形成的向量转换为圆转动的方向和角度大小。
     *
     * @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包下提供的各种类型。对框架类进行学习是很不错的开始。

参考文档

资料

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

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏河湾欢儿的专栏

标签类型

标签类型 块 1.独占一行 2.支持所有样式 3.不设置宽度的时候,宽度撑满整个一行 内嵌 1.可以在一行显示 2.不支持宽高,不支持上下的mar...

1093
来自专栏everhad

android自定义控件一站式入门

Android系统提供了一系列UI相关的类来帮助我们构造app的界面,以及完成交互的处理。 一般的,所有可以在窗口中被展示的UI对象类型,最终都是继承自Vie...

3060
来自专栏木子昭的博客

<自动化办公> Python 操控 Word

虽然Word不好用, 但还必须得用它, python-docx是专门用于编辑Word文档的一个工具库, 它有两大用途, 自动化生成word文档 and 自动化...

2.8K8
来自专栏GIS讲堂

Openlayers4中图片填充的实现

2983
来自专栏web

慕课网javascript 进阶篇 第九章 编程练习

1334
来自专栏欧阳大哥的轮子

Android中的各种Drawable类详解

图形图像的绘制需要在画布上进行操作和处理,但是绘制需要了解很多细节以及可能要进行一些复杂的处理,这样就会增加学习和使用的成本,因此系统提供了一个被称之为Draw...

1672
来自专栏向治洪

Android动画深入分析

动画分类 Android动画可以分3种:View动画,帧动画和属性动画;属性动画为API11的新特性,在低版本是无法直接使用属性动画的,但可以用nineoldA...

22610
来自专栏Keegan小钢

Android样式的开发:Property Animation篇

前篇文章说过,Android框架还提供了两种动画体系,前一篇已经总结了视图动画(View Animation)的用法,本篇则接着总结另一种动画体系——属性动画(...

953
来自专栏我就是马云飞

View的绘制流程之MeasureSpec

目的 我在一个多月之前就说我准备开始梳理基础的事,好吧,我承认这一个月没我怎么梳理。或者梳理的不多,当我梳理到view的时候,发现需要分成绘制流程以及事件分发进...

2059
来自专栏何俊林

Android View框架总结(五)View布局流程之Layout

View树的Layout流程 View的Layout时序图 View布局流程之Layout ViewGroup的Layout过程 setFrame方法 Vi...

2045

扫码关注云+社区

领取腾讯云代金券