自定义View(一)自定义日历

第一次在网络上写文章,想想也是有些小兴奋- -。。。其实写文章这个念头在我心里已经萌生很久了,但是因为种种原因就一直没有开始行动(其实还是懒- -)。之所以选择简书是因为它的界面看起来挺简洁的,毕竟帅气如我,逼格还是要追求一下了(一不小心又装了一波- -)。

好了,回归正题,今天就写一篇关于自定义View的文章,因为这是我的第一篇文章,所以就选了一个难度适中的例子(太难了我怕把自己写晕- -),自定义日历,文章中我会对每一个功能进行详细的描述。废话不多说,先看效果图。

1541579316126.gif

乍一看也没什么突出的啊,但是.....确实没啥突出的啊,简直被原生的完爆。。哈哈,机智如我怎么可能花费两三天时间做一件没有意义的事情呢。其实很多同学可能都遇到过,部分手机用原生的Calendar在5.0前后显示的效果是不一样的,5.0之后效果还是挺好的,但是5.0之前将CalendarView加载到Dialog中的效果是这样的。

捕获.PNG

其实这种不和谐的现象应该是可以解决的,但没办法,我会自定义View, 我就要自定义,就要就要(简书编辑器里面为什么没有emoji表情,写的我是真的难受- -)。

接下来我们来看实现步骤,通过效果图我们可以看出,日历从结构上可以划分为三部分,year和month、week、day,通过图像绘制和触摸反馈共同实现,首先我们先来叙述图像的绘制。

1 . 绘制部分

1.1 year和month部分的绘制


 //绘制月份跟箭头
    private void drawMonth(Canvas canvas){
        String data = getMonthStr(mCurrentDate);//mCurrentDate为当前的日前
        //计算文本的宽高
        Rect rect = new Rect();
        mMonthPaint.getTextBounds(data,0,data.length(),rect);
        float textStartX = mWidth/2 - rect.width()/2;
        float textY = (mMonthHeight - rect.height())/2 + rect.height() - 5;
        //绘制文字
        canvas.drawText(data,textStartX,textY,mMonthPaint);
        //绘制分割线
        mLinePaint.setColor(Color.GRAY);
        mLinePaint.setStrokeWidth(1);
        canvas.drawLine(0,mMonthHeight,mWidth,mMonthHeight,mLinePaint);

        mLinePaint.setStrokeWidth(4);
        mLinePaint.setStrokeJoin(Paint.Join.MITER);

        //绘制左箭头
        Path path = new Path();
        path.moveTo(textStartX-mTextSpec,55);
        path.lineTo(textStartX-mTextSpec-mArrowWidth,mMonthHeight/2);
        path.lineTo( textStartX-mTextSpec,mMonthHeight-55);
        canvas.drawPath(path,mLinePaint);

        //绘制右箭头
        Path path1 = new Path();
        path1.moveTo(textStartX+mTextSpec+rect.width(),55);
        path1.lineTo(textStartX+mTextSpec+rect.width()+mArrowWidth,mMonthHeight/2);
        path1.lineTo(textStartX+mTextSpec+rect.width(),mMonthHeight-55);
        canvas.drawPath(path1,mLinePaint);

    }

年月份的绘制非常简单,注释写的也很清楚,相信稍微有一点自定义View基础的朋友都能看懂。唯一需要说明的是这里的箭头我是用的Path进行绘制的,但也可以通过Bitmap的绘制将一张图片显示到屏幕上,这个就看个人喜好了。

