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

Android本身为我们提供了View,但很多情况下,仍然无法满足我们自己的需求,那么这个时候就需要自己定制View。自定义View的办法有很多,我们从最基础的开始。

device-2014-12-08-145922

上图就是我们要实现的效果,具体的我们依次来列举一下:

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

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

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

资源文件

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

<?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的界面如下:

<?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个构造函数:

    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的源代码:

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、调用父类的构造函数

super(context, attrs, defStyle);

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

        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的属性设置:

        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

Known Indirect Subclasses

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

    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

    @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如何进行初始化和确定尺寸的,现在则看看到底是如何来画图的。

    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

    @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 when accessibility features are enabled

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

    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

 

About: happyhls


发表评论

电子邮件地址不会被公开。 必填项已用*标注