自定义View新手实战-一步步实现精美的钟表界面

效果展示:

这里写图片描述

灵感来源:

这里写图片描述

下面就直接进入正题吧:

1.第一步,创建自定义View继承View,实现构造方法,如下

public WatchBoard(Context context) {
        this(context, null);
    }

    public WatchBoard(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

2.添加一些必要的属性,并且自定义资源文件,书写代码获取属性

一些需要的属性,从实例图可以看出我们需要的可以定制和必须的属性主要有以下几个

 private float mRadius; //外圆半径
    private float mPadding; //边距
    private float mTextSize; //文字大小
    private float mHourPointWidth; //时针宽度
    private float mMinutePointWidth; //分针宽度
    private float mSecondPointWidth; //秒针宽度
    private int mPointRadius; // 指针圆角
    private float mPointEndLength; //指针末尾的长度

    private int mColorLong; //长线的颜色
    private int mColorShort; //短线的颜色
    private int mHourPointColor; //时针的颜色
    private int mMinutePointColor; //分针的颜色
    private int mSecondPointColor; //秒针的颜色

    private Paint mPaint; //画笔

关于各个属性的作用也写一下,以前看别人的自定义View就有的属性根本不知道要用来干啥,难以理解:

这里写图片描述

定义资源文件:在value文件下新建watch_board_attr.xml文件,内容如下

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="WatchBoard">
        <!--表盘的边距-->
        <attr name="wb_padding" format="dimension"/>
        <!--表盘文字大小-->
        <attr name="wb_text_size" format="dimension"/>
        <!--时针的宽度-->
        <attr name="wb_hour_pointer_width" format="dimension"/>
        <!--分针的宽度-->
        <attr name="wb_minute_pointer_width" format="dimension"/>
        <!--秒针的宽度-->
        <attr name="wb_second_pointer_width" format="dimension"/>
        <!--指针圆角值-->
        <attr name="wb_pointer_corner_radius" format="dimension"/>
        <!--指针超过中心点的长度-->
        <attr name="wb_pointer_end_length" format="dimension"/>
        <!--时刻刻度颜色-->
        <attr name="wb_scale_long_color" format="color"/>
        <!--非时刻刻度颜色-->
        <attr name="wb_scale_short_color" format="color"/>
        <!--时针颜色-->
        <attr name="wb_hour_pointer_color" format="color"/>
        <!--分针颜色-->
        <attr name="wb_minute_pointer_color" format="color"/>
        <!--秒针颜色-->
        <attr name="wb_second_pointer_color" format="color"/>
    </declare-styleable>
</resources>

构造方法中获取属性并且设置默认值,添加异常情况的处理(一旦出现异常,使用全部默认值)

public WatchBoard(Context context, AttributeSet attrs) {
        super(context, attrs);
        obtainStyledAttrs(attrs); //获取自定义的属性
   }

    private void obtainStyledAttrs(AttributeSet attrs) {
        TypedArray array = null;
        try {
            array = getContext().obtainStyledAttributes(attrs, R.styleable.WatchBoard);
            mPadding = array.getDimension(R.styleable.WatchBoard_wb_padding, DptoPx(10));
            mTextSize = array.getDimension(R.styleable.WatchBoard_wb_text_size, SptoPx(16));
            mHourPointWidth = array.getDimension(R.styleable.WatchBoard_wb_hour_pointer_width, DptoPx(5));
            mMinutePointWidth = array.getDimension(R.styleable.WatchBoard_wb_minute_pointer_width, DptoPx(3));
            mSecondPointWidth = array.getDimension(R.styleable.WatchBoard_wb_second_pointer_width, DptoPx(2));
            mPointRadius = (int) array.getDimension(R.styleable.WatchBoard_wb_pointer_corner_radius, DptoPx(10));
            mPointEndLength = array.getDimension(R.styleable.WatchBoard_wb_pointer_end_length, DptoPx(10));

            mColorLong = array.getColor(R.styleable.WatchBoard_wb_scale_long_color, Color.argb(225, 0, 0, 0));
            mColorShort = array.getColor(R.styleable.WatchBoard_wb_scale_short_color, Color.argb(125, 0, 0, 0));
            mMinutePointColor = array.getColor(R.styleable.WatchBoard_wb_minute_pointer_color, Color.BLACK);
            mSecondPointColor = array.getColor(R.styleable.WatchBoard_wb_second_pointer_color, Color.RED);
        } catch (Exception e) {
            //一旦出现错误全部使用默认值
            mPadding = DptoPx(10);
            mTextSize = SptoPx(16);
            mHourPointWidth = DptoPx(5);
            mMinutePointWidth = DptoPx(3);
            mSecondPointWidth = DptoPx(2);
            mPointRadius = (int) DptoPx(10);
            mPointEndLength = DptoPx(10);

            mColorLong = Color.argb(225, 0, 0, 0);
            mColorShort = Color.argb(125, 0, 0, 0);
            mMinutePointColor = Color.BLACK;
            mSecondPointColor = Color.RED;
        } finally {
            if (array != null) {
                array.recycle();
            }
        }

    }

其中用到的尺寸转换方法

//Dp转px
    private float DptoPx(int value) {

        return SizeUtil.Dp2Px(getContext(), value);
    }

    //sp转px
    private float SptoPx(int value) {
        return SizeUtil.Sp2Px(getContext(), value);
    }

SizeUtil工具类见博客:自定义View之尺寸的转化

3.初始化画笔

//画笔初始化
    private void init() {
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setDither(true);
    }

现在的构造方法是这样的

public WatchBoard(Context context, AttributeSet attrs) {
        super(context, attrs);
        obtainStyledAttrs(attrs); //获取自定义的属性
        init(); //初始化画笔
    }

4.由于表盘始终显示是圆形的,要做到图形一直在view的中间很简单,但是那样就会浪费很多的空间,于是我们应该重写onMeasure方法,使得表盘始终只占用一个正方形的空间,但是处理的前提是用户一定会给一个确定的值,不管是宽度还是高度或者两者都是.

处理思路:

1.当宽高均为wrap_content的时候抛出异常,因为这样的操作对于这个组件来说是不合理的

2.给初始化宽度设置一个很大的值,当宽度或者高度确定时取最小值,因为宽高必定有一个为确定值,所以这样过后会得到宽高的最小值

代码如下:

自定义的异常

class NoDetermineSizeException extends Exception {
        public NoDetermineSizeException(String message) {
            super(message);
        }
    }

onMeasure方法:

 @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int width = 1000; //设定一个最小值


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


        if (widthMode == MeasureSpec.AT_MOST || widthMode == MeasureSpec.UNSPECIFIED || heightMeasureSpec == MeasureSpec.AT_MOST || heightMeasureSpec == MeasureSpec.UNSPECIFIED) {
            try {
                throw new NoDetermineSizeException("宽度高度至少有一个确定的值,不能同时为wrap_content");
            } catch (NoDetermineSizeException e) {
                e.printStackTrace();
            }
        } else { //至少有一个为确定值,要获取其中的最小值
            if (widthMode == MeasureSpec.EXACTLY) {
                width = Math.min(widthSize, width);
            }
            if (heightMode == MeasureSpec.EXACTLY) {
                width = Math.min(heightSize, width);
            }
        }

        setMeasuredDimension(width, width);

    }

