自定义View:1、定制自己的饼形图

Android本身为我们提供了View,但很多情况下,仍然无法满足我们自己的需求,那么这个时候就需要自己定制View。自定义View的办法有很多,我们从最基础的开始。 device-2014-12-08-145922 上图就是我们要实现的效果,具体的我们依次来列举一下:

  • 首先要画出上面的界面
    • 饼形图,饼的面积代表其进度。
    • 在饼形图中间可以设定是否显示数字进度。
  • 要可以设置饼形图的各种参数,包括颜色,大小等等
  • 要能够通过UI中其他的控件比如SeekBar来设置饼形图的参数。
  • 要有回调函数能够使得其他的控件接收到饼形图中进度的变化。
  • 当手指按在饼形图上时,要能够不断自增进度。

好了,上面就是我们规划的需求,那么我们来依次编写代码,首先来分析我们需要做的工作有哪些?

  • 资源文件
    • attrs.xml中准备饼形图可设置的参数,这样我们可以直接在Layout中设置饼形图的各个参数
  • 程序代码
    • 覆写onMeasure方法来设置界面的大小
    • 覆写onDraw()方法来绘图
    • 设计各种回调函数(Listener)
    • 覆写onTouchEvent()设计触摸事件

资源文件

首先,我们需要在attrs.xml中为饼形图设计各种参数,我们将饼形图名称定义为ProgressPie,那我们为其设计的属性为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="ProgressPie">
<attr name="max" format="integer"/>
<attr name="progress" format="integer"/>
<attr name="showText" format="boolean"/>
<attr name="textSize" format="dimension"/>
<attr name="textColor" format="color"/>
<attr name="textPosition" format="enum">
<enum name="left" value="0"/>
<enum name="middle" value="1"/>
<enum name="right" value="2"/>
</attr>
<attr name="color" format="color"/>
<attr name="showBorder" format="boolean"/>
<attr name="borderColor" format="color"/>
</declare-styleable>
</resources>

我们为ProgressPie声明了一些属性,有一点需要注意的是属性的各种类型,比如整形,尺寸,布尔型,颜色,枚举类型(其中元素要在对应的View中定义)等等。 那怎么使用呢?activity.xml的界面如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout\_width="match\_parent"
android:layout\_height="match\_parent"
android:paddingBottom="@dimen/activity\_vertical\_margin"
android:paddingLeft="@dimen/activity\_horizontal\_margin"
android:paddingRight="@dimen/activity\_horizontal\_margin"
android:paddingTop="@dimen/activity\_vertical\_margin">

<CheckBox
android:layout\_width="wrap\_content"
android:layout\_height="wrap\_content"
android:text="Visiablity"
android:id="@+id/visiablityCheckBox"
android:checked="true" />
<SeekBar
android:layout\_width="match\_parent"
android:layout\_height="wrap\_content"
android:id="@+id/seekbar"
/>
<me.happyhls.androiddemo.view.ProgressPie
xmlns:progresspie="http://schemas.android.com/apk/res-auto"
android:layout\_width="match\_parent"
android:layout\_height="match\_parent"
android:layout\_marginTop="@dimen/activity\_vertical_margin"
progresspie:max="100"
progresspie:progress="90"
progresspie:textColor="#0000FF"
progresspie:showText="true"
progresspie:textSize="50sp"
progresspie:textPosition="right"
progresspie:color="#FF0000"
android:id="@+id/progressspie"
/>
</LinearLayout>

重点我们来看一下我们自己定义的me.happyhls.androiddemo.view.ProgressPie: 首先我们需要申明命名空间,在AndroidStudio中的推荐写法为,将其设为res-auto,即:

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

这样就可以自动找到我们的属性设置,需要注意的是,上面是当前的ADT或者AS中的推荐写法。 以前,我们一般是这样写的:

xmlns:progress=”http://schemas.android.com/apk/res/me.happyhls.view.ProgressPie

但现在最好不要这样写,如果按照以前那样写上自己的包名的话,可以能出现错误,尤其是我们要作为Library提供的时候。参考:http://stackoverflow.com/questions/10398416/using-activities-from-library-projects 其他的地方则是我们比较经常使用的,不需要多说,主要来看看代码里面的内容:

ProgressPie.java

一:初始化->构造函数

构造函数是我们首先需要写好的,默认的View其中有3个构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
public View(Context context) {
}

