C# WinForm自绘日历控件:从Control基座到日期网格与主题定制的完整实践
发布时间:2026/9/16 5:13:00来源:尧图网络
简介面向Winform桌面应用开发者压缩包提供自定义日历控件的完整源码可应对考勤管理、日程安排、任务计划等常见业务场景。包内共91个文件以C#源代码为主体49个cs并包含调试符号pdb、可执行程序exe、动态库dll、资源文件resx与resources、解决方案sln、工程配置文件csproj及CHM帮助文档整体体积仅1.61MB目录结构按功能模块划分得较为清楚。核心工程WindowsFormsCalendar覆盖了控件自绘、日期网格布局、前后翻月交互、日期选择与月改变事件、样式配置以及外部数据绑定等关键机制同时提供TestHarness独立测试项目方便运行时调试与参数调整。资源中Renderers、Enums、UserControls等子目录将绘制器、枚举定义和用户控件区分开配合帮助文档可大幅降低阅读门槛。当前已有960人学习适合希望深入掌握Winform自定义控件开发与事件机制的初中级开发者。1. 反直觉的开场Winform 日历控件为什么值得自绘做 Winform 的时候日历总是那个看着简单、一动手就返工的东西。自带的 MonthCalendar 只能选日期想在格子里画排班、标记考勤、区分节假日得靠截屏拼底色或者绕开控件自己去贴 Label。与其在第三方控件里翻文档找隐藏 API不如直接继承 Control把 6 行 7 列的格子、日期文本、选中态和标记全部收进 OnPaint 里。这个标题要解决的场景很具体你不是在写一个 DatePicker而是在做一个能放进业务界面的日期仪表盘排班、考勤、课程表、统计报表都适用。下面从基座选择开始把一条能直接落地的自绘路径讲完。2. 基座选择自绘日历控件用 Control 还是 UserControl2.1 为什么我最终选了 Control 而不是 UserControl很多人第一步会选 UserControl理由是“能拖 Label 和 Button不用自己画”。这个思路在写一次性窗体时没问题但做成通用控件就反过来了日期格子一旦达到 42 个UserControl 里就要维护几十个 Label 的句柄、坐标和事件翻月时逐个改 Text、改颜色、改 Visible。后续想加“鼠标悬停高亮”得对每个 Label 挂 MouseEnter逻辑散在几十个回调里相当于把网格逻辑重复写了多遍。直接用 Control 意味着整块区域都归自己画绘制、命中、失焦全部集中在 OnPaint 和几个事件里坐标换算只维护一份。代价是文本居中、边界留白这类“控件白送的能力”要自己实现。对日历这种结构高度规律的组件来说自绘的收益远大于成本。对比项UserControlControl句柄数量日期多时几十个占用资源多1 个静态绘制无句柄压力局部刷新逐控件改属性容易闪Invalidate(Rectangle) 精准重绘悬浮与选中态要在每个子控件上挂事件一次命中测试统一处理扩展标记绘制需要在 Label.Paint 里拼代码OnPaint 一个入口全搞定上手门槛低中需要掌握 GDI 基础一个控件是否为 UserControl 不影响它被拖进窗体设计器。区别在于内部维护的是“子控件集合”还是“一段绘图代码”。日历控件没有复合组件的需求选 Control 是让后续所有逻辑都能收敛在同一个坐标系里。2.2 SetStyle 与 ControlStyles自绘的第一组开关自绘最容易翻车的地方是闪烁。Winform 默认按照“擦背景再画前景”的顺序刷新日历这种大面积网格尤其明显。基座代码要在构造函数里把几个样式位打开public CalendarView() { SetStyle( ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | ControlStyles.Selectable, true); TabStop true; }这五个开关各有分工UserPaint 告诉系统“这块区域我自己画别用默认背景填充”AllPaintingInWmPaint 让 WM_ERASEBKGND 和 WM_PAINT 合并处理避免擦除背景时露出白色OptimizedDoubleBuffer 开启离屏缓冲把整帧画完再一次性贴回屏幕ResizeRedraw 保证拖动窗口边缘时立即重绘网格Selectable 让控件能进入 Tab 焦点链这是键盘导航的前提。提示OptimizedDoubleBuffer 和 AllPaintingInWmPaint 要成对出现。只开其中一个在部分 Windows 主题下反而会多闪一次。这段代码不依赖任何第三方库.NET Framework 4.8 和 .NET 6 的 Winform 项目都能用。开了双缓冲之后OnPaint 里就不要再调 base.OnPaint(e) 之外的手动背景擦除否则离屏缓冲的意义会打折扣。2.3 最小 OnPaint 骨架先把 6×7 的框画出来不管后面怎么扩展绘制入口始终只有一个。先写一个能跑起来看到网格的最小版本private const int HeaderHeight 28; private const int Cols 7; private const int Rows 6; protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); var g e.Graphics; var bodyRect new Rectangle( 0, HeaderHeight, ClientSize.Width, ClientSize.Height - HeaderHeight); float cellW bodyRect.Width / (float)Cols; float cellH bodyRect.Height / (float)Rows; using var pen new Pen(Color.FromArgb(220, 220, 220)); for (int row 0; row Rows; row) { for (int col 0; col Cols; col) { var cell new RectangleF( bodyRect.X col * cellW, bodyRect.Y row * cellH, cellW, cellH); g.DrawRectangle(pen, cell.X, cell.Y, cell.Width, cell.Height); } } }注意几个细节HeaderHeight 是留给“日 一 二 三 四 五 六”标题行的不能和日期网格共用同一套纵向坐标用 float 计算格子尺寸避免整数除法四舍五入累积出缝隙每一格的坐标由列宽乘索引得到而不是逐个累加 cellW——后者在缩放大小时容易出现最后一列超出边界。DrawRectangle 还有一个已知的 1 像素裁边问题画笔宽度为 1 时矩形右边界和下边界会被裁掉一点后续画选中框时要用new Rectangle(x, y, width - 1, height - 1)兜底。到这里一个能随窗体缩放、不闪烁的空白网格已经成型。下一章把日期填进去坐标计算要和这里完全一致否则就会出现“画在 A 处、点在 B 处”的错位。3. 日期网格算法日历控件的格子怎么算才稳3.1 周起始与偏移日期连续性的唯一硬指标把日期填进格子之前要先回答一个问题每个月 1 号不一定落在周一那它前面空出来的格子里该显示什么答案是上个月的尾巴。这个计算是整个控件唯一有数学风险的地方错一天整月错乱。private DateTime[] GetVisibleDays() { var first new DateTime(_displayMonth.Year, _displayMonth.Month, 1); int offset (int)first.DayOfWeek - (int)_firstDayOfWeek; if (offset 0) offset 7; var start first.AddDays(-offset); var days new DateTime[Rows * Cols]; for (int i 0; i days.Length; i) days[i] start.AddDays(i); return days; }DayOfWeek 枚举里 Sunday 0Monday 1这是最容易被绕晕的地方。假设设置为周一为一周起点某个月的 1 号是周日偏移就是 0 - 1 -1加 7 后变成 6说明 1 号之前的 6 天都属上个月尾巴。start.AddDays拿到的是连续日期序列跨月时 DateTime 会自动进位到 1 号不需要额外判断月末。提示不要在 GetVisibleDays 内部读取 CurrentCulture.DateTimeFormat.FirstDayOfWeek。控件被放到非主线程、或者切换区域设置时这个值会变化导致周一变成周日开头界面标题却还写着“一”。首日从周几开始应该做成公开属性由调用方固定传入。把 FirstDayOfWeek 设成 public就不需要重新编译控件本体。3.2 完整绘制把“上月末/下月初”画成弱化色边界日期要和当月日期区分开否则用户会误点。常见做法是非本月文字用浅灰色选中或悬浮时依然高亮但优先级低于本月日期。绘制每个格子时先画背景、再画文本、最后画“今天”边框避免填充色盖住文字private void DrawDayCell(Graphics g, DateTime day, Rectangle cell, bool isCurrentMonth) { bool isSelected _selectedDate.HasValue SameDay(_selectedDate.Value, day); bool isToday SameDay(DateTime.Today, day); bool isHovered _hoveredDate.HasValue SameDay(_hoveredDate.Value, day); Color bg isSelected ? _theme.SelectedBackground : isToday ? _theme.TodayBackground : isHovered ? _theme.HoverBackground : _theme.DayBackground; using (var brush new SolidBrush(bg)) g.FillRectangle(brush, cell); Color textColor !isCurrentMonth ? _theme.OtherMonthForeColor : IsWeekend(day) ? _theme.WeekendForeColor : _theme.DayForeColor; TextRenderer.DrawText(g, day.Day.ToString(), _dayFont, cell, textColor, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPrefix); if (isToday) using (var pen new Pen(_theme.TodayBorder)) g.DrawRectangle(pen, cell.X, cell.Y, cell.Width - 1, cell.Height - 1); }IsWeekend 不只判断周六最好把它也做成属性让考勤项目能把周五定为周末。TextRenderer.DrawText 的 NoPrefix 标志很重要日期是纯数字时无所谓但如果后续要显示“休”“班”这类字符没有这个标志会把当快捷键前缀吞掉。3.3 文本渲染TextRenderer 与 DrawString 的选择日期控件在不同 DPI 下的显示效果取决于文本走哪条渲染管线。Winform 原生控件Label、Button默认用 TextRenderer走 GDI 渲染Graphics.DrawString 走 GDI。两者在高分屏下的差距非常直观。对比项TextRendererGDIDrawStringGDI字体边缘清晰锐利贴合 Winform 原生控件的观感边缘偏软100% DPI 下尚可125% DPI基本不发虚靠近原生体验常见模糊、发虚需要手动缩放对齐控制TextFormatFlags 组合写法偏底层StringFormat 直观居中好调与 Winform 主题天然兼容系统的对比度和清晰度设置颜色和渲染方式偏独立安装在持续维护的自绘控件上我一般直接用 TextRenderer。一个容易忽略的关联项是字体初始化new Font(Microsoft YaHei UI, 9F)写死字体名在服务器版 Windows 上会回退到宋体不如用SystemFonts.MessageBoxFont作为默认值对外提供 Font 属性让业务侧覆盖。字号跟着 DPI 走时在 OnFontChanged 里重建字体对象并 Invalidate而不是在 OnPaint 里每次都 new。4. 命中测试与键盘导航让日历控件真正可操作4.1 命中测试从鼠标坐标反推日期绘制和命中必须共用同一套网格计算否则会出现“看得见点不中”的错位。核心思路是用除法反推行列而不是遍历 42 个矩形做 Contains 判断。private DateTime? HitTest(Point p) { if (p.Y HeaderHeight) return null; int bodyHeight ClientSize.Height - HeaderHeight; if (bodyHeight 0) return null; int col (int)((p.X / (float)ClientSize.Width) * Cols); int row (int)(((p.Y - HeaderHeight) / (float)bodyHeight) * Rows); if (col 0 || col Cols || row 0 || row Rows) return null; return GetVisibleDays()[row * Cols col]; }这个写法的关键是先归一化再乘格子数避免两个典型错误一是用p.X / cellWidth直接除浮点误差会在最后一列出现“越界”二是对 42 个格子逐个Rectangle.Contains逻辑没问题但 OnMouseMove 高频触发时会白做大量比较。MouseEventArgs.Location 返回的已经是相对控件的坐标不要再去调 PointToClient。4.2 鼠标悬停与点击只刷新受影响的两个格子悬停高亮最容易做成“每动一下全刷新”在这种 42 格控件上看似无所谓一旦 CellPaint 事件里挂了业务绘制考勤标记、排班底色全量重绘就能感觉到掉帧。正确做法是记录旧悬停格只失效新旧两格protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); var hit HitTest(e.Location); if (SameDay(hit ?? DateTime.MinValue, _hoveredDate ?? DateTime.MinValue)) return; Invalidate(GetCellRect(_hoveredDate)); Invalidate(GetCellRect(hit)); _hoveredDate hit; } protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); Focus(); var hit HitTest(e.Location); if (hit.HasValue) { _selectedDate hit.Value; Invalidate(); OnDayClicked(new DayClickedEventArgs(hit.Value)); } }GetCellRect 传入 DateTime? 时返回 Rectangle.Empty这样 old/new 的 Invalidate 调用天然安全。鼠标移出控件时要在 OnMouseLeave 里清空 _hoveredDate 并失效最后一格否则控件外沿会残留一块高亮。Focus() 放在 OnMouseDown 里是必须的只有拿到焦点后面的键盘事件才会派发到这个自定义控件上。4.3 键盘导航方向键移动翻页键换月一个只能点、不能键盘操作的日历控件谈不上可用。方向键和翻页键的映射如下表按键日期变化Left / Right减一天 / 加一天Up / Down减七天 / 加七天PageUp / PageDown减一个月 / 加一个月Enter / Space确认选中触发激活事件protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); DateTime current _selectedDate ?? _displayMonth; DateTime next current; if (e.KeyCode Keys.Left) next current.AddDays(-1); else if (e.KeyCode Keys.Right) next current.AddDays(1); else if (e.KeyCode Keys.Up) next current.AddDays(-7); else if (e.KeyCode Keys.Down) next current.AddDays(7); else if (e.KeyCode Keys.PageUp) next current.AddMonths(-1); else if (e.KeyCode Keys.PageDown) next current.AddMonths(1); else if (e.KeyCode Keys.Enter || e.KeyCode Keys.Space) { OnDayActivated(new DayClickedEventArgs(current)); return; } if (next.Month ! _displayMonth.Month) _displayMonth new DateTime(next.Year, next.Month, 1); _selectedDate next; Invalidate(); }这里做了一层自动翻月选中日移动到前一个月或后一个月时先把 DisplayMonth 切过去再重绘用户会看到视图跟随光标跨月这个体验和 Windows 日历一致。注意 PageUp 和 PageDown 在文本框里通常用于翻页但日历控件里没有文本输入冲突直接用作翻月是业界约定。Enter 与 Space 触发事件后返回不要再走一遍位置更新。4.4 日程标记与绘制扩展CellPaint 事件日历控件的核心价值在于“把格子交出去”。直接暴露 Dictionary 给外部维护确实能画标记但外部代码需要知道格子坐标换算一旦控件改了 HeaderHeight 或间距调用方的绝对坐标就全错了。更好的方案是提供绘制事件在基础渲染完成后把当前格子的矩形和日期传给外部public event EventHandlerCalendarCellPaintEventArgs CellPaint; protected virtual void OnCellPaint(CalendarCellPaintEventArgs e) CellPaint?.Invoke(this, e); public sealed class CalendarCellPaintEventArgs : EventArgs { public Graphics Graphics { get; } public Rectangle CellBounds { get; } public DateTime Day { get; } public bool IsCurrentMonth { get; } // 构造函数省略 }在 DrawDayCell 的最后调用 OnCellPaint外部使用方式就非常干净calendar.CellPaint (s, e) { if (!e.IsCurrentMonth || !MySchedule.ContainsKey(e.Day)) return; using var brush new SolidBrush(Color.FromArgb(255, 160, 40)); e.Graphics.FillEllipse(brush, e.CellBounds.X e.CellBounds.Width / 2 - 3, e.CellBounds.Y e.CellBounds.Height - 8, 6, 6); };提示CellPaint 里拿到的是同一个 Graphics 实例画完要自行做 Save/Restore。改过 Transform 或 Clip 区域不还原下一个格子的绘制就会错位。事件方案比“让外部改控件属性”更好的一点是绘图代码依然归属外部业务层但控件内部的结构调整不会破坏调用方。事件内部做一次颜色和字体的属性缓存比每次都查字典要快。5. 性能与主题Winform 日历控件的最后一步5.1 翻月性能位图缓存与局部失效42 个格子全量绘制本身很轻但单元格事件里挂了业务绘制后翻月就会从“秒开”变成“肉眼可见”。常见做法是把月份视图缓存成一张位图交互层单独画private Bitmap _monthBitmap; private DateTime _bitmapMonth; private CalendarTheme _bitmapTheme; private void EnsureMonthBitmap() { if (_monthBitmap ! null _bitmapMonth _displayMonth ReferenceEquals(_bitmapTheme, _theme)) return; _monthBitmap?.Dispose(); _monthBitmap new Bitmap(ClientSize.Width, ClientSize.Height); using (var g Graphics.FromImage(_monthBitmap)) DrawMonth(g); _bitmapMonth _displayMonth; _bitmapTheme _theme; } protected override void OnPaint(PaintEventArgs e) { EnsureMonthBitmap(); e.Graphics.DrawImageUnscaled(_monthBitmap, 0, 0); DrawInteractionLayer(e.Graphics); }这个结构把“网格 日期 标记”画进位图把“选中框 悬浮高亮”留给交互层。DrawImageUnscaled 避免二次缩放造成的模糊。位图缓存最隐蔽的坑是失效条件字体、DPI、主题、尺寸四项中任何一项变化缓存都必须重建否则会看到旧主题文字叠加新主题背景。5.2 主题注入把颜色从绘制逻辑里拆出去Winform 界面美化最常见的做法不是贴背景图而是把颜色收敛成一个可替换对象。日历控件尤其需要因为排班、考勤这类页面的主色调通常由甲方指定。定义一个主题类所有绘制逻辑只从里面取色public sealed class CalendarTheme { public Color HeaderBackground { get; set; } Color.FromArgb(33, 44, 54); public Color HeaderForeColor { get; set; } Color.White; public Color DayBackground { get; set; } Color.White; public Color DayForeColor { get; set; } Color.FromArgb(51, 51, 51); public Color WeekendForeColor { get; set; } Color.FromArgb(200, 60, 60); public Color OtherMonthForeColor { get; set; } Color.FromArgb(180, 180, 180); public Color TodayBackground { get; set; } Color.FromArgb(238, 248, 255); public Color TodayBorder { get; set; } Color.FromArgb(30, 144, 255); public Color SelectedBackground { get; set; } Color.FromArgb(60, 120, 220); public Color HoverBackground { get; set; } Color.FromArgb(240, 244, 250); public Color MarkerColor { get; set; } Color.FromArgb(255, 160, 40); }控件持有public CalendarTheme Theme { get; set; }setter 里调用Invalidate()并标记位图缓存失效。调用方换肤时只需要calendar.Theme new DarkCalendarTheme();不需要动任何绘制方法。这就是 C# Winform 主题实现的常见套路颜色对象与绘制逻辑解耦而不是在 OnPaint 里散落十几个魔法色号。5.3 验证清单最小 DemoForm 与常见翻车点新建一个 DemoForm 是最快的验证方式把日历控件配合布局器放进 SplitContainer 里右侧用一个 DataGridView 显示当天的业务数据顺手验证事件链路public class DemoForm : Form { public DemoForm() { var calendar new CalendarView { Dock DockStyle.Fill }; var grid new DataGridView { Dock DockStyle.Fill }; var split new SplitContainer { Dock DockStyle.Fill }; split.Panel1.Controls.Add(calendar); split.Panel2.Controls.Add(grid); Controls.Add(split); calendar.DayClicked (s, e) grid.DataSource LoadOrders(e.Day); } }这样搭出来后用一个表格逐项过一遍能挡住大部分回归问题验证项操作预期结果翻月连续PageDown 翻到下月上月最后一周与下月第一周不重不漏周起始切换切换 FirstDayOfWeek周一开头时星期日落在最后一列月尾边界点击 31 号附近的空格命中上/下月日期不出现空白无反应高 DPI系统缩放 150% 后启动文字不发虚网格无错位布局器拉伸拖拽 SplitContainer 分隔条格子等比缩放无残影无闪烁键盘焦点点击后按方向键选中框移动跨月时自动翻页如果翻月后发现 42 格里出现两个同一天先查 3.1 节里的 offset 计算这是日历控件唯一的数学题。本文还有配套的精品资源点击获取
网站建设高端定制企业官网