现在的效果如下:(宽高均为match_parent的时候也仍然只占用一个正方形)

这里写图片描述

这样做的原因是减少空间的浪费,主要还是避免下面的这种情况(设置宽或高为match_parent时占满全屏,影响其他组件的显示.)

这里写图片描述

5.获取表盘圆的半径值与尾部长度值

获取值应该在测量完成之后,所以在onSizeChange里面获取

 @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mRadius = (Math.min(w, h) - mPadding) / 2;
        mPointEndLength = mRadius / 6; //尾部指针默认为半径的六分之一
    }

6.接下来就是最重要的绘制阶段了

主要分为几个阶段

1>.绘制外圆表盘

2>.绘制刻度与时间标示

3.绘制指针

首先绘制表盘

为减少计算量,首先将canvas的坐标原点移动到中心位置

这一步的操作如图所示:

这里写图片描述
 @Override
    protected void onDraw(Canvas canvas) {
        canvas.save();
        canvas.translate(getWidth() / 2, getHeight() / 2);
       
        ...
        canvas.restore();
    }

已将获取到半径了,那直接绘制圆形

//绘制外圆背景
    public void paintCircle(Canvas canvas) {
        mPaint.setColor(Color.WHITE);
        mPaint.setStyle(Paint.Style.FILL);
        canvas.drawCircle(0, 0, mRadius, mPaint);
    }
    //现在onDraw方法如下
    @Override
    protected void onDraw(Canvas canvas) {
        canvas.save();
        canvas.translate(getWidth() / 2, getHeight() / 2);
        //绘制外圆背景
        paintCircle(canvas);
        canvas.restore();
    }

