应项目需求,需要做一个日历控件,效果图如下:
接到需求后,没有立即查找是否有相关开源日历控件可用、系统日历控件是否能满足 ,第一反应就是这个控件该怎么画?谁叫咱自定义控件技术牛逼呢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、自定义属性
我们看看本控件都定义了那些属性:
<?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); } @Overrideprotected 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()
@Overrideprotected void onDraw(Canvas canvas) { drawMonth(canvas); drawWeek(canvas); drawDayAndPre(canvas); }1、绘制月份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()); }2、绘制星期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); } }3、绘制日期及任务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; }
本篇博客讲解没有特别细致,但是关键的思路已经很清晰了,其实自定义控件也就那么会事儿,在之前自定义控件系列博客及案例中已经讲解的非常详细了。
共同学习,写下你的评论
评论加载中...
作者其他优质文章