public View(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public View(Context context, AttributeSet attrs, int defStyleAttr) {
this(context);
TypedArray a = context.obtainStyledAttributes(attrs, com.android.internal.R.styleable.View,defStyleAttr, 0);
...
}

这里面的3个构造函数都有其不同的应用场景,为了使得我们的自定义View更加规范易用,我们同样需要书写类似的3个构造函数,在编写之前,我们需要首先搞明白,这3个构造函数分别应用在什么场景里面呢?自己思考是想不明白的,我们先去看看Button的源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Button extends TextView {
public Button(Context context) {
this(context, null);
}

public Button(Context context, AttributeSet attrs) {
this(context, attrs, com.android.internal.R.attr.buttonStyle);
}

public Button(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}

@Override
public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
super.onInitializeAccessibilityEvent(event);
event.setClassName(Button.class.getName());
}

@Override
public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(info);
info.setClassName(Button.class.getName());
}
}

从上我们可以看出:

  • 所有的构造函数最终都是通过public Button(Context context, AttributeSet attrs, int defStyle){}初始化Button。
  • 如果通过代码创建Button,那么会调用构造器public Button(Context context) {}(来源:View.java源代码注释),但本质上仍然通过public Button(Context context, AttributeSet attrs, int defStyle) {}实例化,但其中传入的参数attrs为null,defStyle为com.android.internal.R.attr.buttonStyle。
  • 如果通过XML创建Button,那么此时会调用构造器public Button(Context context, AttributeSet attrs) {},但本质上仍然通过public Button(Context context, AttributeSet attrs, int defStyle) {}实例化,但其中传入的参数attrs为attrs,defStyle为com.android.internal.R.attr.buttonStyle。
  • public Button(Context context, AttributeSet attrs, int defStyle) {}什么时候调用?不太清楚,但Button所有的初始化最终都是通过该构造器,并调用父类TextView对应的构造器实现。

综上所示,要搞明白,还需要好好看看TextView对应的构造器实现。 TextView的源代码如下,来分析一下public TextView(Context context, AttributeSet attrs, int defStyle)的源代码。 {https://github.com/happyhls/platform\_frameworks\_base/blob/master/core/java/android/widget/TextView.java} 1、调用父类的构造函数

1
super(context, attrs, defStyle);

2、为了简化逻辑设计,从系统主题中获取默认的主题,使用设置的attrs及com.android.internal.R.styleable.TextAppearance设置默认属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
final Resources.Theme theme = context.getTheme();        
/\*
\* Look the appearance up without checking first if it exists because
\* almost every TextView has one and it greatly simplifies the logic
\* to be able to parse the appearance first and then let specific tags
\* for this View override it.
*/
TypedArray a = theme.obtainStyledAttributes(
attrs, com.android.internal.R.styleable.TextViewAppearance, defStyle, 0);
TypedArray appearance = null;
int ap = a.getResourceId(
com.android.internal.R.styleable.TextViewAppearance_textAppearance, -1);
a.recycle();
if (ap != -1) {
appearance = theme.obtainStyledAttributes(
ap, com.android.internal.R.styleable.TextAppearance);
}

3、使用attrs,并从主题中获取com.android.internal.R.styleable.TextView的属性设置:

1
2
3
4
5
6
7
8
a = theme.obtainStyledAttributes(
attrs, com.android.internal.R.styleable.TextView, defStyle, 0);

int n = a.getIndexCount();
for (int i = 0; i < n; i++) {
int attr = a.getIndex(i);
...
}

4、使用attrs,获取View中定义的focusable,clickable属性值:

a = context.obtainStyledAttributes(attrs, com.android.internal.R.styleable.View, defStyle, 0);

可以看出,在TextView中根据用户在XML中设定的属性和系统的默认属性,依次设置TextView的各个属性值。 有一点我们需要想到的是,TextView的设计比较复杂,是因为在Android当中,TextView是大量控件的父类,换句话说,很多的控件都是基于TextView来实现的,比如说:

Known Direct Subclasses

Button, CheckedTextView, Chronometer, DigitalClock, EditText, RowHeaderView, TextClock

Known Indirect Subclasses

AutoCompleteTextView, CheckBox, CompoundButton, ExtractEditText, MultiAutoCompleteTextView, RadioButton, SearchEditText, Switch, SwitchCompat,ToggleButton

那我们的代码改怎么写呢?如果不会,那么就模仿,所以,我们模仿TextView,同样声明3个构造函数,在含有3个参数的构造函数中具体的初试话所有需要初始化的属性,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public ProgressPie(Context context) {
this(context, null);
}

public ProgressPie(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public ProgressPie(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);

TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ProgressPie, 0, 0);
try {
mMax = typedArray.getInteger(R.styleable.ProgressPie_max, 100);
mProgress = typedArray.getInteger(R.styleable.ProgressPie_progress, 0);
if (mProgress > mMax) {
mProgress = mMax;
} else if (mProgress < 0) {
mProgress = 0;
}
mShowText = typedArray.getBoolean(R.styleable.ProgressPie_showText, false);
if (mShowText) {
mTextSize = typedArray.getDimensionPixelSize(R.styleable.ProgressPie_textSize, 20);
mTextColor = typedArray.getColor(R.styleable.ProgressPie_textColor, Color.BLACK);
mTextPosition = typedArray.getInteger(R.styleable.ProgressPie\_textPosition, TEXT\_POSITION_MIDDLE);
}
mColor = typedArray.getColor(R.styleable.ProgressPie_color, Color.BLUE);
mShowBorder = typedArray.getBoolean(R.styleable.ProgressPie_showBorder, true);
if (mShowBorder) {
mBorderColor = typedArray.getColor(R.styleable.ProgressPie_borderColor, Color.GRAY);
}
} finally {
typedArray.recycle();
}
init();
}