这一步效果如下:

这里写图片描述

接下来是绘制刻度与文字

从实例图上可以看出,一共有60个刻度,两个刻度之间的角度是,其中包含12个整点刻度.

绘制的时候我们希望的当然是直接在X轴或者Y轴上绘制线条,但是当前的x,y相对于原点来说都是水平,垂直的,那么我们就可以想到将坐标系每次旋转6°进行绘制,一共旋转60次,并且每次都是在x轴或者y轴上绘制.

设刻度长度mLineWidth,选定Y轴绘制线条,过程如下:

这里写图片描述

60个刻度进行判断,整点和非整点刻度设置不同的长度,颜色,宽度,绘制一个之后画布旋转,即可完成所有刻度的绘制,代码如下:

//绘制刻度
    private void paintScale(Canvas canvas) {
        mPaint.setStrokeWidth(SizeUtil.Dp2Px(getContext(), 1));
        int lineWidth = 0;
        for (int i = 0; i < 60; i++) {
            if (i % 5 == 0) { //整点
                mPaint.setStrokeWidth(SizeUtil.Dp2Px(getContext(), 1.5f));
                mPaint.setColor(mColorLong);
                lineWidth = 40;
            } else { //非整点
                lineWidth = 30;
                mPaint.setColor(mColorShort);
                mPaint.setStrokeWidth(SizeUtil.Dp2Px(getContext(), 1));
            }
            canvas.drawLine(0, -mRadius + SizeUtil.Dp2Px(getContext(), 10), 0, -mRadius + SizeUtil.Dp2Px(getContext(), 10) + lineWidth, mPaint);
            canvas.rotate(6);
        }
        canvas.restore();

现在的效果如下:

这里写图片描述

接下来要绘制文字,由于只有整点才有文字,所以文字的绘制就放到整点绘制的if里面.

首先获取要显示的文字内容

String text = ((i / 5) == 0 ? 12 : (i / 5)) + "";

不难理解,i0~60,而第一个Y轴上绘制的应该是12点,其他的只要对5作取余数就可得到.

由实例图可以看到文字都是垂直方向上的,所以绘制文字的时候应该将画布中心移动到刻度后面,并且旋转的角度与绘制刻度旋转的角度相反,当然别忘了用canvas.save()canvas.restore().这么说可能有点难理解,看图吧:

这里写图片描述

这有几个数据需要处理

1.已经旋转过的坐标中心,要移动到刻度后面,那么他移动后的Y坐标因该是多少?

2.要将文字绘制在垂直方向,逆时针方向偏转的角度是多少?

3.文字的高度如何计算?

先处理一下这几个数据:

1>.上图已经标记清楚了,坐标原点移动到刻度后面,移动后的Y轴的值如下:

Y坐标 = -mRadius + mLineWidth(刻度长度)+文字高度+文字与刻度的偏移量

偏移量我们自己设计,暂且设为5dp,而刻度长度已经确定了,那么只剩下文字的高度,由如下方法获得

mPaint.setTextSize(mTextSize);
String text = ((i / 5) == 0 ? 12 : (i / 5)) + "";
Rect textBound = new Rect();
mPaint.getTextBounds(text, 0, text.length(), textBound);
int textHeight = textBound.bottom - textBound.top; //获得文字高度

最终的移动后的Y坐标值为:

-mRadius + DptoPx(5) + lineWidth + (textBound.bottom - textBound.top))

