1. 程式人生 > >自定義簡單日曆控制元件

自定義簡單日曆控制元件

前言

時間在現代人的生活中佔有重要地位,這也是為什麼各種系統都會自帶日曆和時鐘控制元件。Android當中也提供了日曆控制元件,但是各種嵌入在應用程式中的日曆控制元件要提供的功能顯然比系統控制元件要求高的多,這種情況下只能靠程式設計師手動開發自己的日曆控制元件,現在來簡單的實現一下。

實現效果

這裡寫圖片描述

展示日期控制元件

展示日期控制元件第一行展示星期幾,下面的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;
}

以上就是簡單的日曆控制元件實現,檢視原始碼