private void init() {
mTextPaint = new TextPaint(Paint.ANTI\_ALIAS\_FLAG);
mTextPaint.setColor(mTextColor);
mTextPaint.setTextSize(mTextSize);


mPiePaint = new Paint(Paint.ANTI\_ALIAS\_FLAG);
mPiePaint.setColor(mColor);
}

其实整体来说,并不复杂,简单的讲,首先获取XML中的配置的属性,并进行设置,最后初始化了我们所需要的Paint画笔。需要注意的是TypeArray使用完成之后,记得要回收。 直接在XML中定义>style定义>由defStyleAttr和defStyleRes指定的默认值>直接在Theme中指定的值 //参考自http://www.cnblogs.com/angeldevil/p/3479431.html

二:测量->onMeasure

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
Log.i(TAG, "onMeasure");

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

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

int width, height;

if (widthMode == MeasureSpec.EXACTLY) {
width = widthSize;
} else {
CharSequence charSequence = String.valueOf(mMax);
width = getTextWidth(charSequence, mTextPaint) << 1;
}

height = width;
if (width > 0) {
this.width = width;
this.height = height;
rectF.set(0f, 0f, width, height);
}
setMeasuredDimension(height, width);
}

在我们的饼形图当中,我们要设定的饼是一个圆形,那么其大小该怎么确定呢?

  • 最小情况(wrap_content):首先是一个圆形,如果要显示数字的话,那么最小的饼只要能够覆盖数字的空间就可以。同时,因为我们设计了3种数字的显示方式:靠左,靠右,居中,因此我们可以保证圆的半径为数字所占空间的大小,那么就可以保证在所有的情况下,都能够正常显示
  • 最大的情况(match_parent)/XML指定控件大小:其实这两种情况下,我们都可以因为是使用到该饼形图的地方已经为我们指定好了大小:如果是XML指定大小的情况,那不用多说,如果是match_parent,那么这个时候其实大小是由容器剩下的空间决定好了的。因此在这些情况下,我们只要采用给定的尺寸就可以。

首先说明一下MeasureSpec这个类: 该类封装了从Layout parent传递给child的Layout参数。MeasureSpec都包含了width和height属性,由size和mode构成。 对于Mode,总共有3中类型,由MeasureSpec的最高两位来表示(32位):

  • UNSPECIFIED(0b00):没有指定
  • EXACTLY(0b01):Layout parent中已经定义了child元素的具体尺寸,不管child所需要的或者设置的尺寸是多少,其最终都使用父控件所指定的大小。
  • AT_MOST(0b11):子元素可以任意获得所需要的大小。

setMeasuredDimension 我们可以注意到,在onMeasure()方法的最后,我们调用了View中的setMeasuredDimension方法,需要注意的是,该方法是onMeasure中必须调用的,用来保存我们设置的尺寸大小。如果没有调用该方法的话,会抛出异常。 还有,其中我们使用getTextWidth()方法来获取数字所占用的屏幕大小。我暂时知道的有两种思路

  • (int)FloatMath.ceil(Layout.getDesiredWidth(charSequence, textPaint)); //Return how wide a layout must be in order to display the specified text slice with one line per paragraph.

  • mTextPaint.getTextBounds(text, 0, text.length(), mBounds); //Return in bounds (allocated by the caller) the smallest rectangle that encloses all of the characters, with an implied origin at (0,0).
    int textWidth = mBounds.width();

