QWidget ベースのカスタムカレンダーコントロールの実装

1. はじめに

この記事では、2つの異なるアプローチで実装したカスタムカレンダーコントロールについて解説します。1つはQLabelを並べて日付を表現する方法、もう1つはQWidgetを継承して自前で描画する方法です。どちらにも長所・短所がありますが、ここでは後者の「自描画方式」に焦点を当て、その実装の詳細を紹介します。サンプルプログラムの実行イメージは以下の通りです。

カレンダーデモ

2. 方式の比較

項目QLabel 方式QWidget 自描画方式
長所実装が単純で直感的;各日付が独立したQLabelなので、特定の日付の強調表示(青丸など)が容易実装はやや複雑だが、描画やイベント処理の階層が少なく、メモリ効率が良い;ウィンドウのリサイズに強い;背景色のグラデーションが容易
短所日数分のQLabelインスタンスが必要で、パフォーマンスに影響;ウィンドウサイズ変更時の再配置が面倒;背景グラデーションが難しい毎回全体を再描画するため、各日付上に細かいコンテンツ(例:予定マーク)を描画する際の計算が複雑になりやすい

3. データ構造の設計

まず、1日分の情報を保持する構造体を定義します。元のコードでは tDayFlag という名前でしたが、今回は DayInfo とします。

struct DayInfo
{
    signed char monthFlag; // -1: 前月, 0: 今月, 1: 来月
    unsigned short dayNum; // 日付番号 (1〜31)
};

次に、カレンダーの内部データを管理する実装クラス CalendarPrivate を設計します。このクラスには、各日付の矩形領域やフラグの配列、各種計算関数を保持します。

class CalendarPrivate
{
public:
    CalendarPrivate(CalendarWidget *q) : q_ptr(q), hoverIndex(-1)
    {
        // 7列×6行の配列確保
        rects = new QRect[7 * 6];
        dayInfos = new DayInfo[7 * 6];
        // 現在日時を取得
        SYSTEMTIME st;
        GetLocalTime(&st);
        currentYear = st.wYear;
        currentMonth = st.wMonth;
        currentDay = st.wDay;
    }
    ~CalendarPrivate()
    {
        delete[] rects;
        delete[] dayInfos;
    }

    // 列の左端位置
    int columnLeft(int col) const
    {
        return (width - leftMargin - rightMargin + colSpacing) / columnCount * col + leftMargin;
    }
    // 列の右端位置
    int columnRight(int col) const
    {
        int itemW = (width - leftMargin - rightMargin - (columnCount - 1) * colSpacing) / columnCount;
        return columnLeft(col) + itemW;
    }
    // 行の上端位置
    int rowTop(int row) const
    {
        QFontMetrics fm(weekFont);
        int weekH = fm.height();
        return (height - topMargin - bottomMargin - weekH + rowSpacing) / rowCount * row
               + rowSpacing + weekH;
    }
    // 行の下端位置
    int rowBottom(int row) const
    {
        int weekH = QFontMetrics(weekFont).height();
        int itemH = (height - topMargin - bottomMargin - weekH - rowSpacing * (rowCount - 1)) / rowCount;
        return rowTop(row) + itemH;
    }

    // 指定された日付が本日と一致するか
    bool isToday(const DayInfo &d) const
    {
        return (d.dayNum == currentDay && d.monthFlag == 0);
    }

    // 日付フラグの再設定(月切り替え時)
    void resetDayInfos()
    {
        unsigned short prevY, prevM;
        getPrevMonth(prevY, prevM);
        int prevDays = daysInMonth(prevY, prevM);
        int curDays  = daysInMonth(currentYear, currentMonth);
        int weekDay  = firstDayOfWeek(currentYear, currentMonth); // 0=日曜

        int idx = 0;
        // 前月の日付
        for (int i = 0; i < weekDay; ++i, ++idx)
        {
            dayInfos[idx].monthFlag = -1;
            dayInfos[idx].dayNum = prevDays - weekDay + 1 + i;
        }
        // 今月の日付
        for (int i = 0; i < curDays; ++i, ++idx)
        {
            dayInfos[idx].monthFlag = 0;
            dayInfos[idx].dayNum = i + 1;
        }
        // 行数を再計算
        rowCount = idx / 7 + (idx % 7 == 0 ? 0 : 1);
        // 来月の日付
        for (int j = 1; idx < columnCount * rowCount; ++j, ++idx)
        {
            dayInfos[idx].monthFlag = 1;
            dayInfos[idx].dayNum = j;
        }
    }