1.2 week部分的绘制

  //绘制周
    private void drawWeek(Canvas canvas){
        //每个周所占用的宽度
        float weekWidth = mWidth/7;
        drawWeekText(canvas,"日",weekWidth/2);
        drawWeekText(canvas,"一",weekWidth/2+weekWidth*1);
        drawWeekText(canvas,"二",weekWidth/2+weekWidth*2);
        drawWeekText(canvas,"三",weekWidth/2+weekWidth*3);
        drawWeekText(canvas,"四",weekWidth/2+weekWidth*4);
        drawWeekText(canvas,"五",weekWidth/2+weekWidth*5);
        drawWeekText(canvas,"六",weekWidth/2+weekWidth*6);

    }

 private void drawWeekText(Canvas canvas,String text,float weekWidth){
        Rect rect = new Rect();
        String textRect = "日";
        mWeekPaint.getTextBounds(textRect,0,textRect.length(),rect);
        float textX = weekWidth;
        //FontUtil.getFontLeading(mPaint)
        float textY = mMonthHeight + (mWeekHeight - rect.height())/2 + rect.height() - 5;
        canvas.drawText(text,textX,textY,mWeekPaint);
    }

我这里采用的宽度是一个屏幕,所以每个周对应的宽度是屏幕的1/7。文字的绘制会牵扯到一个基线的问题,所以不做处理是很难将文字完全居中的,首先获取到文字的高度,(week总高度-text高度)/2就是需要网上移的高度,代码写的也很清楚,就不多做解释了。

1.3 day部分的绘制
day部分的绘制是最麻烦的,把这部分搞清楚那这个自定义日历就很容易实现了。绘制的时候我们应该先考虑当月的第一天是星期几,所以我们要先计算出当月第一天的星期索引,

        //周日索引为0,将日期定位到当月第一天
        calendar.set(Calendar.DATE,1);
        //获取当月第一天星期的索引
        mFirstWeekIndex = calendar.get(Calendar.DAY_OF_WEEK)-1;

获取到当月第一天星期索引后我们再来看绘制的代码:

/**
    *
    * @param canvas
    * @param weekIndex 当月第一天星期索引
    * @param monthDayCount 当月的总天数
    */
   private void drawDay(Canvas canvas,int weekIndex,int monthDayCount){
       Rect rect = new Rect();
       String textRect = "1";
       mDayPaint.getTextBounds(textRect,0,textRect.length(),rect);

       //每日所占用的宽度
       float dayWidth = mWidth/7;
       //当前绘制日期的横向距离
       float currentWidth = dayWidth*weekIndex - dayWidth/2;
       //当前绘制日期的纵向距离
       float currentHeight = mMonthHeight + mWeekHeight +
               (mDayHeight - rect.height())/2 + rect.height() - 5;
       for(int i =0;i<monthDayCount;i++){
           if(mSwitchYear==mClickYear&&mSwitchMonth==mClickMonth
                   &&i+1==mClickDay){
               mDayPaint.setColor(Color.WHITE);
           }else {
               mDayPaint.setColor(Color.BLACK);
           }
           if(weekIndex!=0&&weekIndex%7==0){
               currentWidth = dayWidth/2;
               currentHeight = currentHeight + mDayHeight;
               canvas.drawText(i+1+"",currentWidth,currentHeight,mDayPaint);
               //绘制分割线
               float lineHeight = currentHeight - (mDayHeight - rect.height())/2 - rect.height() + 5;
               mLinePaint.setStrokeWidth((float) 0.5);
               canvas.drawLine(0,lineHeight,mWidth,lineHeight,mLinePaint);
           }else {
               currentWidth = currentWidth + dayWidth;
               canvas.drawText(i+1+"",currentWidth,currentHeight,mDayPaint);
           }
           weekIndex++;
       }

       //当切换到点击日期的年月份
       if(mClickYear == mSwitchYear &&
               mClickMonth == mSwitchMonth){
           //drawDayCircle(canvas);
       }
   }

整个绘制是通过一个for循环来完成的,当月的总天数为结束条件。需要注意的是当绘制完周六后要记得换行绘制。相对于month和week,day部分的绘制虽然麻烦了一些,但逻辑还是比较清晰的。

