android自定义控件之日历控件的实现

为什么要自定义控件

有时,原生控件不能满足我们对于外观和功能的需求,这时候可以自定义控件来定制外观或功能;有时,原生控件可以通过复杂的编码实现想要的功能,这时候可以自定义控件来提高代码的可复用性。

如何自定义控件

下面我通过我在github上开源的Android-CalendarView项目为例,来介绍一下自定义控件的方法。该项目中自定义的控件类名是CalendarView。这个自定义控件覆盖了一些自定义控件时常需要重写的一些方法。

构造函数

为了支持本控件既能使用xml布局文件声明,也可在java文件中动态创建,实现了三个构造函数。

1
2
3
public CalendarView(Context context, AttributeSet attrs, int defStyle);
public CalendarView(Context context, AttributeSet attrs);
public CalendarView(Context context);

可以在参数列表最长的第一个方法中写上你的初始化代码,下面两个构造函数调用第一个即可。

1
2
3
4
5
6
public CalendarView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public CalendarView(Context context) {
this(context, null);
}

那么在构造函数中做了哪些事情呢?

1 读取自定义参数

读取布局文件中可能设置的自定义属性(该日历控件仅自定义了一个mode参数来表示日历的模式)。代码如下。只要在attrs.xml中自定义了属性,就会自动创建一些R.styleable下的变量。

1
2
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CalendarView);
mode = typedArray.getInt(R.styleable.CalendarView_mode, Constant.MODE_SHOW_DATA_OF_THIS_MONTH);

然后附上res目录下values目录下的attrs.xml文件,需要在此文件中声明你自定义控件的自定义参数。

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="CalendarView">
<attr name="mode" format="integer" />
</declare-styleable>
</resources>

2 初始化关于绘制控件的相关参数

如字体的颜色、尺寸,控件各个部分尺寸。

3 初始化关于逻辑的相关参数

对于日历来说,需要能够判断对应于当前的年月,日历中的每个单元格是否合法,以及若合法,其表示的day的值是多少。未设定年月之前先用当前时间来初始化。实现如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* calculate the values of date[] and the legal range of index of date[]
*/

private void initial() {
int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
int monthStart = -1;
if(dayOfWeek >= 2 && dayOfWeek <= 7){
monthStart = dayOfWeek - 2;
}else if(dayOfWeek == 1){
monthStart = 6;
}
curStartIndex = monthStart;
date[monthStart] = 1;
int daysOfMonth = daysOfCurrentMonth();
for (int i = 1; i < daysOfMonth; i++) {
date[monthStart + i] = i + 1;
}
curEndIndex = monthStart + daysOfMonth;
if(mode == Constant.MODE_SHOW_DATA_OF_THIS_MONTH){
Calendar tmp = Calendar.getInstance();
todayIndex = tmp.get(Calendar.DAY_OF_MONTH) + monthStart - 1;
}
}

其中date[]是一个整型数组,长度为42,因为一个日历最多需要6行来显示(6*7=42),
curStartIndex和curEndIndex决定了date[]数组的合法下标区间,即前者表示该月的第一天在date[]数组的下标,后者表示该月的最后一天在date[]数组的下标。

4 绑定了一个OnTouchListener监听器

监听控件的触摸事件。

onMeasure方法

该方法对控件的宽和高进行测量。CalendarView覆盖了View类的onMeasure()方法,因为某个月的第一天可能是星期一到星期日的任何一个,而且每个月的天数不尽相同,因此日历控件的行数会有多变化,也导致控件的高度会有变化。因此需要根据当前的年月计算控件显示的高度(宽度设为屏幕宽度即可)。实现如下。

1
2
3
4
5
6
7
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(screenWidth, View.MeasureSpec.EXACTLY);
heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(measureHeight(), View.MeasureSpec.EXACTLY);
setMeasuredDimension(widthMeasureSpec, heightMeasureSpec);
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

