Android自定义View之钟表绘制

自定义view一直是Android进阶路上的一块石头,跨过去就是垫脚石,跨不过去就是绊脚石。作为一个攻城狮,怎么能被他绊倒,一定要跟它死磕到底,这段时间看到自定义View新手实战-一步步实现精美的钟表界面特别漂亮,咱们也来手撸一个。

先看下效果图
这里写图片描述

咱们先写一个类WatchBoard继承View,并重写他的构造方法

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

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

    public WatchBoard(Context context, AttributeSet attrs, int defStyleAttr) {
        this(context, attrs, defStyleAttr,0);
    }

    public WatchBoard(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    /**
    * 这是最全面构造方法的写法,存在一个问题,当minSdkVersion<21的时候,这里会出现红线,这里有两个解决办法
    * 1.删除最后一个构造函数,使用前三个就可以,最直接暴力。
    * 2.加入 @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)或者
    *        @TargetApi(Build.VERSION_CODES.LOLLIPOP),
    * 这里注意加入这些注解只是不出现红线,一旦使用这个构造函数在API 21以下还是会出现错误。
    */ 

    // 这里写内容
    }

1. 首先声明需要的属性

在res/values包下新建attrs.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>

我们一气呵成,在类里面声明需要的属性如下

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

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

    private Paint mPaint; // 画笔
    private PaintFlagsDrawFilter mDrawFilter; // 为画布设置抗锯齿

在构造方法中写一个方法取出我们的属性

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

        mHourPointColor = typedArray.getColor(R.styleable.WatchBoard_wb_hour_pointer_color, Color.BLACK);
        mMinutePointColor = typedArray.getColor(R.styleable.WatchBoard_wb_minute_pointer_color, Color.BLACK);
        mSecondPointColor = typedArray.getColor(R.styleable.WatchBoard_wb_second_pointer_color, Color.RED);
        mColorLong = typedArray.getColor(R.styleable.WatchBoard_wb_scale_long_color, Color.argb(225, 0, 0, 0));
        mColorShort = typedArray.getColor(R.styleable.WatchBoard_wb_scale_short_color, Color.argb(125, 0, 0, 0));

        // 一定要回收
        typedArray.recycle();
    }

我们还需要一个画笔,也在构造器中初始化

    public WatchBoard(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        // 获取属性
        obtainStyledAttrs(attrs);
        //初始化画笔
        initPaint();
        // 为画布实现抗锯齿
        mDrawFilter = new PaintFlagsDrawFilter(0, Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
        //测量手机的宽度
        int widthPixels = context.getResources().getDisplayMetrics().widthPixels;
        int heightPixels = context.getResources().getDisplayMetrics().heightPixels;
        // 默认和屏幕的宽高最小值相等
        width = Math.min(widthPixels, heightPixels);
    }

initPaint方法比较简单

    private void initPaint() {
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setDither(true);
    }

我们进行onMeasure()的测量,这里作者用了一个方法setMeasuredDimension(width,width)来保证控件的宽高相等。这里我们先给width一个固定值即屏幕的短边。

        //测量手机的宽度
        int widthPixels = context.getResources().getDisplayMetrics().widthPixels;
        int heightPixels = context.getResources().getDisplayMetrics().heightPixels;

        // 默认和屏幕的宽高最小值相等
        width = Math.min(widthPixels,heightPixels);

重写onMeasure()方法,设置钟表为一个正方形

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        // 传入相同的数width,height,确保是正方形背景
        setMeasuredDimension(measureSize(widthMeasureSpec),measureSize(heightMeasureSpec));
    }

    // 这里不用管测量模式是什么,因为咱们有屏幕短边保底,只取其中一个小值即可。测量宽高和屏幕短边作对比,返回最小值
    private int measureSize(int measureSpec) {
        int size=MeasureSpec.getSize(measureSpec);
        width=Math.min(width,size);
        return width;
    }

为了验证结果,咱们测试一番,先将xml中修改宽高属性:

    <com.example.jmf.timetest.WatchBoard
        android:layout_width="match_parent"
        android:layout_height="300dp"
        android:background="@color/colorPrimary"
        app:wb_scale_short_color="@color/colorAccent"/>

我们在onMeasure()方法和measureSize()方法中分别加入Log日志。

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        // 传入相同的数width,height,确保是正方形背景
        setMeasuredDimension(measureSize(widthMeasureSpec),measureSize(heightMeasureSpec));
    }

    // 这里不用管测量模式是什么,因为咱们有屏幕短边保底,只取其中一个小值即可。测量宽高和屏幕短边作对比,返回最小值
    private int measureSize(int measureSpec) {
        int size=MeasureSpec.getSize(measureSpec);
        width=Math.min(width,size);
        return width;
    }

运行结果如下:
钟表依然是正方形
从钟表背景色可以看出,钟表依然是正方形。我们再来看下日志文件:
日志结果如下,onMeasure进行了两次测量,最终结果为width = 900,为什么是900呢?因为我的手机density = 3。

07-13 11:48:59.987 8385-8385/com.example.jmf.timetest E/TAG: WatchBoard measureSize() width == 1080
07-13 11:48:59.987 8385-8385/com.example.jmf.timetest E/TAG: WatchBoard measureSize() width == 900
07-13 11:48:59.987 8385-8385/com.example.jmf.timetest E/TAG: WatchBoard onMeasure()i+++j ==1080+++900
07-13 11:48:59.997 8385-8385/com.example.jmf.timetest E/TAG: WatchBoard measureSize() width == 900
07-13 11:48:59.997 8385-8385/com.example.jmf.timetest E/TAG: WatchBoard measureSize() width == 900
07-13 11:48:59.997 8385-8385/com.example.jmf.timetest E/TAG: WatchBoard onMeasure()i+++j ==900+++900

