以前写过一篇Qt通过重写 QScrollBar
实现一个滚动屏的数字显示控件,片面了的用了这个东西实现了一个时间滚动选择器。
后来又尝试了结合Qt自带的 QCanlendarWidget
实现了一个简单的DateTime控件,这个控件可以用,不过就是存在一些比较麻烦的样式表设计,并且,人总是会有一个通病,总是想着怎样能够完全地实现一个不用其他控件就能正常使用的控件。
最近尝试了下,使用了Qt的基础控件 QPushButton
实现了一个完全自定义的日历控件,顺便改良了一下以前的时间按选择器,测试效果还可以。
初始界面如下:
打开之后:
完成之后:
这个控件主要分三部分:
- 第一部分是前面已经说过的滚动屏实现的时间选择器
- 第二部分是如何使用
QComboBox
定制使用,将QComboBox
自定义为一个日期编辑器 - 第三部分是我们今天的重点,如何自定义实现一个日历控件
首先,第一部分,我们至少已经在前面提到过两次了,如果有什么不明白的可以选择往前翻一翻。
其次,第二部分其实前面也有说过的,并且针对 QComboBox 的自定义专门写了两篇文章,《QComboBox自定义(一)–类似QQ登陆界面的下拉框》和《QComboBox自定义(二)–下拉列表定制为表格》。
我们这儿的话是用了第二篇的部分,将下来列表自定义为了一个单行单列的表格,通过 QTableWidget
的 setCellWidget
方法将 DateTimeWidget
放置。并且可通过 QComboBox
的 hidePopup()
方法来控制下来列表的隐藏。
代码比较简单,只是在做构造的时候进行上述的操作就行。
void DateTimeEdit::initPage()
{
auto table = new QTableWidget;
table->setMinimumHeight(592);
table->verticalHeader()->setVisible(false);
table->horizontalHeader()->setVisible(false);
table->setColumnCount(1);
table->setRowCount(1);
auto cell = new DateTimeWidget;
cell->setMinimumHeight(592);
table->setCellWidget(0, 0, cell);
this->setModel(table->model());
this->setView(table);
this->setEditable(true);
this->setProperty("type", "datetime");
connect(cell, static_cast<void(DateTimeWidget::*)(const QDateTime&)>(&DateTimeWidget::signal_dateTime), this, [this](const QDateTime& dt)
{
this->setEditText(dt.toString("yyyy-MM-dd hh:mm:ss"));
this->hidePopup();
});
connect(cell, &DateTimeWidget::signal_cancel, this, [this]
{
hidePopup();
});
}
说是这么说,但是为了方便别人使用控件的时候方便调用,可以添加一些属性方法,用以设置和获取时间。
void DateTimeEdit::setDateTime(const QDateTime& dt)
{
this->setEditText(dt.toString("yyyy-MM-dd hh:mm:ss"));
/*下面的这部分其实没什么作用,因为每次下拉框打开的时候都会按照当前的 currentDateTime 进行重新赋值,所以不用也罢*/
auto pTable = static_cast<QTableWidget*>(const_cast<QAbstractItemView*>(view()));
auto pWidget = static_cast<DateTimeWidget*>(pTable->cellWidget(0, 0));
pWidget->setDateTime(dt);
}
QDateTime DateTimeEdit::datetime() const
{
return QDateTime::fromString(this->currentText(), "yyyy-MM-dd hh:mm:ss");
}
这么来说,我们针对时间编辑器的设定已经搞定了。 如果有什么不明白的,也可以往前翻翻,看看前面的文章。
第三部分才是我们的重头戏,以前我也想过手撕一个日历控件,想一想要不停地控制42个按钮或者label的显示状态,还要根据真实的日期进行切换和变更。如果好一点的话,还有年的选择。真是令人头大。所以就放弃了。
这次刚开始也是这样,想着反正这个东西后面是给Qt用的,那何不使用一些基础的Qt控件,比如 QPushbutton
,比如 QDateTime
,这样至少能省好一些力。毕竟偷懒才是我的指引。
看看类定义,很简单。
class DateTimeWidget : public QWidget
{
Q_OBJECT
public:
explicit DateTimeWidget(QWidget* parent = nullptr);
~DateTimeWidget();
void setDateTime(const QDateTime& dt);
protected:
void showEvent(QShowEvent *event) override;
private:
void initPage();
void updateDays(int current);
signals:
void signal_cancel();
void signal_dateTime(const QDateTime&);
void signal_dateTime(const QDate&, const QTime&);
private:
Ui::DateTimeWidget* ui;
QVariantList m_dayList{ };
QVariantList m_yearList{ };
};
总共就这么几个函数, setDateTime
这个方法并没什么实际的用处,这个类包含了两个私有函数,一个继承的 protected
函数,三个信号。下面的两个信号参数的类型不同是为了方便使用,并没有什么特殊的意义。
两个 QVariantList
成员变量, 使用 QVariantList
类型的主要原因是,我想用 QVariant
来存储一些 void*
类型的数据。我们在这个类面全部用了重定义的 QPushButton
,如果存储的基本类型不一样的时候,使用QVariantList
可以避免更多的类型容器参与。
真实的情况是这个类的函数要比类定义中显示的比较多,只是我用了比较多的 lambda
表达式来实现了一些功能,所以才会在定义中函数比较少。
这个日历控件的部分样子参考了下Qt 自带的 QCanlendarWidget
控件。
为了方便使用,我将一些该控件共用的变量和枚举进行了同意管理,全部存放在 namespace DateTime
下。
比如将月份数字和中文显示对应起来,这样做的目的是能够实现更好的对应,方便简单的操作关系。
namespace DateTime
{
const int JANUARY = 1;
...
const QString JANUARY_TEXT = QString::fromLocal8Bit("1月");
...
static QMap<int, QString>& month()
{
static QMap<int, QString> map
{
{JANUARY, JANUARY_TEXT},
...
};
return map;
}
}
再比如一些枚举,这边使用了宏 Q_DECLARE_FLAGS
,本来刚开始是想用 多变参数的形式来进行控制显示的,后来发现这种方式逻辑会很复杂,投了一下懒就放弃了。但是使用这种方组合成 QFlags
之后,有个好处就是可以直接调用 testFlag
方法来判断是否存在该标志。
namespace DateTime
{
enum DAY_TYPE //日期类型
{
WEEKEND = 1,
WORKDAY,
};
Q_DECLARE_FLAGS(DayType, DAY_TYPE)
Q_DECLARE_OPERATORS_FOR_FLAGS(DayType)
}
下面才是正轨,首先先对月份的点击进行菜单设计。并且顺手实现一下菜单的点击效果。想一想这个菜单的点击效果也没有什么复杂的地方:
- 第一肯定就是更新月份的现实
- 第二是根据点击的月份更新日期棋盘上的显示
- 第三是更新最后的日期
实现下就差不多是下面的这样。
auto pMenu = new QMenu(this);
pMenu->setProperty("type", "month");
ui->btnMonth->setMenu(pMenu);
for (auto itor = DateTime::month().begin(); itor != DateTime::month().end(); ++itor)
{
auto pMonth = pMenu->addAction(itor.value(), this, [this]
{
auto const pAction = qobject_cast<QAction*>(sender());
if (Q_NULLPTR == pAction)
{
return;
}
ui->btnMonth->setText(pAction->text());
ui->btnMonth->setData(DateTime::month().key(pAction->text()));
updateDays(ui->dateEdit->date().day());
});
pMonth->setData(itor.key());
}
接下来就是先 new
出来 42 个 button
放在界面,后面根据日期的变更再对这42个 button 进行样式变化及显示数字的变化。
for (int index = 0; index < 42; ++index)
{
auto btn = new ButtonDay(index + 1);
btn->setFixedSize(QSize(30, 30));
ui->gridLayout->addWidget(btn, index / 7, index % 7);
btn->setType(((index % 7 == 0) || (index % 7 == 6)) ? DateTime::WEEKEND : DateTime::WORKDAY);
btn->setRole(DateTime::CURRENT_MONTH);
m_dayList.append(QVariant::fromValue(static_cast<void*>(btn)));
...
}
上面的 ButtonDay
是自定义的一个 QPushbutton
,目的是为了更好的存放一些必要的数据。
想一想应用场景,一个月最多就是 31 天,我们为什么要构造 42 个 button 呢? 是不是只要保证比31个多进行了,那35个够了吧。
好像是这么个理,那我为啥要 构造 42 个呢? 我也不知道。。。
其实这个是很好理解的,如果是 35 的话,那我们为了整齐,肯定是会进行 5 * 7 的排列的,如果有些月份的第一天是周五或者周六,哎,巧了,就比如是 这个月, 2022年7月1号是周五,并且该月份有 31 天,如果按照 5 * 7 的排列,那么就不够了,显示不下了,你说气不气人。
日期被点击之后是不是需要一些界面的变化的,比如点击了上一个月或者下一个月,要根据点击的月份进行界面数据的重排列。还要更换选中的样式等等一些操作,那么上面的 for
循环肯定是没有结束的。所以给每个按钮关联了槽函数。
槽函数中首先将目前选中的的按钮就行样式的改变。 这种方式我采用了先用成员变量 QVariantList
来存储按钮的指针,通过遍历的方式找到目前是被选中的按钮,就行状态修改即可。
for (const auto& pbt : m_dayList)
{
auto pb = static_cast<ButtonDay*>(pbt.value<void*>());
if (Q_NULLPTR == pb)
{
continue;
}
if (pb->role().testFlag(DateTime::SELECT_DAY))
{
pb->setRole(DateTime::CURRENT_MONTH);
break;
}
}
紧接着会判断点击的按钮是否是当前月份,如果不是,就要修改界面的一些月份及年份的设置,并且根据设置之后的日期来进行日期日期棋盘的重绘。需要注意的是,这种设置可能会涉及到年份的修改,如果是一年的最后一个月或者第一个月。
if (btn->month().testFlag(DateTime::PREV_MONTH_DAY)) //上一个月
{
if (ui->btnMonth->data().toInt() == 1)
{
ui->btnMonth->setData(12);
ui->btnYear->setData(ui->btnYear->data().toInt() - 1);
ui->btnYear->setText(QString::fromLocal8Bit("%1年").arg(ui->btnYear->data().toInt()));
}
else
{
ui->btnMonth->setData(ui->btnMonth->data().toInt() - 1);
}
ui->btnMonth->setText(DateTime::month().value(ui->btnMonth->data().toInt()));
}
if (btn->month().testFlag(DateTime::NEXT_MONTH_DAY)) //下一个月
{
if (ui->btnMonth->data().toInt() == 12)
{
ui->btnMonth->setData(1);
ui->btnYear->setData(ui->btnYear->data().toInt() + 1);
ui->btnYear->setText(QString::fromLocal8Bit("%1年").arg(ui->btnYear->data().toInt()));
}
else
{
ui->btnMonth->setData(ui->btnMonth->data().toInt() + 1);
}
ui->btnMonth->setText(DateTime::month().value(ui->btnMonth->data().toInt()));
}
上面的两个判断是设置了年份和月份,下面则是调用方法重绘棋盘。因为这个控件中,棋盘的重绘是重中之重并且会被不停地重绘,因此对他进行了抽像,后面会慢慢说明。
updateDays(btn->data().toInt());
接下来是年如何选择的问题,查看了很多的样例,发现大部分都是双击年份的时候会通过代理设置一个 QSpinBox
的控件进行增减,这样对我来说不是最好的选择,主要是因为太丑了。
因为年份的选择不像日期那样那么麻烦,所以我想,要不新建个页面,专门用来做年份的管理,每次点击都显示对应的页面,选中之后再返回回来。说干就干,于是就有了下面的样子。
每次点击的时候不用太多,就按照当前年份,往前十年,往后十年,进行显示就行了。
选中之后的操作也是比较简单的,只是根据年份和月份进行日期棋盘的修改和年份的更改。
for (int index = 0; index < 20; ++index)
{
auto btn = new ButtonDay(index + 1);
btn->setFixedSize(QSize(60, 30));
ui->gridLayoutYears->addWidget(btn, index / 4, index % 4);
btn->setData(ui->dateEdit->date().year() - 10 + index);
btn->setText(QString::fromLocal8Bit("%1年").arg(btn->data().toInt()));
btn->setRole(DateTime::OTHER);
m_yearList.append(QVariant::fromValue(static_cast<void*>(btn)));
connect(btn, &QPushButton::clicked, this, [this]
{
for (const auto& pbt : m_yearList)
{
auto pb = static_cast<ButtonDay*>(pbt.value<void*>());
if (Q_NULLPTR == pb)
{
continue;
}
if (pb->role().testFlag(DateTime::CURRENT_YEAR))
{
pb->setRole(DateTime::OTHER);
break;
}
}
auto const btn = qobject_cast<ButtonDay*>(sender());
btn->setRole(DateTime::CURRENT_YEAR);
ui->stackedWidget->setCurrentWidget(ui->wdgCalendar);
ui->btnYear->setData(btn->data());
ui->btnYear->setText(QString::fromLocal8Bit("%1年").arg(btn->data().toInt()));
updateDays(ui->dateEdit->date().day());
ui->btnMonth->setVisible(true);
ui->btnPrev->setVisible(true);
ui->btnNext->setVisible(true);
ui->wdgConfirm->setVisible(true);
});
}
增加了两个功能按钮,今日 和 现在,这两个按钮可使界面快速的进行按照今天和现在的本地时间进行界面的重绘。
connect(ui->btnToday, &QPushButton::clicked, this, [this]
{
QDate dt = QDate::currentDate();
ui->btnMonth->setData(dt.month());
ui->btnYear->setData(dt.year());
ui->btnMonth->setText(DateTime::month().value(dt.month()));
ui->btnYear->setText(QString::fromLocal8Bit("%1年").arg(dt.year()));
updateDays(dt.day());
});
connect(ui->btnNow, &QPushButton::clicked, this, [this]
{
QTime time = QTime::currentTime();
ui->timeEdit->setTime(time);
ui->wdgHour->setValue(time.hour());
ui->wdgMin->setValue(time.minute());
ui->wdgSec->setValue(time.second());
});
前一个月和下一个月的按钮也提供了,他们俩的功能其实在前面已经说过了,点击棋盘上的日期的时候也会有相应的操作,这俩按钮的功能实际上就是当棋盘上是一年的最后一个月或者第一个月时,点击非当前月日期时的操作。
connect(ui->btnPrev, &QPushButton::clicked, this, [this]
{
if (ui->btnMonth->data().toInt() == 1)
{
ui->btnMonth->setData(12);
ui->btnYear->setData(ui->btnYear->data().toInt() - 1);
ui->btnYear->setText(QString::fromLocal8Bit("%1年").arg(ui->btnYear->data().toInt()));
}
else
{
ui->btnMonth->setData(ui->btnMonth->data().toInt() - 1);
}
ui->btnMonth->setText(DateTime::month().value(ui->btnMonth->data().toInt()));
updateDays(ui->dateEdit->date().day());
});
connect(ui->btnNext, &QPushButton::clicked, this, [this]
{
if (ui->btnMonth->data().toInt() == 12)
{
ui->btnMonth->setData(1);
ui->btnYear->setData(ui->btnYear->data().toInt() + 1);
ui->btnYear->setText(QString::fromLocal8Bit("%1年").arg(ui->btnYear->data().toInt()));
}
else
{
ui->btnMonth->setData(ui->btnMonth->data().toInt() + 1);
}
ui->btnMonth->setText(DateTime::month().value(ui->btnMonth->data().toInt()));
updateDays(ui->dateEdit->date().day());
});
接下来是滚动时间界面的初始化,获取当前时间,分别设置给时分秒的对象,并且对他们分别设置区间。
QTime time = QTime::currentTime();
ui->wdgHour->setRang(0, 23);
ui->wdgHour->setValue(time.hour());
ui->wdgMin->setRang(0, 60);
ui->wdgMin->setValue(time.minute());
ui->wdgSec->setRang(0, 60);
ui->wdgSec->setValue(time.second());
ui->timeEdit->setTime(time);
每一个时间的滚动都是差不多的功能,按照对应的时间进行重新设置就行了。比如,下面是小时的设置。
connect(ui->wdgHour, &TimeScrollBar::signal_valueChanged, this, [this](int val)
{
QTime time = ui->timeEdit->time();
time.setHMS(val, time.minute(), time.second());
ui->timeEdit->setTime(time);
});
实现了他的 showEvent
方法,主要目的是为了能够在日历每次显示的时候,能够按照当前日期、当前时间进行重绘。
void DateTimeWidget::showEvent(QShowEvent *event)
{
ui->stackedWidget->setCurrentWidget(ui->wdgCalendar);
QDate dt = QDate::currentDate();
ui->btnMonth->setData(dt.month());
ui->btnYear->setData(dt.year());
ui->btnMonth->setText(DateTime::month().value(dt.month()));
ui->btnYear->setText(QString::fromLocal8Bit("%1年").arg(dt.year()));
ui->dateEdit->setDate(dt);
updateDays(dt.day());
}
最后一部分就是他的日期棋盘的更新和重绘,我们首先理解一下:
- 棋盘上总共有三个月的日期,包括当前月、上一个月和下一个月的部分
- 日期按照工作日和周末来分的话就是有两种
- 无论怎样,棋盘上肯定会有当前月的某个日期被选中
所以,我们用了属性来控制按钮的样式表显示。
void ButtonDay::setRole(const DateTime::DayDisplay& role)
{
m_role = role;
QString property = "";
if (role.testFlag(DateTime::NON_CURRENY_MONTH)) //非当前月
{
property = "no-current-month-day";
}
if (role.testFlag(DateTime::CURRENT_MONTH))//当前月,还要分周末跟工作日,为了显示出区别
{
property = type().testFlag(DateTime::WEEKEND) ? "weekend" : "workday";
}
if (role.testFlag(DateTime::CURRENT_DAY))//当天,用了一个不是特别明显的样式标记一下
{
property = "current-day";
}
if (role.testFlag(DateTime::SELECT_DAY) || role.testFlag(DateTime::CURRENT_YEAR)) //被选中的日期和年份
{
property = "select-day";
}
if (role.testFlag(DateTime::OTHER))
{
property = "workday";
}
setProperty("type", property);
style()->unpolish(this);
style()->polish(this);
}
基本上就是上面的这些操作。因为我们在初始化界面的时候已经 new 了 42 button ,所以在这儿要进行更新的时候直接操作就行,不必重复的析构和构造。
首先,取出需要设置的月的的第一天,看看是一周中的第几天,这样我们也就知道了上一个月需要显示几天。
QDate dt = QDate(ui->btnYear->data().toInt(), ui->btnMonth->data().toInt(), 01);
int days = dt.dayOfWeek();
QDate prevDt = dt.addDays(-1);
int offset = prevDt.day() - days + 1;
for (int index = 0; index < days; ++index)
{
auto btn = static_cast<ButtonDay*>(m_dayList[index].value<void*>());
btn->setText(QString::number(offset + index));
btn->setData(offset + index);
btn->setMonth(DateTime::PREV_MONTH_DAY);
btn->setRole(DateTime::NON_CURRENY_MONTH);
}
接下来是当前月份了,直接设置就行,有多少天就多少天,肯定够用。
QDate nextDt = dt.addMonths(1).addDays(-1);
int count = nextDt.day() - dt.day() + 1;
offset = 0;
for (int index = days; index < count + days; ++index)
{
auto btn = static_cast<ButtonDay*>(m_dayList[index].value<void*>());
btn->setText(QString::number(++offset));
btn->setData(offset);
btn->setMonth(DateTime::CURRENT_MONTH_DAY);
btn->setRole(DateTime::CURRENT_MONTH);
if (offset == current)
{
btn->setRole(DateTime::SELECT_DAY);
}
}
最后就是下一个月的日期了,这个也是很好理解,总共42个,减去当前月份和上一个月份的数量就是下一个月的数量。
offset = 0;
for (int index = count + days; index < m_dayList.size(); ++index)
{
auto btn = static_cast<ButtonDay*>(m_dayList[index].value<void*>());
btn->setText(QString::number(++offset));
btn->setData(offset);
btn->setMonth(DateTime::NEXT_MONTH_DAY);
btn->setRole(DateTime::NON_CURRENY_MONTH);
}
将选择的日期写入上方的日期编辑框。
ui->dateEdit->setDate(QDate(ui->btnYear->data().toInt(), ui->btnMonth->data().toInt(), current));
最后设置一下当天的样式就行。
QDate cdt = QDate::currentDate();
if (ui->dateEdit->date().month() != cdt.month() || ui->dateEdit->date().year() != cdt.year())
{
return;
}
for (const auto& btn : m_dayList)
{
auto pb = static_cast<ButtonDay*>(btn.value<void*>());
if (Q_NULLPTR == pb)
{
continue;
}
if (pb->month().testFlag(DateTime::CURRENT_MONTH_DAY) && !pb->role().testFlag(DateTime::SELECT_DAY) && pb->data().toInt() == cdt.day())
{
pb->setRole(DateTime::CURRENT_DAY);
break;
}
}
使用方式很简单,将一个 QComboBox 提升为 DateTimeEdit 类就行。
原文中的代码和测试代码有稍微差异,是因为本来使用VS2017编译的,但是在以前的电脑上没有VS2017,有些代码不支持,重新进行了适配。但保留了原本的代码。