Android自定義View精品(CustomCalendar-定製日曆控件)

版權聲明:本文爲openXu原創文章【openXu的博客】,未經博主允許不得以任何形式轉載

目錄:


  應項目需求,需要做一個日曆控件,效果圖如下:

     這裏寫圖片描述

  接到需求後,沒有立即查找是否有相關開源日曆控件可用、系統日曆控件是否能滿足 ,第一反應就是這個控件該怎麼畫?誰叫咱自定義控件技術牛逼呢O(∩_∩)O哈哈~(開個玩笑,不要這樣子嚴肅→廣告時間:要想達到哥的高度,請認真學習自定義控件系列博客喔)。言歸正傳,如圖,上部分是自定義日曆的效果圖,下面是系統自帶CalendarView的效果,這兩個控件的相關功能需求元素圖上都有標註。系統自帶的日曆控件能夠左右滑動切換月份,效果還是挺酷的(這是我在自定義控件完畢之後才發現的),結果就後悔了,這麼酷幹嘛還要自定義啊?

  自定義當然不是爲了裝逼吶,請認真看需求,我們需要在日期下面顯示任務完成情況,當日被切換之後需要標註爲灰色圓圈背景,這些都是自帶日曆控件不可達到的,當然,我們也可以繼承系統CalendarView然後試着修改;那爲什麼不選擇開源的日曆控件呢?如果我們不會自定義控件或者時間很緊,開源的當然是首選,誰叫我閒的慌,開源的控件也會有些問題,有的可能不夠完善很多bug,比較完善的可能內容太多,如果要抽取有用的內容還是需要花一定時間,如果整個庫工程都弄過來會造成大量的代碼冗餘。另外一點很重要,任務情況需要從服務器上獲取,當切換日期之後,日曆控件下方要顯示那天的任務詳情(可能需要請求數據),這麼多問題如果去修改開源庫工程工作量不一定比自定義小。綜上,還是自定義更適合我,整個日曆控件500多行代碼就搞定(包括註釋、接口、各種變量),後面如果項目需求有變動,改動起來也是so easy! 當然,日常開發中,自帶控件能搞定的儘量就用系統自帶的。下面我們一起看看這個控件是怎樣實現的。

1、分析

    這裏寫圖片描述

  怎樣自定義這個日曆控件呢?可能我們第一反應是GridView+組合控件的方式,GridView用來展示下面日期部分,這種方式實現起來相對比較容易,但是核心的內容(獲取某月的天數、具體展示在什麼位置)還是得自己做,這種方式代碼量也不少,另外這樣做也會加大系統性能開銷,GridView中同時顯示30來個item,item裏面還嵌套子控件,之前我們講控件填充、測量時講過儘量減少佈局的嵌套,這樣會造成過多的遍歷,一不小心又裝逼了,現在的手機那麼牛逼,這麼點工作量跟我談什麼性能?
  第二種就是通過自定義View完全繪製出來,只需要一個類搞定。其實繪製很簡單,拿到一個畫筆(Paint),我們就能畫天畫地畫美女,愛畫什麼畫什麼,不需要有品位、不需要藝術功底,比起拿2B鉛筆作畫簡單多了。
  如果要繪製出這個控件,我們首先要得到某個月的所有天數(從1號開始…)、1號是星期幾(從什麼位置開始展示),有了這兩個數據,我們就能得到第一行從哪裏開始繪製,能繪製多少天,最後一行能繪製多少天,其他中間的都是繪製7天;接下來需要繪製當前日期和被選中日期的背景,其實就是在繪製日期時先判斷下日期是不是當前日期,如果是就給他先畫一個背景,被選擇的也是一樣。我們先看看獲取日期的算法:

