前言

时间在现代人的生活中占有重要地位,这也是为什么各种系统都会自带日历和时钟控件。Android当中也提供了日历控件,但是各种嵌入在应用程序中的日历控件要提供的功能显然比系统控件要求高的多,这种情况下只能靠程序员手动开发自己的日历控件,现在来简单的实现一下。

实现效果

android 自定义日历组件 安卓自定义日历控件_ide

展示日期控件

展示日期控件第一行展示星期几,下面的6行展示选中月份的每一天,第一行里空白的地方展示上一个月最后几天,最后面的空白行展示下个月的前几天。为什么要用6行展示日期呢,考虑一个31天的月份,第一天是周日,那么这个月就会横跨6个星期,考虑到这种极端情况同时避免其他只有4或5个星期跨度月份导致日历大小改变,整个日期就展示6行数据。

日历的数据从何而来,Java中有一个Calendar工具类,能够提供各种需要的日期操作。公历的每个月对应天数基本上是固定的,除了2月份需要考虑是闰年还是平年,代码实现如下:

private static final int[] MONTH_DAYS = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };

// 判断平年还是闰年
private boolean isLeaf(int year) {
    return year % 100 == 0 && year % 400 == 0 || year % 4 == 0;
}

确定每月的天数接下来要确定每月的第一天到底是星期几,Calendar类已经完成了这个计算过程,实现代码如下:

int year = calendar.get(Calendar.YEAR);
// 确定每月天数
MONTH_DAYS[1] = isLeaf(year) ? 29 : 28;

int month = calendar.get(Calendar.MONTH);
int date = calendar.get(Calendar.DATE);
tmpCalendar.set(year, month, 1, 1, 1);

// 获取当前月份的第一天星期几
int day = tmpCalendar.get(Calendar.DAY_OF_WEEK);
tmpCalendar.add(Calendar.DATE, -1);

// 获取前一个月是哪个月
int lastMonth = tmpCalendar.get(Calendar.MONTH);

这里需要注意的是星期的取值范围是1 -> 7,其中1代表周日,2~7代表周一到周六,所以周日需要特别处理。月份的取值范围是0 -> 11正好对应着月份天数数组的索引值。考虑到用TextView控件来实现每个日期展示比较消耗内存,这里采用绘制的方式实现日期的展示。
首先是绘制外部的边框线,然后绘制顶部的周一到周日中文描述,接下来绘制上个月的最后几天,跟着绘制本月的所有日期,最后绘制下个月的开始几天。

// 绘制边框线
private void drawGrid(Canvas canvas) {
    int width = getMeasuredWidth(), height = getMeasuredHeight();
    canvas.drawLine(getPaddingLeft(), getPaddingTop(), width - getPaddingRight(), getPaddingTop(), paint);
    for (int i = 1; i < ROW + 1; i++) {
        int y = getPaddingTop() + dayCellHeight + (i - 1) * cellHeight;
        if (i == 1 || i == ROW) {
            canvas.drawLine(getPaddingLeft(), y, width - getPaddingRight(), y, paint);
        }
    }
}

// 绘制星期中文描述
private void drawDay(Canvas canvas) {
    String[] days = getResources().getStringArray(R.array.weekdays);
    int start = getPaddingLeft();
    for (int i = 0; i < COLUMN; i++) {
        paint.setTextSize(CommonUtils.dp2px(13));
        paint.setColor(textColor);
        int textWidth = (int) paint.measureText(days[i]);
        // 单元格中间点的位置
        int x = (start + (start + cellWidth)) / 2 - textWidth / 2;
        start += cellWidth;
        int y = getPaddingTop() + dayCellHeight - (dayCellHeight - CommonUtils.dp2px(13)) / 2;
        canvas.drawText(days[i], x, y, paint);
    }
}

// 绘制上个月最后几天
// day 1 - 7 1 -> 周日  2 -> 7 周一 -> 周六
// index代表当前绘制到第几个单元格
int index = 0;
int days = MONTH_DAYS[lastMonth];
if (day >= 2) { // 本月第一天是周一到周六的某天
    for (int i = 0; i < day - 2; i++, index++) {
        paint.setColor(getResources().getColor(R.color.gray));
        drawCell(canvas, index, days - day + i + 3);
    }
} else { // 本月第一天是周日
    for (int i = 0; i < 6; i++, index++) {
        paint.setColor(getResources().getColor(R.color.gray));
        drawCell(canvas, index, days - 5 + i);
    }
}

// 绘制本月数据
days = MONTH_DAYS[month];
for (int i = 0; i < days; i++, index++) {
    if (date == i + 1) {
        paint.setColor(todayColor);
    } else {
        paint.setColor(textColor);
    }
    drawCell(canvas, index, i + 1);
}

// 绘制下月头几天
int cellCount = ROW * COLUMN;
for (int i = index ; i < cellCount; i++) {
    paint.setColor(getResources().getColor(R.color.gray));
    drawCell(canvas, i, i - index + 1);
}