1.4 背景蓝色圈的绘制
在本例中,当前日期下会有一个圆的蓝色背景圈。绘制这个圈复杂的地方也就是计算圈的坐标,我们先将代码贴出来,结合代码进行叙述。

 //为当前日添加圆形背景
    private void drawDayCircle(Canvas canvas){

        //RectF rectF = new RectF(dayX,dayY-mDayHeight,dayX+mWidth/7,dayY);
        float dayY = 0,dayX = 0;//当前日的X、Y轴坐标
        if(mClickDay<=mFirstLineDayCount){
            //点击了第一行
            int clickDayCount = 7-(mFirstLineDayCount - mClickDay);
            dayX = (clickDayCount-1)*(mWidth/7);
            dayY = mMonthHeight + mWeekHeight + mDayHeight;
        } else {
            //点击日期相对于所在行的位置
            int clickDayCount = (mClickDay - mFirstLineDayCount - 1) % 7;
            //除去第一行跟最后一行的行数
            int centerColumnY = (mClickDay - mFirstLineDayCount - clickDayCount) / 7 + 2;
            Log.i("calendar", "centerColumnY=" + centerColumnY);
            dayX = clickDayCount * (mWidth / 7);
            dayY = mMonthHeight + mWeekHeight + centerColumnY * mDayHeight;

        }
        float radiusX = dayX+(mWidth/7)/2;
        float radiusY = dayY - mDayHeight/2;
        Log.i("calendar","x="+radiusX+"---y="+radiusY);
        float radius = mDayHeight/2 - 10;
        canvas.drawCircle(radiusX,radiusY,radius,mDayBgPaint);

    }

代码中我分了两部分,因为第一行的天数不固定比较特殊,所以单独拎出来进行处理。首先第一行(mClickDay这个变量是当前点击的是几号,这个变量是为了后面的触摸反馈服务的,现在我们还没说到触摸反馈,所以目前这个变量的值是当前day),判断当前day是否小于第一行的总天数,如果小于说明当前日期处于第一行,这个时候我们可以计算出当前日期的坐标。然后就是其余行,clickDayCount 代表的是当前日期x轴所处的位置,centerColumnY 代表的是除去第一行跟所在行剩余的行数,比如说当前日期处于第二行,除去第一行跟所在行剩余行为0,处于第三行剩余行为1以此类推,通过这种方式我们可以计算出当前day的x,y轴的坐标,然后就可以进行圆的绘制。

2 触摸反馈

一个日历只能进行显示当前日期是完全不够的,所以我们要增加浏览所有日期,并且可以选择任意日期,所以这个时候就要增加一个“发动机”触摸反馈。
老规矩,先贴代码:

    float startX = 0,startY = 0;
    @Override
    public boolean onTouchEvent(MotionEvent event) {

        float endX,endY;
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                startX = event.getX();
                startY = event.getY();
                return true;
            case MotionEvent.ACTION_MOVE:
                return true;
            case MotionEvent.ACTION_UP:
                endX = event.getX();
                endY = event.getY();
                clickEvent(startX,startY,endX,endY);
                return true;
        }
        return super.onTouchEvent(event);
    }
    //模拟的点击事件
    private void clickEvent(float startX,float startY,float endX,float endY){
        String data = getMonthStr(mCurrentDate);
        //计算标题文本的宽高
        Rect rect = new Rect();
        mMonthPaint.getTextBounds(data,0,data.length(),rect);
        float textStartX = mWidth/2 - rect.width()/2;//辩题文字的X轴起始坐标
        float arrowLeftStartX = textStartX - mTextSpec - (mArrowWidth+60);
        float arrowLeftEndX = textStartX - mTextSpec +60;
        float arrowLeftStartY = 10;
        float arrowLeftEndY = mMonthHeight-10;
        //当按下和抬起坐标都在箭头规定的范围内视为点击
        //点解了左箭头
        if(startX>arrowLeftStartX&&startX<arrowLeftEndX
                &&startY>arrowLeftStartY&&startY<arrowLeftEndY
            &&endX>arrowLeftStartX&&endX<arrowLeftEndX
                &&endY>arrowLeftStartY&&endY<arrowLeftEndY){
            subDate();
        }

        float arrowRightStartX = textStartX + rect.width() + mTextSpec - 60;
        float arrowRightEndX = textStartX + rect.width() + mTextSpec +mArrowWidth + 60;
        float arrowRightStartY = 10;
        float arrowRightEndY = mMonthHeight-10;
        //点解了右箭头
        if(startX>arrowRightStartX && startX<arrowRightEndX
                && startY>arrowRightStartY && startY<arrowRightEndY
                && endX>arrowRightStartX && endX<arrowRightEndX
                && endY>arrowRightStartY && endY<arrowRightEndY){
            addDate();
        }
        clickDate(startX,startY,endX,endY);
    }
 //通过坐标获取当前点击的日期