/**設置月份*/
private void setMonth(String Month){
    //設置的月份(2017年01月)
    month = str2Date(Month);

    Calendar calendar = Calendar.getInstance();
    calendar.setTime(new Date());
    //獲取今天是多少號
    currentDay = calendar.get(Calendar.DAY_OF_MONTH);

    Date cM = str2Date(getMonthStr(new Date()));
    //判斷是否爲當月
    if(cM.getTime() == month.getTime()){
        isCurrentMonth = true;
        selectDay = currentDay;//當月默認選中當前日
    }else{
        isCurrentMonth = false;
        selectDay = 0;
    }
    Log.d(TAG, "設置月份:"+month+"   今天"+currentDay+"號, 是否爲當前月:"+isCurrentMonth);
    calendar.setTime(month);
    dayOfMonth = calendar.getActualMaximum(Calendar.DAY_OF_MONTH);
    //第一行1號顯示在什麼位置(星期幾)
    firstIndex = calendar.get(Calendar.DAY_OF_WEEK)-1;
    lineNum = 1;
    //第一行能展示的天數
    firstLineNum = 7-firstIndex;
    lastLineNum = 0;
    int shengyu = dayOfMonth - firstLineNum;
    while (shengyu>7){
        lineNum ++;
        shengyu-=7;
    }
    if(shengyu>0){
        lineNum ++;
        lastLineNum = shengyu;
    }
    Log.i(TAG, getMonthStr(month)+"一共有"+dayOfMonth+"天,第一天的索引是:"+firstIndex+"   有"+lineNum+
            "行,第一行"+firstLineNum+"個,最後一行"+lastLineNum+"個");
}

2、自定義屬性

  自定義屬性相關的知識請參考Android自定義View(二、深入解析自定義屬性),這裏就不多說了,我們看看本控件都定義了那些屬性:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CustomCalendar">
        <!--這四個顏色分別是月份、星期、日期、任務的背景色,只是方便調試測量時使用,正式試用時可配置透明色-->
        <attr name="mBgMonth" format="color" />
        <attr name="mBgWeek" format="color" />
        <attr name="mBgDay" format="color" />
        <attr name="mBgPre" format="color" />

        <attr name="mTextColorMonth" format="color" />           <!--標題字體顏色-->
        <attr name="mTextColorWeek" format="color" />            <!--星期字體顏色-->
        <attr name="mTextColorDay" format="color" />             <!--日期字體顏色-->
        <attr name="mTextColorPreFinish" format="color" />       <!--任務次數字體顏色-->
        <attr name="mTextColorPreUnFinish" format="color" />
        <attr name="mSelectTextColor" format="color" />          <!--選中日期字體顏色-->
        <attr name="mSelectBg" format="color" />                 <!--選中日期背景-->
        <attr name="mCurrentBg" format="color" />                <!--當天日期背景-->
        <attr name="mCurrentBgStrokeWidth" format="dimension" /> <!--當天日期背景虛線寬度-->
        <attr name="mCurrentBgDashPath" format="reference" />    <!--當天日期背景虛線數組-->

        <attr name="mTextSizeMonth" format="dimension" />        <!--標題字體大小-->
        <attr name="mTextSizeWeek" format="dimension" />         <!--星期字體大小-->
        <attr name="mTextSizeDay" format="dimension" />          <!--日期字體大小-->
        <attr name="mTextSizePre" format="dimension" />          <!--任務次數字體大小-->

        <attr name="mMonthRowL" format="reference" />            <!--月份箭頭-->
        <attr name="mMonthRowR" format="reference" />            <!--月份箭頭-->
        <attr name="mMonthRowSpac" format="dimension" />

        <attr name="mSelectRadius" format="dimension" />         <!--選中日期背景半徑-->
        <attr name="mMonthSpac" format="dimension" />            <!--標題月份上下間隔-->
        <attr name="mLineSpac" format="dimension" />             <!--日期行間距-->
        <attr name="mTextSpac" format="dimension" />             <!--日期和任務次數字體上下間距-->
    </declare-styleable>
</resources>