    // 前月の年月を取得
    void getPrevMonth(unsigned short &y, unsigned short &m) const
    {
        if (currentMonth > 1) { y = currentYear; m = currentMonth - 1; }
        else { y = currentYear - 1; m = 12; }
    }
    // 来月の年月を取得
    void getNextMonth(unsigned short &y, unsigned short &m) const
    {
        if (currentMonth >= 12) { y = currentYear + 1; m = 1; }
        else { y = currentYear; m = currentMonth + 1; }
    }

    // メンバ変数
    CalendarWidget *q_ptr;
    int columnCount = 7;
    int rowCount = 6;
    int colSpacing = 5;
    int rowSpacing = 5;
    int leftMargin = 10;
    int rightMargin = 10;
    int topMargin = 5;
    int bottomMargin = 5;
    int width = 100;
    int height = 80;
    int rowSpaceBetweenWeekAndDays = 10; // 週ヘッダと日付の間隔

    QFont weekFont = QFont("微软雅黑", 14);
    QFont dayFont;

    unsigned short currentYear;
    unsigned short currentMonth;
    unsigned short currentDay;

    QRect *rects;
    DayInfo *dayInfos;
    short hoverIndex; // ホバー中のインデックス
};

4. 日付領域の生成

日付領域の矩形は、カレンダー全体のサイズや余白、間隔から毎回計算します。月が変わったときには resetDayInfos() を呼び、さらに rects 配列を再設定します。描画関数内で直接計算しても構いませんが、パフォーマンスを考慮しておきます。以下のコードは resetDayInfos の具体的な処理です(上記の実装と同じ)。

// 上記 resetDayInfos() の再掲
void resetDayInfos()
{
    unsigned short prevY, prevM;
    getPrevMonth(prevY, prevM);
    int prevDays = daysInMonth(prevY, prevM);
    int curDays  = daysInMonth(currentYear, currentMonth);
    int weekDay  = firstDayOfWeek(currentYear, currentMonth);

    int idx = 0;
    for (int i = 0; i < weekDay; ++i, ++idx)
    {
        dayInfos[idx].monthFlag = -1;
        dayInfos[idx].dayNum = prevDays - weekDay + 1 + i;
    }
    for (int i = 0; i < curDays; ++i, ++idx)
    {
        dayInfos[idx].monthFlag = 0;
        dayInfos[idx].dayNum = i + 1;
    }
    rowCount = idx / 7 + (idx % 7 == 0 ? 0 : 1);
    for (int j = 1; idx < columnCount * rowCount; ++j, ++idx)
    {
        dayInfos[idx].monthFlag = 1;
        dayInfos[idx].dayNum = j;
    }
}

5. クリック位置の判定

ユーザーがクリックした点がどの日付に対応するかを高速に求めるには、事前に全矩形を配列に格納しておき、単純なループで包含判定を行います。

int CalendarWidget::dayIndexAt(const QPoint &pos)
{
    for (int i = 0; i < d_ptr->rowCount * d_ptr->columnCount; ++i)
    {
        if (d_ptr->rects[i].contains(pos))
            return i;
    }
    return -1;
}

6. 週ヘッダの描画

void CalendarWidget::drawWeekHeader(QPainter &painter)
{
    const QString weekNames[] = {"周日", "周一", "周二", "周三", "周四", "周五", "周六"};
    painter.save();
    painter.setFont(d_ptr->weekFont);
    QFontMetrics fm(d_ptr->weekFont);
    int h = fm.height();
    for (int i = 0; i < 7; ++i)
    {
        int left = d_ptr->columnLeft(i);
        int right = d_ptr->columnRight(i);
        QRect rect(left, d_ptr->topMargin, right - left, h);
        painter.drawRect(rect);
        painter.drawText(rect, Qt::AlignCenter, weekNames[i]);
    }
    painter.restore();
}

7. 日付の描画

日付部分の描画では、各セルの矩形を計算しながら、ホバー時や前月・来月の文字色を変えます。