其中screenWidth是构造函数中已经获取的屏幕宽度,measureHeight()则是根据年月计算控件所需要的高度。实现如下,已经写了非常详细的注释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* calculate the total height of the widget
*/

private int measureHeight(){
/**
* the weekday of the first day of the month, Sunday's result is 1 and Monday 2 and Saturday 7, etc.
*/

int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
/**
* the number of days of current month
*/

int daysOfMonth = daysOfCurrentMonth();
/**
* calculate the total lines, which equals to 1 (head of the calendar) + 1 (the first line) + n/7 + (n%7==0?0:1)
* and n means numberOfDaysExceptFirstLine
*/

int numberOfDaysExceptFirstLine = -1;
if(dayOfWeek >= 2 && dayOfWeek <= 7){
numberOfDaysExceptFirstLine = daysOfMonth - (8 - dayOfWeek + 1);
}else if(dayOfWeek == 1){
numberOfDaysExceptFirstLine = daysOfMonth - 1;
}
int lines = 2 + numberOfDaysExceptFirstLine / 7 + (numberOfDaysExceptFirstLine % 7 == 0 ? 0 : 1);
return (int) (cellHeight * lines);
}

onDraw方法

该方法实现对控件的绘制。其中drawCircle给定圆心和半径绘制圆,drawText是给定一个坐标x,y绘制文字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
* render
*/

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
/**
* render the head
*/

float baseline = RenderUtil.getBaseline(0, cellHeight, weekTextPaint);
for (int i = 0; i < 7; i++) {
float weekTextX = RenderUtil.getStartX(cellWidth * i + cellWidth * 0.5f, weekTextPaint, weekText[i]);
canvas.drawText(weekText[i], weekTextX, baseline, weekTextPaint);
}
if(mode == Constant.MODE_CALENDAR){
for (int i = curStartIndex; i < curEndIndex; i++) {
drawText(canvas, i, textPaint, "" + date[i]);
}
}else if(mode == Constant.MODE_SHOW_DATA_OF_THIS_MONTH){
for (int i = curStartIndex; i < curEndIndex; i++) {
if(i < todayIndex){
if(data[date[i]]){
drawCircle(canvas, i, bluePaint, cellHeight * 0.37f);
drawCircle(canvas, i, whitePaint, cellHeight * 0.31f);
drawCircle(canvas, i, blackPaint, cellHeight * 0.1f);
}else{
drawCircle(canvas, i, grayPaint, cellHeight * 0.1f);
}
}else if(i == todayIndex){
if(data[date[i]]){
drawCircle(canvas, i, bluePaint, cellHeight * 0.37f);
drawCircle(canvas, i, whitePaint, cellHeight * 0.31f);
drawCircle(canvas, i, blackPaint, cellHeight * 0.1f);
}else{
drawCircle(canvas, i, grayPaint, cellHeight * 0.37f);
drawCircle(canvas, i, whitePaint, cellHeight * 0.31f);
drawCircle(canvas, i, blackPaint, cellHeight * 0.1f);
}
}else{
drawText(canvas, i, textPaint, "" + date[i]);
}
}
}
}

需要说明的是,绘制文字时的这个x表示开始位置的x坐标(文字最左端),这个y却不是文字最顶端的y坐标,而应传入文字的baseline。因此若想要将文字绘制在某个区域居中部分,需要经过一番计算。本项目将其封装在了RenderUtil类中。实现如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* get the baseline to draw between top and bottom in the middle
*/

public static float getBaseline(float top, float bottom, Paint paint){
Paint.FontMetrics fontMetrics = paint.getFontMetrics();
return (top + bottom - fontMetrics.bottom - fontMetrics.top) / 2;
}
/**
* get the x position to draw around the middle
*/

public static float getStartX(float middle, Paint paint, String text){
return middle - paint.measureText(text) * 0.5f;
}

自定义监听器

