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

1,994 阅读8分钟

效果展示:

这里写图片描述

灵感来源:

这里写图片描述

下面就直接进入正题吧:

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文件,内容如下



    
        
        
        
        
        
        
        
        
        
        
        
        
        
        
        
        
        
        
        
        
        
        
        
        
    

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

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()
            }
        }

    }

其中用到的尺寸转换方法


    private float DptoPx(int value) {

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

    
    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);
    }
    
    @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
            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
            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