// 实际的绘制操作
private void drawCell(Canvas canvas, int i, int date) {
    int row = i / COLUMN, column = i % COLUMN;
    paint.setTextSize(CommonUtils.dp2px(13));
    int textWidth = (int) paint.measureText(String.valueOf(date));
    // 单元格中间点的位置
    int x = getPaddingLeft() + column * cellWidth + cellWidth / 2 - textWidth / 2;
    int y = getPaddingTop() + (row + 1) * cellHeight + dayCellHeight - (dayCellHeight - CommonUtils.dp2px(13)) / 2;
    canvas.drawText(String.valueOf(date), x, y, paint);
}

除了上面的绘制基本操作,日期控件还能够根据用户设置的时间来展示不同的月份,添加如下接口:

// 设置年份
public void setYear(int year) {
    calendar.set(Calendar.YEAR, year);
    invalidate();
}

// 设置月份
public void setMonth(int month) {
    calendar.set(Calendar.MONTH, month);
    invalidate();
}

public int getYear() {
    return calendar.get(Calendar.YEAR);
}

public int getMonth() {
    return calendar.get(Calendar.MONTH);
}

日历控件

前面的展示日期控件只是完成了展示某个月的所有日期,现在需要实现日历控件的外部布局,上面的切换月份和展示当前月份的部件,下方的展示日期控件能够随着用户的切换而自动做月份切换。开始考虑使用ViewPager来实现,但是日期实际上是没有大小限制的,如果用户不停的翻页就会导致分配大量内存,系统不停的做回收释放操作。这里考虑使用FrameLayout+ObjectAnimator属性动画来实现切换效果。

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <LinearLayout
        android:orientation="horizontal"
        android:paddingTop="10dp"
        android:paddingRight="30dp"
        android:paddingLeft="30dp"
        android:gravity="center_vertical"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <ImageView
            android:id="@+id/back"
            android:src="@drawable/ic_arrow_back_black_24dp"
            android:layout_width="20dp"
            android:layout_height="20dp" />

        <LinearLayout
            android:layout_width="0dp"
            android:layout_weight="1"
            android:gravity="center_horizontal"
            android:layout_height="wrap_content">
            <TextView
                android:id="@+id/year"
                tools:text="2018年"
                android:padding="5dp"
                android:textColor="@color/black"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content" />

            <TextView
                android:id="@+id/month"
                android:padding="5dp"
                tools:text="2月"
                android:textColor="@color/black"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content" />
        </LinearLayout>

        <ImageView
            android:id="@+id/forward"
            android:src="@drawable/ic_arrow_forward_black_24dp"
            android:layout_width="20dp"
            android:layout_height="20dp" />
    </LinearLayout>

    <FrameLayout
        android:id="@+id/frame_layout"
        android:layout_gravity="center_horizontal"
        android:padding="10dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

    </FrameLayout>
</LinearLayout>

日期的横向切换实际上可以看成是改变它的translationX属性,可以在日历控件里包含两个日期控件,一个展示一个隐藏,在用户切换月份的时候,把隐藏控件可见并且设置它的日期为前/后一个月,然后同时对这两个控件做属性动画,最后再交换它们的引用。实现代码如下:

// 向前切换
back.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) {
        calendar.add(Calendar.MONTH, -1);
        hideCalendarView.setYear(calendar.get(Calendar.YEAR));
        hideCalendarView.setMonth(calendar.get(Calendar.MONTH));
        hideCalendarView.setVisibility(View.VISIBLE);

        // 隐藏月份展示动画
        ObjectAnimator showAnimator = ObjectAnimator.ofFloat(hideCalendarView,
                "translationX", -calendarView.getWidth(), 0);
        // 展示月份隐藏动画
        final ObjectAnimator hideAnimator = ObjectAnimator.ofFloat(calendarView,
                "translationX", 0, calendarView.getWidth());
        AnimatorSet animatorSet = new AnimatorSet();
        animatorSet.play(showAnimator).with(hideAnimator);
        animatorSet.setDuration(300);
        animatorSet.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                // 动画结束之后交换引用并且隐藏旧的月份
                CalendarView tmp = hideCalendarView;
                hideCalendarView = calendarView;
                calendarView = tmp;
                hideCalendarView.setVisibility(View.INVISIBLE);
            }
        });
        animatorSet.start();
        initText();
    }
});

上面的实现只是点击按钮时的切换效果,用户还可以滑动地下的日期切换月份,这就需要对日历控件的onTouchEvent触摸事件做分析:

@Override
public boolean onTouchEvent(MotionEvent event) {
    int x = (int) event.getY();
    tracker.addMovement(event);
    switch (event.getActionMasked()) {
        case MotionEvent.ACTION_DOWN:
            mLastX = mDownX = x;
            break;
        case MotionEvent.ACTION_MOVE:
            if (!mIsScroll && Math.abs(x - mDownX) > mTouchSlop) {
                mIsScroll = true;
            }
            mLastX = x;
            break;
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            if (mIsScroll) {
                tracker.computeCurrentVelocity(1000);
                if (Math.abs(tracker.getXVelocity()) > 50) {
                    // 如果是向右滑动,那么展示前一个月
                    if (tracker.getXVelocity() > 0) {
                        back.performClick();
                    } else { // 向左滑动展示后一个月
                        forward.performClick();
                    }
                }
            }
            mIsScroll = false;
            break;
    }
    return true;
}