自定义日历(一)
1、概要
最近一直比较忙,根本没时间记录自己想写的东西,趁着国庆小长假,我自己也列出了想记录的一些小东西,方便他人借鉴,也方便我自己学习。废话不多说,我先来贴上我自己的demo展示图,这个demo比较复杂,我可能会分两篇博客来讲述。与其说是因为demo复杂,还不如说是这个月份是按照完全不同的两种思路来展示。
月份展示效果
如上图所示,这是两种日历展示形式,上方的日历是用label拼成,下方的日历是用一个窗口绘制而成,完全不同的两种路线,各有优缺点。接下来我将会分别介绍这两种月份的实现过程。
2、优缺点比较
这两种日历的优缺点我总结了一个简单的表格,如下表:
label平凑 | widget自绘 | |
优点 |
实现思路简单容易理解,每天都是一个label 绘制不同于其他天比较方便(蓝点) |
实现稍微复杂,每天的位置需要自己计算 相对于第一种相率高,内存占用率低,事件处理层级少 窗口放大缩小效率高 背景色渐变容易实现 |
缺点 |
每天都是一个label,对性能有影响 窗口大小变化时麻烦 背景色渐变难以实现 |
widget每次整个重绘,每一天上的内容绘制比较难以计算 |
两种月份优缺点比较
本片文字我重点介绍下第二种日历的绘制,也就是整个窗口重新绘制,这种日历的实现难点就在于窗口事件的处理上,设计了好的数据结构,我们的程序处理才会变得简单。
3、数据结构设计
1 struct tDayFlag 2 { 3 signed char m_chFlagM; // -1 pre 0 cur 1 next 4 unsigned short m_chFlagD; // day num 5 };
这个结构负责存储每一天的信息,m_chFlagM这个字段表示是否是当前月份的,-1:上一个月,1:下一个月,0表示当前月份;m_chFlagD存储是一个月中的那一天。
接下来是一个impl接口类DrawDateTimePrivate,这个类中存储了大量的计算信息,负责日历的数据计算和比较
1 struct DrawDateTime::DrawDateTimePrivate 2 { 3 public: 4 DrawDateTimePrivate(DrawDateTime * s) 5 : m_Self(s) 6 //, m_pOnChanged(NULL) 7 { 8 m_aRect = new QRect[m_column_count * m_row_count]; 9 m_aDayFlag = new tDayFlag[m_column_count * m_row_count]; 10 m_sOverIndex = -1; 11 12 SYSTEMTIME st; 13 GetLocalTime(&st); 14 m_wYear = st.wYear; 15 m_wMonth = st.wMonth; 16 m_wDay = st.wDay; 17 } 18 19 ~DrawDateTimePrivate() 20 { 21 delete []m_aRect; 22 delete []m_aDayFlag; 23 } 24 25 public: 26 unsigned int GetColumnLeft(int column) 27 { 28 unsigned int left = (width - leftBorder - rightBorder + columnSpace) / m_column_count * column + leftBorder; 29 30 return left; 31 } 32 33 unsigned int GetColumnRight(int column) 34 { 35 if (column < 0 || column > m_column_count) 36 { 37 column = 0; 38 } 39 40 //总宽度-左border-由border-(m_column_count-1)*列间隙 41 unsigned int itemWidth = (width - leftBorder - rightBorder - (m_column_count - 1) * columnSpace) / m_column_count; 42 unsigned int right = GetColumnLeft(column) + itemWidth; 43 44 return right; 45 } 46 47 unsigned int GetRowTop(int row) 48 { 49 QFontMetrics fm(weekFont); 50 int weekHeight = fm.height(); 51 52 unsigned int top = (height - topBorder - bottomBorder - weekHeight + rowSpace) / m_row_count * row 53 + space + weekHeight; 54 55 return top; 56 } 57 58 unsigned int GetRowBottom(int row) 59 { 60 if (row < 0 || row > m_row_count) 61 { 62 row = 0; 63 } 64 65 QFontMetrics fm(weekFont); 66 int weekHeight = fm.height(); 67 68 //总高度-上border-下border-(m_row_count-1)*行间隙-week高 69 unsigned int itemHeight = (height - topBorder - bottomBorder - weekHeight - space - (m_row_count - 1) * rowSpace) / m_row_count; 70 unsigned int bottom = GetRowTop(row) + itemHeight; 71 72 return bottom; 73 } 74 75 public: 76 int columnSpace = 5; 77 int rowSpace = 5; 78 79 int leftBorder = 10; 80 int rightBorder = 10; 81 int topBorder = 5; 82 int bottomBorder = 5; 83 84 int width = 100; 85 int height = 80; 86 87 int space = 10;//周名称和天之间距离 88 89 int m_column_count = 7;//列数 90 int m_row_count = 6;//行数 91 92 QFont weekFont = QFont(STR("微软雅黑", 14)); 93 QFont dayFont; 94 95 public: 96 DrawDateTime * m_Self; 97 98 // IDateInfoChangedNotify * m_pOnChanged; 99 100 unsigned short m_wYear; 101 unsigned short m_wMonth; 102 unsigned short m_wDay; 103 104 tDayFlag * m_aDayFlag; // 各个按钮日期号 105 QRect * m_aRect; // 各个按钮区域 106 short m_sOverIndex; // 热点按钮下标 107 108 bool MatchRealDate(tDayFlag df) 109 { 110 if (df.m_chFlagD == m_wDay && 0 == df.m_chFlagM) 111 { 112 return true; 113 } 114 return false; 115 } 116 117 //重置当前月份上的日期flag及显示的数据 118 void ResetDayFlag() 119 { 120 unsigned short preY, preM; 121 GetPreviousMonth(preY, preM); 122 123 int nPreMonDays = DayofMonth(preY, preM); 124 int nCurMonDays = DayofMonth(m_wYear, m_wMonth); 125 int week = CalDayofWeek(m_wYear, m_wMonth, 1); 126 127 int index = 0; 128 129 for (int i = 0; i < week; ++i, index++) 130 { 131 m_aDayFlag[index].m_chFlagM = -1; 132 m_aDayFlag[index].m_chFlagD = (nPreMonDays - week + 1) + i; 133 } 134 135 for (int i = 0; i < nCurMonDays; ++i, index++) 136 { 137 m_aDayFlag[index].m_chFlagM = 0; 138 m_aDayFlag[index].m_chFlagD = i + 1; 139 } 140 141 m_row_count = index / 7 + (index % 7 == 0 ? 0 : 1); 142 143 for (int j = 1; index < m_column_count * m_row_count; ++j, ++index) 144 { 145 m_aDayFlag[index].m_chFlagM = 1; 146 m_aDayFlag[index].m_chFlagD = j; 147 } 148 } 149 150 //获取上一个月的年和月份 151 void GetPreviousMonth(unsigned short & preYear, unsigned short & preMonth) 152 { 153 if (m_wMonth > 1) 154 { 155 preYear = m_wYear; 156 preMonth = m_wMonth - 1; 157 } 158 else 159 { 160 preYear = m_wYear - 1; 161 preMonth = 12; 162 } 163 } 164 165 //获取下一个月的年和月份 166 void GetNextMonth(unsigned short & nextYear, unsigned short & nextMonth) 167 { 168 if (m_wMonth >= 12) 169 { 170 nextYear = m_wYear + 1; 171 nextMonth = 1; 172 } 173 else 174 { 175 nextYear = m_wYear; 176 nextMonth = m_wMonth + 1; 177 } 178 } 179 };
GetColumnLeft:获取指定列的左边界
GetColumnRight:获取指定列的右边界
GetRowTop:获取指定行的上边界
GetRowBottom:获取指定行的下边界
MatchRealDate:检测给定日期是否是当前天
ResetDayFlag:重置impl中的内存数据
GetPreviousMonth:获取上一个月份的年和日
GetNextMonth:获取下一个月份的年和日
4、区域生成
区域生成顾名思义就是生成日期的绘制区域,这个需要根据当前窗口的大小、列间距、行间距等信息来计算每一天的矩形区域,当有月份切换时需要重新计算该信息,如果觉着这个过程对性能没有影响可以在每次整个绘制的时候都重新计算,这样有助于程序在出错时自动恢复。关于区域自动生成,在上一个小节我们已经给出了接口解释,接下来我将贴出实现代码,并做相应解释
1 //重置当前月份上的日期flag及显示的数据 2 void ResetDayFlag() 3 { 4 unsigned short preY, preM; 5 GetPreviousMonth(preY, preM); 6 7 int nPreMonDays = DayofMonth(preY, preM); 8 int nCurMonDays = DayofMonth(m_wYear, m_wMonth); 9 int week = CalDayofWeek(m_wYear, m_wMonth, 1); 10 11 int index = 0; 12 13 for (int i = 0; i < week; ++i, index++)//重置上一个月份的日期 14 { 15 m_aDayFlag[index].m_chFlagM = -1; 16 m_aDayFlag[index].m_chFlagD = (nPreMonDays - week + 1) + i; 17 } 18 19 for (int i = 0; i < nCurMonDays; ++i, index++)//重置本月份的日期 20 { 21 m_aDayFlag[index].m_chFlagM = 0; 22 m_aDayFlag[index].m_chFlagD = i + 1; 23 } 24 25 m_row_count = index / 7 + (index % 7 == 0 ? 0 : 1);//更新行数 26 27 for (int j = 1; index < m_column_count * m_row_count; ++j, ++index)//重置下一个月份的日期 28 { 29 m_aDayFlag[index].m_chFlagM = 1; 30 m_aDayFlag[index].m_chFlagD = j; 31 } 32 }
5、点击位置是哪一天
在自绘制日历的时候,点击位置或者hover位置是哪一个日期判断是非常重要的,这个涉及到整个日历是否是有一个友好的交互。在开始做这个基于widget绘制日的时候我也迷茫过,觉得判断点击位置是哪一天非常困难,实时也是这样的,判断起来是非常困难的,直到后来我看到了一个网友的数据结构设计,原来这个判断是如此的简单,不过简单的判断都是基于一个优秀的结构设计,判断代码如下:
1 int DrawDateTime::GetIndex(const QPoint & point) 2 { 3 int id = -1; 4 for (int i = 0; i < _ptr->m_row_count * _ptr->m_column_count + 4; ++i) 5 { 6 QRect & rc = _ptr->m_aRect[i]; 7 if (point.x() > rc.left() && point.x()< rc.right() 8 && point.y() > rc.top() && point.y() < rc.bottom()) 9 { 10 id = i; 11 break; 12 } 13 } 14 15 return id; 16 }
上述判断只用了一个循环就可以判断出点击位置是在那个日期上,_ptr->m_aRect这个结构存储了所有日期的矩形区域,他在合适的时机就会重置。
6、周内容绘制
1 void DrawDateTime::DrawWeek(QPainter & painter) 2 { 3 QString aText[7] = { STR("周日"), STR("周一"), STR("周二"), STR("周三"), STR("周四"), STR("周五"), STR("周六") }; 4 5 painter.save(); 6 painter.setFont(_ptr->weekFont); 7 QFontMetrics fm(_ptr->weekFont); 8 int height = fm.height(); 9 for (int i = 0; i < 7; ++i) 10 { 11 int left = _ptr->GetColumnLeft(i); 12 int right = _ptr->GetColumnRight(i); 13 QRect rect(left, _ptr->topBorder, right - left, height); 14 painter.drawRect(rect); 15 painter.drawText(rect, Qt::AlignCenter, aText[i]); 16 } 17 painter.restore(); 18 }
7、日期内容绘制
1 void DrawDateTime::DrawDay(QPainter & painter) 2 { 3 _ptr->ResetDayFlag(); 4 5 painter.save(); 6 7 QFontMetrics fm(_ptr->weekFont); 8 int weekHeight = fm.height(); 9 10 for (int column = 0; column < _ptr->m_column_count; ++column) 11 { 12 int column_left = _ptr->GetColumnLeft(column); 13 int column_right = _ptr->GetColumnRight(column); 14 for (int row = 0; row < _ptr->m_row_count; ++row) 15 { 16 int index = row * _ptr->m_column_count + column; 17 QRect & rcTmp = _ptr->m_aRect[index]; 18 tDayFlag & flag = _ptr->m_aDayFlag[index]; 19 20 rcTmp.setLeft(column_left); 21 rcTmp.setRight(column_right); 22 rcTmp.setTop(_ptr->GetRowTop(row)); 23 rcTmp.setBottom(_ptr->GetRowBottom(row)); 24 25 QPainterPath path; 26 path.addRoundRect(rcTmp, 25); 27 28 if (index == _ptr->m_sOverIndex)//hover时背景色 29 { 30 painter.fillPath(path, QColor(144, 151, 151)); 31 } 32 33 painter.save(); 34 if (-1 == flag.m_chFlagM || 1 == flag.m_chFlagM) 35 { 36 painter.setPen(QColor(Qt::blue)); 37 } 38 else 39 { 40 if (_ptr->MatchRealDate(flag)) 41 { 42 painter.setPen(QColor(Qt::red)); 43 } 44 else if (index == _ptr->m_sOverIndex) 45 { 46 painter.setPen(QColor(Qt::white)); 47 } 48 else 49 { 50 51 } 52 } 53 painter.setOpacity(0.5);//绘制半透明度字 54 painter.drawText(rcTmp, Qt::AlignCenter, QString::number(flag.m_chFlagD)); 55 56 painter.restore(); 57 58 painter.drawPath(path); 59 } 60 } 61 62 painter.restore(); 63 }
8、月份切换
1 void DrawDateTime::PreviousMonth()//上一个月份 2 { 3 unsigned short year, month; 4 _ptr->GetPreviousMonth(year, month); 5 6 int acturlDays = DayofMonth(year, month); 7 if (acturlDays < _ptr->m_wDay) 8 { 9 _ptr->m_wDay = acturlDays; 10 } 11 SetDate(year, month, _ptr->m_wDay); 12 13 update(); 14 } 15 16 void DrawDateTime::NextMonth()//下一个月份 17 { 18 unsigned short year, month; 19 _ptr->GetNextMonth(year, month); 20 21 int acturlDays = DayofMonth(year, month); 22 if (acturlDays < _ptr->m_wDay) 23 { 24 _ptr->m_wDay = acturlDays; 25 } 26 SetDate(year, month, _ptr->m_wDay); 27 28 update(); 29 }
9、日期点击时效果
1 void DrawDateTime::mousePressEvent(QMouseEvent * event) 2 { 3 if (event->button() == Qt::LeftButton) 4 { 5 int cur = GetIndex(event->pos()); 6 tDayFlag & flag = _ptr->m_aDayFlag[cur]; 7 8 unsigned short year = _ptr->m_wYear, month = _ptr->m_wMonth; 9 if (flag.m_chFlagM == -1) 10 { 11 _ptr->GetPreviousMonth(year, month); 12 } 13 else if (flag.m_chFlagM == 1) 14 { 15 _ptr->GetNextMonth(year, month); 16 17 } 18 bool b = (_ptr->m_wDay != flag.m_chFlagD || month != _ptr->m_wMonth || year != _ptr->m_wYear); 19 if (b) 20 { 21 _ptr->m_wDay = flag.m_chFlagD; 22 _ptr->m_wMonth = month; 23 _ptr->m_wYear = year; 24 update(); 25 } 26 } 27 }
日期点击时,如果点击的是上一个月份的日期,则把当前月份切换到上一个月;如果点击的是下一个月的日期,则把当前月份切换到下一个月;否则当前月份不变,当前点击的日期颜色变成白色。
10、鼠标移动时效果
1 void DrawDateTime::mouseMoveEvent(QMouseEvent * event) 2 { 3 int cur = GetIndex(event->pos()); 4 bool b = (cur != _ptr->m_sOverIndex); 5 if (b) 6 { 7 int previousHover = _ptr->m_sOverIndex; 8 _ptr->m_sOverIndex = cur; 9 update(_ptr->m_aRect[cur]); 10 update(_ptr->m_aRect[previousHover]); 11 } 12 13 QWidget::mouseMoveEvent(event); 14 }
鼠标hover时,修改当前所hover的日期,然后在重新绘制时,如果是hover的日期,则绘制颜色变为QColor(144, 151, 151)
11、实现动画
本文所讲述的这种日历在demo中实现时没有使用动画来切换月份,但是用label拼凑的日历使用了动画来切换月份,如果有兴趣的同学可以把本文后面提供的demo下载下来,自行进行修改,修改的时候可以参考label拼凑月份的动画,由于label拼凑的月份实现起来更为繁琐,因此本片文章就不继续讲解了,在下一篇文章中我将讲解一些关键的实现思路和不足。顺便提一句,支持动画切换月份的日历控件时在一个窗口的基础上切换的,感兴趣的同学也可以实现5个月份的切换,就像一些音乐播放器主页上的音乐提示一样,是一个轮播的形式。例如网易音乐
网易轮播
日历同意可以以这样的形式来切换,这样的功能我已经实现了,如果感兴趣的同学可以私聊,demo中的代码在完善优化下,就可以做出这个效果,基于widget完全绘制的日历可能更合适这样的切换,label的堆砌在切换的时候可能会有效率的问题,这就取决于你的机器了,一般的机器还都是没有问题的,除非你要做对效率要求很高的程序。
关于label拼凑的日历我将会在自定义日历(二)给出详细讲解
11、demo下载链接
demo下载:自绘制日历控件