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,如果有任何問題,歡迎留言指正。

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