两种办法都可以。

三、画图->onDraw

前面我们已经看完了,一个自定义View如何进行初始化和确定尺寸的,现在则看看到底是如何来画图的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
protected void onDraw(Canvas canvas) {
Log.i(TAG, "onDraw");
super.onDraw(canvas);
mPiePaint.setColor(mColor);
mPiePaint.setStyle(Paint.Style.FILL);
canvas.drawArc(rectF, 180, 360*getProgress()/getMax(), true, mPiePaint );
if (mShowBorder) {
mPiePaint.setColor(mBorderColor);
mPiePaint.setStyle(Paint.Style.STROKE);
canvas.drawArc(rectF, 180, 360*getProgress()/getMax(), true, mPiePaint );
}
if ( mShowText ) {
String text = String.valueOf(getProgress());
mTextPaint.getTextBounds(text, 0, text.length(), mBounds);
int textWidth = mBounds.width();
int textHeight = mBounds.height();
switch (mTextPosition) {
case TEXT\_POSITION\_LEFT:
canvas.drawText(text, width/2 - textWidth, height/2 + textHeight/2, mTextPaint);
break;
case TEXT\_POSITION\_MIDDLE:
canvas.drawText(text, width/2 - textWidth/2, height/2 + textHeight/2, mTextPaint);
break;
case TEXT\_POSITION\_RIGHT:
canvas.drawText(text, width/2, height/2 + textHeight/2, mTextPaint);
break;
}
}
}

onDraw方法是我们必须要实现的一个方法,说简单一点,该方法就是用来画图的,怎么画呢? 在onDraw方法中有一个参数Canvas,Canvas即画板,在该画板上画图,则会直接显示在界面上。我们来看看我们具体是怎么画的图。

  1. 我们首先调用了super.onDraw(canvas):其实分析到这里,我们可以知道,View的onDraw(Canvas canvas)方法中,什么工作也没有做,因此这段代码可以省去。
  2. 在canvas画扇形,需要注意的是,我们同时利用mPiePaint来画扇形和边界,因此此时我们应该先将画笔设置的Style设置为Paint.Style.FILL。
  3. 判断是否需要画边界,则画出边界
  4. 判断是否需要画文字,如果需要,则画出文字。

四、触摸事件->onTouchEvent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mUpdatingThread = new UpdatingThread();
mUpdatingThread.start();;
break;
case MotionEvent.ACTION_UP:
if(mUpdatingThread!=null && mUpdatingThread.isAlive()) {
mUpdatingThread.finish();;
mUpdatingThread.interrupt();;
}
break;

default:
return true;
}
return true;
}

触摸事件,我们只需要实现onTouchEvent就可以,需要注意的是,其中的返回值,如果返回true,那么说明此事件已经被View处理,不需要再分发,如果为false,则说明此事件还需要被其他的View处理。 在View的文档中提到,如果说我们要处理点击事件的话,最好的方法不是覆写onTouchEvent方法,而是覆写performClick()方法,可以获得以下的好处:

  • obeying click sound preferences
  • dispatching OnClickListener calls
  • handling [ACTION_CLICK](http://developer.android.com/reference/android/view/accessibility/AccessibilityNodeInfo.html#ACTION_CLICK) when accessibility features are enabled

我们这里要处理一直按下的状态,当按下的时候,数字每1s加一,当松手的时候停止,因此我们设计的逻辑是,当按下的时候,启动一个线程处理,松手的时候,停止该线程即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private class UpdatingThread extends Thread {
private volatile boolean isRunning = true;

@Override
public void run() {
while (!Thread.interrupted() &&isRunning) {
if (mProgress < mMax) {
ProgressPie.this.post(new Runnable() {
@Override
public void run() {
ProgressPie.this.setProgress(++mProgress);
}
});
}
try {
Thread.sleep(1000);
} catch (Exception e) {
//e.printStackTrace();;
}
}
}

public void finish() {
isRunning = false;
}
}

需要注意的是,为了访问UI线程的空间,我们使用了View的post(Runnable runnable)方法。 好了,各个部分就是这样,具体的详细代码可以参考: https://github.com/happyhls/AndroidDemo/blob/master/app/src/main/java/me/happyhls/androiddemo/view/ProgressPie.java