控件需要自定义一些监听器,以在控件发生了某种行为或交互时提供一个外部接口来处理一些事情。本项目的CalendarView提供了两个接口,OnRefreshListener和OnItemClickListener,均为自定义的接口。onItemClick只传了day一个参数,年和月可通过CalendarView对象的getYear和getMonth方法获取。

1
2
3
4
5
6
interface OnItemClickListener{
void onItemClick(int day);
}
interface OnRefreshListener{
void onRefresh();
}

先介绍一下两种mode,CalendarView提供了两种模式,第一种普通日历模式,日历每个位置简单显示了day这个数字,第二种本月计划完成情况模式,绘制了一些图形来表示本月的某一天是否完成了计划(模仿自悦跑圈,用一个圈表示本日跑了步)。
OnRefreshListener用于刷新日历数据后进行回调。两种模式定义了不同的刷新方法,都对OnRefreshListener进行了回调。refresh0用于第一种模式,refresh1用于第二种模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/**
* used for MODE_CALENDAR
* legal values of month: 1-12
*/

@Override
public void refresh0(int year, int month) {
if(mode == Constant.MODE_CALENDAR){
selectedYear = year;
selectedMonth = month;
calendar.set(Calendar.YEAR, selectedYear);
calendar.set(Calendar.MONTH, selectedMonth - 1);
calendar.set(Calendar.DAY_OF_MONTH, 1);
initial();
invalidate();
if(onRefreshListener != null){
onRefreshListener.onRefresh();
}
}
}

/**
* used for MODE_SHOW_DATA_OF_THIS_MONTH
* the index 1 to 31(big month), 1 to 30(small month), 1 - 28(Feb of normal year), 1 - 29(Feb of leap year)
* is better to be accessible in the parameter data, illegal indexes will be ignored with default false value
*/

@Override
public void refresh1(boolean[] data) {
/**
* the month and year may change (eg. Jan 31st becomes Feb 1st after refreshing)
*/

if(mode == Constant.MODE_SHOW_DATA_OF_THIS_MONTH){
calendar = Calendar.getInstance();
selectedYear = calendar.get(Calendar.YEAR);
selectedMonth = calendar.get(Calendar.MONTH) + 1;
calendar.set(Calendar.DAY_OF_MONTH, 1);
for(int i = 1; i <= daysOfCurrentMonth(); i++){
if(i < data.length){
this.data[i] = data[i];
}else{
this.data[i] = false;
}
}
initial();
invalidate();
if(onRefreshListener != null){
onRefreshListener.onRefresh();
}
}
}

OnItemClickListener用于响应点击了日历上的某一天这个事件。点击的判断在onTouch方法中实现。实现如下。在同一位置依次接收到ACTION_DOWNACTION_UP两个事件才认为完成了点击。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Override
public boolean onTouch(View v, MotionEvent event) {
float x = event.getX();
float y = event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if(coordIsCalendarCell(y)){
int index = getIndexByCoordinate(x, y);
if(isLegalIndex(index)) {
actionDownIndex = index;
}
}
break;
case MotionEvent.ACTION_UP:
if(coordIsCalendarCell(y)){
int actionUpIndex = getIndexByCoordinate(x, y);
if(isLegalIndex(actionUpIndex)){
if(actionDownIndex == actionUpIndex){
actionDownIndex = -1;
int day = date[actionUpIndex];
if(onItemClickListener != null){
onItemClickListener.onItemClick(day);
}
}
}
}
break;
}
return true;
}

关于该日历控件

日历控件demo效果图如下,分别为普通日历模式和本月计划完成情况模式。

需要说明的是CalendarView控件部分只包括日历头与下面的日历,该控件上方的是其他控件,这里仅用作展示一种使用方法,你完全可以自定义这部分的样式。
此外,日历头的文字支持多种选择,比如周一有四种表示:一、周一、星期一、Mon。此外还有其他一些控制样式的接口,详情见github项目主页。

github项目主页: Android-CalendarView