private void clickDate(float startX,float startY,float endX,float endY){
        int startLineX,startColumnY,endLineX,endColumnY;
        int clickStartDay = 0,clickEndDay = 0;
        //当点击周以下的位置,即日部分
        if(startY>mMonthHeight+mWeekHeight&&endY>mMonthHeight+mWeekHeight){
            //按下时的天数
            startLineX = (int) (startX/(mWidth/7)) +1;
            startColumnY = (int) ((startY-mMonthHeight-mWeekHeight)/mDayHeight) + 1;
            if(startColumnY==1){//点击第一行
                if(startLineX-1>=mFirstWeekIndex){
                    clickStartDay = startLineX - mFirstWeekIndex;
                }else {//点击了第一行空白处
                    return;
                }
            }else {
                //中间整行数*7 + 第一行天数 + 最后一行天数
                clickStartDay = (startColumnY-2)*7 + mFirstLineDayCount + startLineX;
                if(clickStartDay>mCurrentDayOfMonth){//大于当月总天数
                    return;
                }
            }

            //抬起时的天数
            endLineX = (int) (endX/(mWidth/7)) +1;
            endColumnY = (int) ((endY-mMonthHeight-mWeekHeight)/mDayHeight) + 1;
            if(endColumnY==1){//点击第一行
                if(endLineX-1>=mFirstWeekIndex){
                    clickEndDay = endLineX - mFirstWeekIndex;
                }else {//点击了第一行空白处
                    return;
                }
            }else {
                //中间整行数*7 + 第一行天数 + 最后一行天数
                clickEndDay = (endColumnY-2)*7 + mFirstLineDayCount + endLineX;
                if(clickStartDay>mCurrentDayOfMonth){//大于当月总天数
                    return;
                }
            }

            if(clickStartDay==clickEndDay){
                mClickYear = mSwitchYear;
                mClickMonth = mSwitchMonth;
                mClickDay = clickStartDay;
                invalidate();
                String date = mSwitchYear+"年"+mSwitchMonth+"月"+clickStartDay+"日";
                if(mDateCallBack!=null){
                    mDateCallBack.onClick(date);
                }
                Log.i("touch",date);
            }
        }
    }

首先我们要重写onTouchEvent()方法,clickEvent()方法的作用很简单,就是模拟一个点击事件,当down和up处于同一区域时认为事件有效。这里着重说一下clickDate()这个方法,这个方法的作用就是通过点击的坐标计算出点击的日期,最后将计算出的日期分别赋值给mClickYear 、mClickMonth、mClickDay 。我们在前面叙述绘制背景蓝色小圈的时候提到过mClickDay这个变量 ,没有注意到的同学请翻回到相关篇节了解,就不在此进行叙述了。当拿到mClickDay后就可以将点击的日期绘制成蓝色背景,然后通过接口DateCallBack进行日期的响应。
这里有一个细节的地方要处理,当当月第一天位于非周日即第一行的天数小于7时,点击第一天左边的位置不能响应,否则会出现0号、-1号等奇葩现象,最后一行同理。在clickDate()方法中我进行了相应的处理并加有注释。