测量完成之后我们要重写一个onSizeChanged()来获取最终测量好的width,顺便给表盘半径mRadius赋值。

    @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; // 设置成半径的六分之一
    }
第一步绘制圆盘背景,当然绘制方法是在onDraw()方法中,这里重写onDraw()方法并为canvas设置抗锯齿
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 为画布设置抗锯齿
        canvas.setDrawFilter(mDrawFilter);
        // 绘制半径圆
        drawCircle(canvas);
    }
        // 绘制半径圆
        private void drawCircle(Canvas canvas) {
        mPaint.setColor(Color.WHITE);
        mPaint.setStyle(Paint.Style.FILL);
        canvas.drawCircle(width/2, width/2, mRadius, mPaint);
    }

相信这里没有什么难度,效果如下

这里写图片描述

第二步绘制刻度,刻度尺分为大刻度尺和小刻度尺,对应的颜色不同,我们需要区别对待
private void drawScale(Canvas canvas) {
        mPaint.setStrokeWidth(SizeUtils.Dp2Px(getContext(), 1));
        int lineWidth;
        for (int i = 0; i < 60; i++) {
            if (i % 5 == 0) {
                mPaint.setStrokeWidth(SizeUtils.Dp2Px(getContext(), 1.5f));
                mPaint.setColor(mColorLong);
                lineWidth = 40;
            } else {
                lineWidth = 30;
                mPaint.setColor(mColorShort);
                mPaint.setStrokeWidth(SizeUtils.Dp2Px(getContext(), 1));
            }
            canvas.drawLine(width/2,  mPadding, width/2,  mPadding + lineWidth, mPaint);
            canvas.rotate(6,width/2,width/2);
        }
    }

这里写图片描述

第三步绘制数字,字和长刻度绘制保持一致,我们直接在绘制长刻度的时候绘制字体即可,算法如下

String text = ((i / 5) == 0 ? 12 : (i / 5)) + ""; 整体代码如下

        mPaint.setStrokeWidth(SizeUtils.Dp2Px(getContext(), 1));
        int lineWidth;
        for (int i = 0; i < 60; i++) {
            if (i % 5 == 0) {
                mPaint.setStrokeWidth(SizeUtils.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.drawText(text, width / 2 - textBound.width() / 2, textBound.height() + DptoPx(5) + lineWidth + mPadding, mPaint);
            } else {
                lineWidth = 30;
                mPaint.setColor(mColorShort);
                mPaint.setStrokeWidth(SizeUtils.Dp2Px(getContext(), 1));
            }
            canvas.drawLine(width / 2, mPadding, width / 2, mPadding + lineWidth, mPaint);
            canvas.rotate(6, width / 2, width / 2);
        }

运行效果如下,这里字体好像没有完全正过来,先不管了,接着实现指针
这里写图片描述

第四步绘制指针和圆点
    private void drawPointer(Canvas canvas) {
        Calendar calendar = Calendar.getInstance();

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

        // 绘制时针
        canvas.save();
        canvas.rotate(angleHour,width/2,width/2); // 旋转到时针的角度
        RectF rectHour = new RectF(width/2 -mHourPointWidth / 2, width/2 -mRadius * 3 / 5, width/2 +mHourPointWidth / 2, width/2 + mPointEndLength);
        mPaint.setColor(mHourPointColor);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeWidth(mHourPointWidth);
        canvas.drawRoundRect(rectHour, mPointRadius, mPointRadius, mPaint);
        canvas.restore();
        // 绘制分针
        canvas.save();
        canvas.rotate(angleMinute,width/2,width/2); // 旋转到分针的角度
        RectF rectMinute = new RectF(width/2-mMinutePointWidth / 2, width/2-mRadius * 3.5f / 5, width/2+mMinutePointWidth / 2, width/2+mPointEndLength);
        mPaint.setColor(mMinutePointColor);
        mPaint.setStrokeWidth(mMinutePointWidth);
        canvas.drawRoundRect(rectMinute, mPointRadius, mPointRadius, mPaint);
        canvas.restore();
        // 绘制分针
        canvas.save();
        canvas.rotate(angleSecond,width/2,width/2); // 旋转到分针的角度
        RectF rectSecond = new RectF(width/2-mSecondPointWidth / 2, width/2-mRadius + DptoPx(10), width/2+mSecondPointWidth / 2, width/2+mPointEndLength);
        mPaint.setStrokeWidth(mSecondPointWidth);
        mPaint.setColor(mSecondPointColor);
        canvas.drawRoundRect(rectSecond, mPointRadius, mPointRadius, mPaint);
        canvas.restore();

        // 绘制原点
        mPaint.setStyle(Paint.Style.FILL);
        canvas.drawCircle(width/2, width/2, mSecondPointWidth * 4, mPaint);
    }

这里写图片描述
结果已经有了,现在就是要让他动起来就OK了,这里我们可以使用BroadcastReceiver来监听时间的改变,也可以直接用postInvalidateDelayed(1000)一秒后重绘一句话解决。现在onDerw()中代码如下

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.setDrawFilter(mDrawFilter);
        // 绘制半径圆
        drawCircle(canvas);
        // 绘制刻度尺
        drawScale(canvas);
        // 绘制指针
        drawPointer(canvas);
        // 每一秒刷新一次
        postInvalidateDelayed(1000);
    }

最终效果:
这里写图片描述

这个自定义view已经搞定了,Demo已经传到Github,双手奉上
Github地址: https://github.com/Jmengfei/CustomView
源码都在里面,还会有后续的自定义view添加进去。如果对你们有帮助,不妨留个star和fork,如果有任何问题,欢迎留言指正。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章