而要绘制文字应该旋转的角度为-6 * i(当前旋转的角度的负值)

数据都有了,接下来就是绘制文字了.绘制文字目前还需要绘制文字的起始X,Y坐标,注意其中的Y坐标的基线的坐标.有不懂的同学建议看着片博客,后部分有关于绘制文字的详细内容:Android仿京东首页轮播文字(又名垂直跑马灯)

绘制文字的示意图如下:

这里写图片描述

图上说的已将很明白了,那么就开始绘制文字了

canvas.save();
canvas.translate(0, -mRadius + DptoPx(5) + lineWidth + (textBound.bottom - textBound.top));
canvas.rotate(-6 * i);
mPaint.setStyle(Paint.Style.FILL);
canvas.drawText(text, -(textBound.right - textBound.left) / 2,textBound.bottom, mPaint);
canvas.restore();

完整的绘制刻度方法

 //绘制刻度
    private void paintScale(Canvas canvas) {
        mPaint.setStrokeWidth(SizeUtil.Dp2Px(getContext(), 1));
        int lineWidth = 0;
        for (int i = 0; i < 60; i++) {
            if (i % 5 == 0) { //整点
                mPaint.setStrokeWidth(SizeUtil.Dp2Px(getContext(), 1.5f));
                mPaint.setColor(mColorLong);
                lineWidth = 40;
                mPaint.setTextSize(mTextSize);
                String text = ((i / 5) == 0 ? 12 : (i / 5)) + "";
                Rect textBound = new Rect();
                mPaint.getTextBounds(text, 0, text.length(), textBound);
                mPaint.setColor(Color.BLACK);
                canvas.save();
                canvas.translate(0, -mRadius + DptoPx(5) + lineWidth + (textBound.bottom - textBound.top));
                canvas.rotate(-6 * i);
                mPaint.setStyle(Paint.Style.FILL);
                canvas.drawText(text, -(textBound.right - textBound.left) / 2,textBound.bottom, mPaint);
                canvas.restore();
            } else { //非整点
                lineWidth = 30;
                mPaint.setColor(mColorShort);
                mPaint.setStrokeWidth(SizeUtil.Dp2Px(getContext(), 1));
            }
            canvas.drawLine(0, -mRadius + SizeUtil.Dp2Px(getContext(), 10), 0, -mRadius + SizeUtil.Dp2Px(getContext(), 10) + lineWidth, mPaint);
            canvas.rotate(6);
        }
        canvas.restore();
    }

效果图:

这里写图片描述

由图可以看出文字和刻度都绘制在了我们希望他们在的地方

接下来绘制指针:

绘制指针用的是canvas.drawRoundRect方法 ,需要指定指针的RectF属性,为了简化计算,我们仍然采用的是在Y轴上绘制然后旋转指定角度的方法.

首先获取当前的实践,计算时分秒各要旋转的角度值

 Calendar calendar = Calendar.getInstance();
        int hour = calendar.get(Calendar.HOUR_OF_DAY); //时
        int minute = calendar.get(Calendar.MINUTE); //分
        int second = calendar.get(Calendar.SECOND); //秒
        int angleHour = (hour % 12) * 360 / 12; //时针转过的角度
        int angleMinute = minute * 360 / 60; //分针转过的角度
        int angleSecond = second * 360 / 60; //秒针转过的角度