3、onMeasure()

  得到需要繪製的數據之後,接下來就是重寫onMeasure()方法了,這個控件需要多寬多高?寬度直接填充父窗體即可,總高度=月份高度+星期高度+日期高度,相應的數據在上面的算法中都得到了,請看下面分析圖:
    這裏寫圖片描述

代碼:

    /**計算相關常量,構造方法中調用*/
    private void initCompute(){
        mPaint = new Paint();
        bgPaint = new Paint();
        mPaint.setAntiAlias(true); //抗鋸齒
        bgPaint.setAntiAlias(true); //抗鋸齒

        map = new HashMap<>();

        //標題高度
        mPaint.setTextSize(mTextSizeMonth);
        titleHeight = FontUtil.getFontHeight(mPaint) + 2 * mMonthSpac;
        //星期高度
        mPaint.setTextSize(mTextSizeWeek);
        weekHeight = FontUtil.getFontHeight(mPaint);
        //日期高度
        mPaint.setTextSize(mTextSizeDay);
        dayHeight = FontUtil.getFontHeight(mPaint);
        //次數字體高度
        mPaint.setTextSize(mTextSizePre);
        preHeight = FontUtil.getFontHeight(mPaint);
        //每行高度 = 行間距 + 日期字體高度 + 字間距 + 次數字體高度
        oneHeight = mLineSpac + dayHeight + mTextSpac + preHeight;

        //默認當前月份
        String cDateStr = getMonthStr(new Date());
//        cDateStr = "2015年08月";
        setMonth(cDateStr);
    }

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    //寬度 = 填充父窗體
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);   //獲取寬的尺寸
    columnWidth = widthSize / 7;
    //高度 = 標題高度 + 星期高度 + 日期行數*每行高度
    float height = titleHeight + weekHeight + (lineNum * oneHeight);
    Log.v(TAG, "標題高度:"+titleHeight+" 星期高度:"+weekHeight+" 每行高度:"+oneHeight+
            " 行數:"+ lineNum + "  \n控件高度:"+height);
    setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
            (int)height);

}

4、onDraw()

@Override
protected void onDraw(Canvas canvas) {
    drawMonth(canvas);
    drawWeek(canvas);
    drawDayAndPre(canvas);
}

①、繪製月份

private void drawMonth(Canvas canvas){
    //背景
    bgPaint.setColor(mBgMonth);
    RectF rect = new RectF(0, 0, getWidth(), titleHeight);
    canvas.drawRect(rect, bgPaint);
    //繪製月份
    mPaint.setTextSize(mTextSizeMonth);
    mPaint.setColor(mTextColorMonth);
    float textLen = FontUtil.getFontlength(mPaint, getMonthStr(month));
    float textStart = (getWidth() - textLen)/ 2;
    canvas.drawText(getMonthStr(month), textStart,
            mMonthSpac+FontUtil.getFontLeading(mPaint), mPaint);
    /*繪製左右箭頭*/
    Bitmap bitmap = BitmapFactory.decodeResource(getResources(), mMonthRowL);
    int h = bitmap.getHeight();
    rowWidth = bitmap.getWidth();
    //float left, float top
    rowLStart = (int)(textStart-2*mMonthRowSpac-rowWidth);
    canvas.drawBitmap(bitmap, rowLStart+mMonthRowSpac , (titleHeight - h)/2, new Paint());
    bitmap = BitmapFactory.decodeResource(getResources(), mMonthRowR);
    rowRStart = (int)(textStart+textLen);
    canvas.drawBitmap(bitmap, rowRStart+mMonthRowSpac, (titleHeight - h)/2, new Paint());
}

②、繪製星期

