第一次在网络上写文章,想想也是有些小兴奋- -。。。其实写文章这个念头在我心里已经萌生很久了,但是因为种种原因就一直没有开始行动(其实还是懒- -)。之所以选择简书是因为它的界面看起来挺简洁的,毕竟帅气如我,逼格还是要追求一下了(一不小心又装了一波- -)。
好了,回归正题,今天就写一篇关于自定义View的文章,因为这是我的第一篇文章,所以就选了一个难度适中的例子(太难了我怕把自己写晕- -),自定义日历,文章中我会对每一个功能进行详细的描述。废话不多说,先看效果图。
乍一看也没什么突出的啊,但是.....确实没啥突出的啊,简直被原生的完爆。。哈哈,机智如我怎么可能花费两三天时间做一件没有意义的事情呢。其实很多同学可能都遇到过,部分手机用原生的Calendar在5.0前后显示的效果是不一样的,5.0之后效果还是挺好的,但是5.0之前将CalendarView加载到Dialog中的效果是这样的。
其实这种不和谐的现象应该是可以解决的,但没办法,我会自定义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(二)自定义刻度尺