获取指针RectF的示意图:

这里写图片描述

看懂了就简单多了,在Y轴绘制RoundRect,然后旋转对应的角度即可,时分秒针旋转的角度不同,所以都需要用canvas.save()canvas.restore()方法包括.直接上全部指针的代码:

private void paintPointer(Canvas canvas) {
        Calendar calendar = Calendar.getInstance();
        int hour = calendar.get(Calendar.HOUR_OF_DAY); //时
        int minute = calendar.get(Calendar.MINUTE); //分
        int second = calendar.get(Calendar.SECOND); //秒
        int angleHour = (hour % 12) * 360 / 12; //时针转过的角度
        int angleMinute = minute * 360 / 60; //分针转过的角度
        int angleSecond = second * 360 / 60; //秒针转过的角度
        //绘制时针
        canvas.save();
        canvas.rotate(angleHour); //旋转到时针的角度
        RectF rectFHour = new RectF(-mHourPointWidth / 2, -mRadius * 3 / 5, mHourPointWidth / 2, mPointEndLength);
        mPaint.setColor(mHourPointColor); //设置指针颜色
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeWidth(mHourPointWidth); //设置边界宽度
        canvas.drawRoundRect(rectFHour, mPointRadius, mPointRadius, mPaint); //绘制时针
        canvas.restore();
        //绘制分针
        canvas.save();
        canvas.rotate(angleMinute);
        RectF rectFMinute = new RectF(-mMinutePointWidth / 2, -mRadius * 3.5f / 5, mMinutePointWidth / 2, mPointEndLength);
        mPaint.setColor(mMinutePointColor);
        mPaint.setStrokeWidth(mMinutePointWidth);
        canvas.drawRoundRect(rectFMinute, mPointRadius, mPointRadius, mPaint);
        canvas.restore();
        //绘制秒针
        canvas.save();
        canvas.rotate(angleSecond);
        RectF rectFSecond = new RectF(-mSecondPointWidth / 2, -mRadius + 15, mSecondPointWidth / 2, mPointEndLength);
        mPaint.setColor(mSecondPointColor);
        mPaint.setStrokeWidth(mSecondPointWidth);
        canvas.drawRoundRect(rectFSecond, mPointRadius, mPointRadius, mPaint);
        canvas.restore();
        //绘制中心小圆
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setColor(mSecondPointColor);
        canvas.drawCircle(0, 0, mSecondPointWidth * 4, mPaint);
    }

最后在onDraw()内调用各绘制方法即可.然后每隔一秒钟刷新一次.最终的onDraw如下:

@Override
    protected void onDraw(Canvas canvas) {
        canvas.save();
        canvas.translate(getWidth() / 2, getHeight() / 2);
        //绘制外圆背景
        paintCircle(canvas);
        //绘制刻度
        paintScale(canvas);
        //绘制指针
        paintPointer(canvas);
        canvas.restore();
        //刷新
        postInvalidateDelayed(1000);
    }

最终效果:

这里写图片描述

这个自定义View是写在我个人的Demo合集当中的,就不单独提取出来浪费时间了.给出我的Demo合集地址:LibManager

java文件就在UILib module下的watchboard package下.晚些时候会写个Demo合集的介绍.先放个动态图(只演示部分).

这里写图片描述

至此这个自定义View就完成了,有不足的地方欢迎指出,另外建了个新手交流Android开发的QQ群,欢迎加入.

群号:375276053

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,784评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,745评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,702评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,229评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,245评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,376评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,798评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,471评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,655评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,485评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,535评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,235评论 3 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,793评论 3 304
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,863评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,096评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,654评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,233评论 2 341

推荐阅读更多精彩内容