private String[] WEEK_STR = new String[]{"Sun", "Mon", "Tues", "Wed", "Thur", "Fri", "Sat", };
private void drawWeek(Canvas canvas){
    //背景
    bgPaint.setColor(mBgWeek);
    RectF rect = new RectF(0, titleHeight, getWidth(), titleHeight + weekHeight);
    canvas.drawRect(rect, bgPaint);
    //繪製星期:七天
    mPaint.setTextSize(mTextSizeWeek);
    mPaint.setColor(mTextColorWeek);
    for(int i = 0; i < WEEK_STR.length; i++){
        int len = (int)FontUtil.getFontlength(mPaint, WEEK_STR[i]);
        int x = i * columnWidth + (columnWidth - len)/2;
        canvas.drawText(WEEK_STR[i], x, titleHeight + FontUtil.getFontLeading(mPaint), mPaint);
    }
}

③、繪製日期及任務

private void drawDayAndPre(Canvas canvas){
        //某行開始繪製的Y座標,第一行開始的座標爲標題高度+星期部分高度
        float top = titleHeight+weekHeight;
        //行
        for(int line = 0; line < lineNum; line++){
            if(line == 0){
                //第一行
                drawDayAndPre(canvas, top, firstLineNum, 0, firstIndex);
            }else if(line == lineNum-1){
                //最後一行
                top += oneHeight;
                drawDayAndPre(canvas, top, lastLineNum, firstLineNum+(line-1)*7, 0);
            }else{
                //滿行
                top += oneHeight;
                drawDayAndPre(canvas, top, 7, firstLineNum+(line-1)*7, 0);
            }
        }
    }

    /**
     * 繪製某一行的日期
     * @param canvas
     * @param top 頂部座標
     * @param count 此行需要繪製的日期數量(不一定都是7天)
     * @param overDay 已經繪製過的日期,從overDay+1開始繪製
     * @param startIndex 此行第一個日期的星期索引
     */
    private void drawDayAndPre(Canvas canvas, float top,
                               int count, int overDay, int startIndex){
//        Log.e(TAG, "總共"+dayOfMonth+"天  有"+lineNum+"行"+ "  已經畫了"+overDay+"天,下面繪製:"+count+"天");
        //背景
        float topPre = top + mLineSpac + dayHeight;
        bgPaint.setColor(mBgDay);
        RectF rect = new RectF(0, top, getWidth(), topPre);
        canvas.drawRect(rect, bgPaint);

        bgPaint.setColor(mBgPre);
        rect = new RectF(0, topPre, getWidth(), topPre + mTextSpac + dayHeight);
        canvas.drawRect(rect, bgPaint);

        mPaint.setTextSize(mTextSizeDay);
        float dayTextLeading = FontUtil.getFontLeading(mPaint);
        mPaint.setTextSize(mTextSizePre);
        float preTextLeading = FontUtil.getFontLeading(mPaint);
//        Log.v(TAG, "當前日期:"+currentDay+"   選擇日期:"+selectDay+"  是否爲當前月:"+isCurrentMonth);
        for(int i = 0; i<count; i++){
            int left = (startIndex + i)*columnWidth;
            int day = (overDay+i+1);

            mPaint.setTextSize(mTextSizeDay);

            //如果是當前月,當天日期需要做處理
            if(isCurrentMonth && currentDay == day){
                mPaint.setColor(mTextColorDay);
                bgPaint.setColor(mCurrentBg);
                bgPaint.setStyle(Paint.Style.STROKE);  //空心
                PathEffect effect = new DashPathEffect(mCurrentBgDashPath, 1);
                bgPaint.setPathEffect(effect);   //設置畫筆曲線間隔
                bgPaint.setStrokeWidth(mCurrentBgStrokeWidth);       //畫筆寬度
                //繪製空心圓背景
                canvas.drawCircle(left+columnWidth/2, top + mLineSpac +dayHeight/2,
                        mSelectRadius-mCurrentBgStrokeWidth, bgPaint);
            }
            //繪製完後將畫筆還原,避免髒筆
            bgPaint.setPathEffect(null);
            bgPaint.setStrokeWidth(0);
            bgPaint.setStyle(Paint.Style.FILL);

            //選中的日期,如果是本月,選中日期正好是當天日期,下面的背景會覆蓋上面繪製的虛線背景
            if(selectDay == day){
                //選中的日期字體白色,橙色背景
                mPaint.setColor(mSelectTextColor);
                bgPaint.setColor(mSelectBg);
                //繪製橙色圓背景,參數一是中心點的x軸,參數二是中心點的y軸,參數三是半徑,參數四是paint對象;
                canvas.drawCircle(left+columnWidth/2, top + mLineSpac +dayHeight/2, mSelectRadius, bgPaint);
            }else{
                mPaint.setColor(mTextColorDay);
            }

            int len = (int)FontUtil.getFontlength(mPaint, day+"");
            int x = left + (columnWidth - len)/2;
            canvas.drawText(day+"", x, top + mLineSpac + dayTextLeading, mPaint);

            //繪製次數
            mPaint.setTextSize(mTextSizePre);
            MainActivity.DayFinish finish = map.get(day);
            String preStr = "0/0";
            if(finish!=null){
                //區分完成未完成
                if(finish.finish >= finish.all) {
                    mPaint.setColor(mTextColorPreFinish);
                }else{
                    mPaint.setColor(mTextColorPreUnFinish);
                }
                preStr = finish.finish+"/"+finish.all;

            }else{
                mPaint.setColor(mTextColorPreUnFinish);
            }
            len = (int)FontUtil.getFontlength(mPaint, preStr);
            x = left + (columnWidth - len)/2;
            canvas.drawText(preStr, x, topPre + mTextSpac + preTextLeading, mPaint);
        }
    }