然后我们再回到刚开始通过Path绘制的两个箭头,这两个箭头的作用相信不用我说大家都明白是啥意思吧- - ,两个箭头的点击事件的实现也是在clickEvent()这个方法中,这里就不再进行叙述。具体代码如下:

 //日期减月
    private void subDate(){

        Calendar calendar = Calendar.getInstance();
        calendar.setTime(mCurrentDate);
        calendar.add(Calendar.MONTH,-1);

        mCurrentDayOfMonth = calendar.getActualMaximum(Calendar.DAY_OF_MONTH);//获取当月总天数
        //周日索引为0
        calendar.set(Calendar.DATE,1);//将日期定位到当月第一天
        mFirstWeekIndex = calendar.get(Calendar.DAY_OF_WEEK)-1;//获取当月第一天星期的索引
        calendar.set(Calendar.DATE,mCurrentDayOfMonth);//将日期定位到当月最后一天
        mEndWeekIndex = calendar.get(Calendar.DAY_OF_WEEK)-1;//获取当月最后一天星期的索引
        mFirstLineDayCount = 7 - mFirstWeekIndex;//第一行天数
        mEndLine = mEndWeekIndex + 1;//最后一行天数

        mCurrentDate = calendar.getTime();

        mSwitchYear = calendar.get(Calendar.YEAR);//获取切换到的年
        mSwitchMonth= calendar.get(Calendar.MONTH) +1;//获取切换到的月
        mSwitchDay = calendar.get(Calendar.DAY_OF_MONTH);//获取切换到的日

        invalidate();
        Log.i("calendar","subDate---------------");

    }

    //日期加月
    private void addDate(){

        Calendar calendar = Calendar.getInstance();
        calendar.setTime(mCurrentDate);
        calendar.add(Calendar.MONTH,1);

        mCurrentDayOfMonth = calendar.getActualMaximum(Calendar.DAY_OF_MONTH);//获取当月总天数
        //周日索引为0
        calendar.set(Calendar.DATE,1);//将日期定位到当月第一天
        mFirstWeekIndex = calendar.get(Calendar.DAY_OF_WEEK)-1;//获取当月第一天星期的索引
        calendar.set(Calendar.DATE,mCurrentDayOfMonth);//将日期定位到当月最后一天
        mEndWeekIndex = calendar.get(Calendar.DAY_OF_WEEK)-1;//获取当月最后一天星期的索引
        mFirstLineDayCount = 7 - mFirstWeekIndex;//第一行天数
        mEndLine = mEndWeekIndex + 1;//最后一行天数

        mCurrentDate = calendar.getTime();

        mSwitchYear = calendar.get(Calendar.YEAR);//获取切换到的年
        mSwitchMonth= calendar.get(Calendar.MONTH) +1;//获取切换到的月
        mSwitchDay = calendar.get(Calendar.DAY_OF_MONTH);//获取切换到的日

        invalidate();
        Log.i("calendar","addDate---------------");
    }

我们可以从代码中看到,点击了左箭头月份会减1,这时重新计算当月天数和当月第一天的索引,然后执行invalidate()方法重新绘制,右箭头同理。至此整个自定义日历的全部过程我们已经叙述完毕。
Demo已托管至github:自定义日历

总结

整个View的绘制总共分为四部分,分别是:year和month、week、day、背景圈,这四部分比较麻烦的就是day和背景圈的绘制,不过这两部分绘制关键都是要获取到当月第一天的星期索引,获取到星期索引后一步一步来逻辑还是很清晰的,虽然麻烦但是不难。然后就是触摸反馈,通过触摸不同屏幕位置来触发不同的点击事件,在事件的响应中进行图像的重绘。

因为这是我第一次在网络上发表文章,所以从语言的组织到文章的结构肯定存在很大的问题,也希望老铁们多多包涵并指出问题所在,也让我们能够共同进步。好了,本篇文章至此结束,下一篇文章我将为老铁们叙述 :自定义View(二)自定义刻度尺

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