void CalendarWidget::drawDays(QPainter &painter)
{
    d_ptr->resetDayInfos();
    painter.save();

    QFontMetrics fm(d_ptr->weekFont);
    int weekH = fm.height();

    for (int col = 0; col < d_ptr->columnCount; ++col)
    {
        int colLeft = d_ptr->columnLeft(col);
        int colRight = d_ptr->columnRight(col);
        for (int row = 0; row < d_ptr->rowCount; ++row)
        {
            int idx = row * d_ptr->columnCount + col;
            QRect &r = d_ptr->rects[idx];
            r.setLeft(colLeft);
            r.setRight(colRight);
            r.setTop(d_ptr->rowTop(row));
            r.setBottom(d_ptr->rowBottom(row));

            QPainterPath path;
            path.addRoundRect(r, 25);

            // ホバー時の背景
            if (idx == d_ptr->hoverIndex)
                painter.fillPath(path, QColor(144, 151, 151));

            painter.save();
            const DayInfo &info = d_ptr->dayInfos[idx];
            if (info.monthFlag == -1 || info.monthFlag == 1)
                painter.setPen(Qt::blue);
            else
            {
                if (d_ptr->isToday(info))
                    painter.setPen(Qt::red);
                else if (idx == d_ptr->hoverIndex)
                    painter.setPen(Qt::white);
                // else はデフォルト色
            }
            painter.setOpacity(0.5);
            painter.drawText(r, Qt::AlignCenter, QString::number(info.dayNum));
            painter.restore();

            painter.drawPath(path);
        }
    }
    painter.restore();
}

8. 月の切り替え

前月・来月ボタンなどのクリックに応じて、内部の年月を更新し再描画します。

void CalendarWidget::goPreviousMonth()
{
    unsigned short y, m;
    d_ptr->getPrevMonth(y, m);
    int actualDays = daysInMonth(y, m);
    if (actualDays < d_ptr->currentDay)
        d_ptr->currentDay = actualDays;
    setCurrentDate(y, m, d_ptr->currentDay);
    update();
}

void CalendarWidget::goNextMonth()
{
    unsigned short y, m;
    d_ptr->getNextMonth(y, m);
    int actualDays = daysInMonth(y, m);
    if (actualDays < d_ptr->currentDay)
        d_ptr->currentDay = actualDays;
    setCurrentDate(y, m, d_ptr->currentDay);
    update();
}

9. マウスクリック時の処理

クリックされた日付がどの月に属するかを判定し、必要に応じて月を切り替えてから日付を設定します。

void CalendarWidget::mousePressEvent(QMouseEvent *event)
{
    if (event->button() != Qt::LeftButton) return;
    int idx = dayIndexAt(event->pos());
    if (idx < 0) return;
    const DayInfo &info = d_ptr->dayInfos[idx];
    unsigned short y = d_ptr->currentYear, m = d_ptr->currentMonth;
    if (info.monthFlag == -1)
        d_ptr->getPrevMonth(y, m);
    else if (info.monthFlag == 1)
        d_ptr->getNextMonth(y, m);
    // 日付が変化した場合のみ更新
    if (info.dayNum != d_ptr->currentDay || m != d_ptr->currentMonth || y != d_ptr->currentYear)
    {
        d_ptr->currentDay = info.dayNum;
        d_ptr->currentMonth = m;
        d_ptr->currentYear = y;
        update();
    }
}

10. マウスホバー処理

ホバー中の日付だけを再描画することで効率化します。

void CalendarWidget::mouseMoveEvent(QMouseEvent *event)
{
    int newIdx = dayIndexAt(event->pos());
    if (newIdx != d_ptr->hoverIndex)
    {
        int oldIdx = d_ptr->hoverIndex;
        d_ptr->hoverIndex = newIdx;
        // 該当セルのみ更新
        if (oldIdx >= 0) update(d_ptr->rects[oldIdx]);
        if (newIdx >= 0) update(d_ptr->rects[newIdx]);
    }
    QWidget::mouseMoveEvent(event);
}

11. アニメーションの実装について

本記事の自描画方式では、月の切り替えアニメーションは実装していませんが、QLabel方式のサンプルではアニメーションを導入しています。自描画方式でも同様のアニメーションは実現可能です。たとえば、スライドイン・スライドアウト効果を加えるには、月の切り替え時に一時的にオフスクリーン画像を描画し、QPropertyAnimationで位置を変化させながら現在のウィジェットに合成する方法が考えられます。興味があれば、音楽プレイヤーのカバーフローに似たカルーセル機構を採用することも可能です。

次回の記事では、QLabel方式の詳細と両方式の使い分けについて掘り下げる予定です。

タグ: Qt QWidget カスタムカレンダー 自描画 日付計算

5月28日 11:06 投稿