這部分完成之後,我們自定義日曆的繪製工作就over了,下面我們看看效果圖:
    這裏寫圖片描述

5、事件處理

  事件相關知識點也是自定義控件比較重要的內容,後面有空會詳細介紹。下面我們看看這個控件需要處理那些事件。當點擊箭頭時需要增減月份,點擊日期時需要置爲選中。控件接受到事件之後,我要怎樣知道點擊的是箭頭還是日期還是其他部位?只能通過事件的座標計算了,如果在某個範圍之內即可,在上面的分析圖中,將控件劃分成了很多小網格,這些小網格的座標範圍都是確定的(根據寬高等數據),事件發生後,只需要判斷事件點座標是否落入相應區域即可,然後邊測試邊修改一些細節問題,下面是事件處理先關的代碼:

//焦點座標
    private PointF focusPoint = new PointF();
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getAction() & MotionEvent.ACTION_MASK;
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                focusPoint.set(event.getX(), event.getY());
                touchFocusMove(focusPoint, false);
                break;
            case MotionEvent.ACTION_MOVE:
                focusPoint.set(event.getX(), event.getY());
                touchFocusMove(focusPoint, false);
                break;
            case MotionEvent.ACTION_OUTSIDE:
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                focusPoint.set(event.getX(), event.getY());
                touchFocusMove(focusPoint, true);
                break;
        }
        return true;
    }

    /**焦點滑動*/
    public void touchFocusMove(final PointF point, boolean eventEnd) {
        Log.e(TAG, "點擊座標:("+point.x+" ,"+point.y+"),事件是否結束:"+eventEnd);
        /**標題和星期只有在事件結束後才響應*/
        if(point.y<=titleHeight){
            //事件在標題上
            if(eventEnd && listener!=null){
                if(point.x>=rowLStart && point.x<(rowLStart+2*mMonthRowSpac+rowWidth)){
                    Log.w(TAG, "點擊左箭頭");
                    listener.onLeftRowClick();
                }else if(point.x>rowRStart && point.x<(rowRStart + 2*mMonthRowSpac+rowWidth)){
                    Log.w(TAG, "點擊右箭頭");
                    listener.onRightRowClick();
                }else if(point.x>rowLStart && point.x <rowRStart){
                    listener.onTitleClick(getMonthStr(month), month);
                }
            }
        }else if(point.y<=(titleHeight+weekHeight)){
            //事件在星期部分
            if(eventEnd && listener!=null){
                //根據X座標找到具體的焦點日期
                int xIndex = (int)point.x / columnWidth;
                Log.e(TAG, "列寬:"+columnWidth+"  x座標餘數:"+(point.x / columnWidth));
                if((point.x / columnWidth-xIndex)>0){
                    xIndex += 1;
                }
                if(listener!=null){
                    listener.onWeekClick(xIndex-1, WEEK_STR[xIndex-1]);
                }
            }
        }else{
            /**日期部分按下和滑動時重繪,只有在事件結束後才響應*/
            touchDay(point, eventEnd);
        }
    }

    //控制事件是否響應
    private boolean responseWhenEnd = false;
    /**事件點在 日期區域 範圍內*/
    private void touchDay(final PointF point, boolean eventEnd){
        //根據Y座標找到焦點行
        boolean availability = false;  //事件是否有效
        //日期部分
        float top = titleHeight+weekHeight+oneHeight;
        int foucsLine = 1;
        while(foucsLine<=lineNum){
            if(top>=point.y){
                availability = true;
                break;
            }
            top += oneHeight;
            foucsLine ++;
        }
        if(availability){
            //根據X座標找到具體的焦點日期
            int xIndex = (int)point.x / columnWidth;
            if((point.x / columnWidth-xIndex)>0){
                xIndex += 1;
            }
//            Log.e(TAG, "列寬:"+columnWidth+"  x座標餘數:"+(point.x / columnWidth));
            if(xIndex<=0)
                xIndex = 1;   //避免調到上一行最後一個日期
            if(xIndex>7)
                xIndex = 7;   //避免調到下一行第一個日期
//            Log.e(TAG, "事件在日期部分,第"+foucsLine+"/"+lineNum+"行, "+xIndex+"列");
            if(foucsLine == 1){
                //第一行
                if(xIndex<=firstIndex){
                    Log.e(TAG, "點到開始空位了");
                    setSelectedDay(selectDay, true);
                }else{
                    setSelectedDay(xIndex-firstIndex, eventEnd);
                }
            }else if(foucsLine == lineNum){
                //最後一行
                if(xIndex>lastLineNum){
                    Log.e(TAG, "點到結尾空位了");
                    setSelectedDay(selectDay, true);
                }else{
                    setSelectedDay(firstLineNum + (foucsLine-2)*7+ xIndex, eventEnd);
                }
            }else{
                setSelectedDay(firstLineNum + (foucsLine-2)*7+ xIndex, eventEnd);
            }
        }else{
            //超出日期區域後,視爲事件結束,響應最後一個選擇日期的回調
            setSelectedDay(selectDay, true);
        }
    }
    /**設置選中的日期*/
    private void setSelectedDay(int day, boolean eventEnd){
        Log.w(TAG, "選中:"+day+"  事件是否結束"+eventEnd);
        selectDay = day;
        invalidate();
        if(listener!=null && eventEnd && responseWhenEnd && lastSelectDay!=selectDay) {
            lastSelectDay = selectDay;
            listener.onDayClick(selectDay, getMonthStr(month) + selectDay + "日", map.get(selectDay));
        }
        responseWhenEnd = !eventEnd;
    }

最終效果如下:

    這裏寫圖片描述

  本篇博客講解沒有特別細緻,但是關鍵的思路已經很清晰了,其實自定義控件也就那麼會事兒,在之前自定義控件系列博客及案例中已經講解的非常詳細了;如果後面我再更新自定義系列文章也將側重講解思路,知識點不熟悉的還請移步自定義控件基礎

##源碼下載:

注:沒有積分的童鞋 請點贊留言索要代碼

http://download.csdn.net/detail/u010163442/9728781 CSDN下載平臺很噁心

https://github.com/openXu/CustomCalendar

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