我的开发助手之时间线控件
数年前因为某个原因,开始编写的我的开发助手,一路艰辛,一路坚持,目前仍不断完善之中,项目地址:https://gitee.com/sqlorm/DevelopAssistant 欢迎大家点赞和支持。
今天想和大家分享一下其中的时间线控件,这是一个通过GDI绘制和对原有事件重写来实现的用户自定义控件,界面还算美观,操作也很简捷,喜欢的同学不妨收下。
控件是这样子的:
![](https://img-blog.csdnimg.cn/20190826231329553.bmp)
![](https://img-blog.csdnimg.cn/2019082623054247.bmp)
下面就来介绍一下关于这个控件的开发:
第一步、我们创建一个类继承 UserControl
控件主要对 OnPaint ,OnMouseClick ,OnMouseMove,OnSizeChanged,OnMouseWheel 方法进行重写,其中 OnPaint 方法用户界面元素的绘制,并在该方法里面计算控件元素的绘制区域,以便在OnMouseClick 重写方法里实现元素的点击事件,OnMouseClick 方法就是实现控件元素的点击事件,OnMouseMove 主要实体一些鼠标特效,例如滑动鼠标改变背景色,改变控件默认光标形状等,OnSizeChanged 方法主要实现当改变控件大小时控件控件滚动条相关属性的计算和触发控件元素重绘及事件区域范围Rectangle的计算,通过判断鼠标点击的位置属于哪个元素的区域范围来确定触发哪个元素的相关事件,OnMouseWheel 鼠标滚轮滚动时发生。其实winform下自定义控件特别是通过GDI绘制来实现的一类基本上都是实现上述几个事件方法来实现,可以用一张图来概括:
第二步、定义控件的内部元素
控件主要涉及到 月份对象元素: MonthItem ,日期对象元素:DateItem ,时间对象元素:DateTimeItem 他们都继承自公共对象元素:TimelineItem 他们都有公同的属性Id (与数据库表主键做关联),Name 名称,Tag 其它数据相关绑定的标签。其次MonthItem和 DateItem 都有 Bound 属于,用户保存该元素在控件中的绘制区域。下面贴出这三个元素实体类的代码:
MonthItem:
[Serializable] public class MonthItem : TimelineItem { public DateTime Date { get; set; } public string DateLabel { get; set; } public List<DateItem> List { get; set; } internal Size Size { get; set; } internal Rectangle Bound { get; set; } }
DateItem:
[Serializable] public class DateItem : TimelineItem { public DateTime Date { get; set; } public List<DateTimeItem> List { get; set; } internal Size Size { get; set; } internal Rectangle Bound { get; set; } internal Rectangle AddRect { get; set; } internal Rectangle ClickRect { get; set; } public bool Selected { get; set; } private string _tag; public override string Tag { get { if (string.IsNullOrEmpty(_tag)) { _tag = Date.ToString("yyyyMMdd"); } return _tag; } } }
DateTimeItem :
[Serializable] public class DateTimeItem : TimelineItem { public Image Icon { get; set; } public string Title { get; set; } public string Summary { get; set; } public string Description { get; set; } public string ToolTip { get; set; } public string PersonName { get; set; } public DateTime DateTime { get; set; } public ImportantLevel Level { get; set; } public Timeliness Timeliness { get; set; } public string ResponsiblePerson { get; set; } internal Rectangle EditRect { get; set; } internal Rectangle DeleteRect { get; set; } /// <summary> /// 0 :默认 1:修改 2:删除 /// </summary> internal int ButtonState { get; set; } public Rectangle ClickRect { get; set; } public bool Selected { get; set; } private string _tag; public override string Tag { get { if (string.IsNullOrEmpty(_tag)) { _tag = DateTime.ToString("yyyyMMddHHmmss"); } return _tag; } } }
第三步、绘制控件内部的元素
绘制控件内部的元素主要分为绘制 TimelineItem 一类(包括 MonthItem ,DateItem 和 DateTimeItem)和 控件的滚动条,一般来讲winform 控件自带的滚动条都由系统绘制,往往和操作系统息息相关,这里我们的时间线控件要适合开发助手相关的主题,所以我们采用在内部自己绘制滚动条,通过主要对OnMouseMove,OnMouseWheel两相事件方法进行重写来实现滚动条的控件。
这里对TimelineItem 一类的元素绘制主要贴出以下代码:
/// <summary> /// 计算 TimelineItem 绘制区域 通过对 MonthItem 子元素递规循环计算 整个 MonthItem 元素的绘制区域 /// </summary> /// <param name="g"></param> /// <param name="index"></param> /// <param name="item"></param> /// <returns></returns> private Rectangle MeasureItemBound(Graphics g, int index, MonthItem item) { int itemHeight = 46; if (item.List != null) { foreach (DateItem subItem in item.List) { if (subItem.List != null) { foreach(DateTimeItem subsubItem in subItem.List) { itemHeight = itemHeight + 32; if (!string.IsNullOrEmpty(subsubItem.Summary)) { itemHeight = itemHeight + 26; } } } itemHeight = itemHeight + 32; } } Rectangle rect = new Rectangle(drawPositionOffset.X + padding.Left, drawPositionOffset.Y + position, this.Width - padding.Left - padding.Right - (scrollerBarVisable ? 0 : scrollerBarWidth), itemHeight); position = position + itemHeight; return rect; }
/// <summary> /// 绘制 MonthItem 元素,包括下面的 DateItem 和 DateTimeItem 子元素 /// </summary> /// <param name="g"></param> /// <param name="index"></param> /// <param name="item"></param> private void DrawTimelineItem(Graphics g, int index, MonthItem item) { StringFormat sf = new StringFormat(); sf.Alignment = StringAlignment.Center; sf.LineAlignment = StringAlignment.Center; // margin Rectangle bound = item.Bound; //g.DrawRectangle(new Pen(SystemColors.ControlDark), bound); g.DrawLine(new Pen(SystemColors.ControlLight) { DashStyle = System.Drawing.Drawing2D.DashStyle.Dot }, 5, bound.Top + 23, bound.Width - 10, bound.Top + 23); Point start = new Point(5 + 18, bound.Top + (index > 0 ? 0 : 5)); Point end = new Point(5 + 18, bound.Bottom - (index < this.DataList.Count - 1 ? 0 : 5)); g.DrawLine(new Pen(SystemColors.ControlLight), start, end); Rectangle iconRect = new Rectangle(5, bound.Top + 5, 36, 36); g.FillEllipse(Brushes.Orange, iconRect); g.DrawString(item.DateLabel, this.Font, Brushes.White, iconRect, sf); if (item.List != null) { StringFormat subSf = new StringFormat(); subSf.LineAlignment = StringAlignment.Center; Font subTitleFont = new Font("仿宋", 12, FontStyle.Bold | FontStyle.Italic) { }; int top = bound.Top + 15; for (int i = 0; i < item.List.Count; i++) { top = top + 32; DateItem subItem = item.List[i]; Rectangle subIconRect = new Rectangle(5 + 12, top + 9, 12, 12); g.FillEllipse(Brushes.Orange, subIconRect); //g.DrawEllipse(new Pen(Color.Orange) { Width=2.0f }, subIconRect); //g.DrawString((i + 1).ToString(), this.Font, Brushes.White, subIconRect, sf); subIconRect.Inflate(-2, -2); g.FillEllipse(Brushes.White, subIconRect); Rectangle subRect = new Rectangle(56, top, bound.Width - 64, 32); if (subItem.Selected) { using (var roundedRectanglePath = CreateRoundedRectanglePath(subRect, 2)) { g.FillPath(new SolidBrush(Color.FromArgb(240, 245, 249)), roundedRectanglePath); } } Rectangle subTitleRect = new Rectangle(56, top, bound.Width - 64 - 30, 32); //g.DrawRectangle(new Pen(Color.Orange), subTitleRect); //g.DrawString((i + 1) + "、" + subItem.Title, this.Font, Brushes.Red, subTitleRect, subSf); Brush subTitleBrush = Brushes.Black; g.DrawString(subItem.Date.ToString("yyyy-MM-dd"), subTitleFont, subTitleBrush, subTitleRect, subSf); //g.FillRectangle(Brushes.Red, subTitleRect); Rectangle subOptionRect = new Rectangle(bound.X + bound.Width - 34 + 4, top + 8, 16, 16); //g.FillRectangle(Brushes.Yellow, subOptionRect); g.DrawImage(this.TimeLineIcons.Images[2], subOptionRect); subItem.AddRect = subOptionRect; subItem.ClickRect = subRect; if (subItem.List != null) { for (int j = 0; j < subItem.List.Count; j++) { top = top + 32; DateTimeItem subsubItem = subItem.List[j]; //Rectangle subsubIconRect = new Rectangle(5 + 14, top + 10, 8, 8); //g.FillEllipse(Brushes.Orange, subsubIconRect); //subsubIconRect.Inflate(-2, -2); //g.FillEllipse(Brushes.White, subsubIconRect); Rectangle DateTimeItemClickRect = new Rectangle(56, top + 2, bound.Width - 64, 28); if (!string.IsNullOrEmpty(subsubItem.Summary)) DateTimeItemClickRect = new Rectangle(DateTimeItemClickRect.X, DateTimeItemClickRect.Y, DateTimeItemClickRect.Width, DateTimeItemClickRect.Height + 32); if (subsubItem.Selected) { using (var roundedRectanglePath = CreateRoundedRectanglePath(DateTimeItemClickRect, 2)) { g.FillPath(new SolidBrush(Color.FromArgb(240, 245, 249)), roundedRectanglePath); } } Brush drawTitleBrush = Brushes.Black; if (subsubItem.Selected) drawTitleBrush = Brushes.Blue; Color drawTitleColor = Color.Black; if (subsubItem.Selected) drawTitleColor = Color.Blue; if (!subsubItem.Selected) { switch (subsubItem.Level) { case ImportantLevel.Important: drawTitleColor = Color.Orange; break; case ImportantLevel.MoreImportant: drawTitleColor = Color.Brown; break; case ImportantLevel.MostImportant: drawTitleColor = Color.Red; break; } } //Rectangle subsubImgRect = new Rectangle(56 + 0, top + 7, 16, 16); ////g.FillEllipse(Brushes.Red, subsubImgRect); //g.DrawImage(this.TimeLineIcons.Images[2], subsubImgRect); Brush itemIconBrush = Brushes.Red; switch (subsubItem.Timeliness) { case Timeliness.Normal: itemIconBrush = Brushes.Green; break; case Timeliness.Yellow: itemIconBrush = Brushes.Yellow; break; case Timeliness.Orange: itemIconBrush = Brushes.Orange; break; case Timeliness.Red: itemIconBrush = Brushes.Red; break; case Timeliness.Dark: itemIconBrush = Brushes.Gray; break; case Timeliness.Black: itemIconBrush = Brushes.Black; break; } int m = 20; Rectangle subsubImgRect = Rectangle.Empty; if (subsubItem.Icon != null) { if (subsubItem.Icon.Height == 16) { m = 20; subsubImgRect = new Rectangle(56 + 0, top + 3, 16, 16); } if (subsubItem.Icon.Height == 24) { m = 28; subsubImgRect = new Rectangle(56 + 0, top + 3, 24, 24); } else { throw new Exception("只支持16*16、24*24大小的图标"); } g.DrawImage(subsubItem.Icon, subsubImgRect); } else { if(!string.IsNullOrEmpty(subsubItem.PersonName)) { m = 28; subsubImgRect = new Rectangle(56 + 0, top + 3, 24, 24); } else { m = 20; subsubImgRect = new Rectangle(56 + 0, top + 7, 16, 16); } g.FillEllipse(itemIconBrush, subsubImgRect); if (!string.IsNullOrEmpty(subsubItem.PersonName)) { Brush showNameBrush = Brushes.White; Font showNameFont = new Font("微软雅黑", 8, FontStyle.Bold); if (itemIconBrush == Brushes.Red || itemIconBrush == Brushes.Yellow) { showNameBrush = Brushes.Black; } g.DrawString(subsubItem.PersonName, showNameFont, showNameBrush, subsubImgRect, sf); } } //Rectangle subsubTitleRect = new Rectangle(56 + 20, top, bound.Width - 84 - 60, 32); Rectangle subsubTitleRect = new Rectangle(56 + m, top, bound.Width - 84 - 60, 32); //g.DrawRectangle(new Pen(Color.Orange), subsubTitleRect); //g.DrawString((i + 1) + "、" + subItem.Title, this.Font, Brushes.Red, subTitleRect, subSf); //g.DrawString(subsubItem.Title, this.Font, drawTitleBrush, subsubTitleRect, subSf); TextRenderer.DrawText(g, subsubItem.Title, this.Font, subsubTitleRect, drawTitleColor, TextFormatFlags.Left | TextFormatFlags.WordEllipsis | TextFormatFlags.VerticalCenter); if (_isEditModel && subsubItem.Selected) { //绘制删除按扭 Size subsubTitleSize = TextRenderer.MeasureText(g, subsubItem.Title, this.Font); subsubItem.EditRect = new Rectangle(subsubTitleRect.X + subsubTitleSize.Width + 2, top + 8, 16, 16); subsubItem.DeleteRect = new Rectangle(subsubTitleRect.X + subsubTitleSize.Width + 2 + 16 + 4, top + 8, 16, 16); } Rectangle subsubTimeRect = new Rectangle(bound.Width - 64, top, 56, 32); //g.FillRectangle(Brushes.Green, subsubTimeRect); //g.DrawString((i + 1) + "、" + subItem.Title, this.Font, Brushes.Red, subTitleRect, subSf); g.DrawString(subsubItem.DateTime.ToString("HH:mm:ss"), this.Font, drawTitleBrush, subsubTimeRect, subSf); if (!string.IsNullOrEmpty(subsubItem.Summary)) { Font drawSummaryFont = this.Font; Brush drawSummaryBrush = Brushes.Gray; Color drawSummaryColor = Color.Gray; if (subsubItem.Selected) { drawSummaryBrush = new SolidBrush(SystemColors.ControlDark); //drawSummaryFont = new Font(this.Font, FontStyle.Italic); drawSummaryColor = SystemColors.ControlDark; } top = top + 32; Rectangle subsubSummaryRect = new Rectangle(56, top, bound.Width - 64, 26); //g.DrawRectangle(Pens.Red, subsubSummaryRect); //g.DrawString(subsubItem.Summary, drawSummaryFont, drawSummaryBrush, subsubSummaryRect, subSf); TextRenderer.DrawText(g, subsubItem.Summary, drawSummaryFont, subsubSummaryRect, drawSummaryColor, TextFormatFlags.Left | TextFormatFlags.WordEllipsis | TextFormatFlags.VerticalCenter); } subsubItem.ClickRect = DateTimeItemClickRect; g.DrawLine(new Pen(SystemColors.ControlLight) { DashStyle = System.Drawing.Drawing2D.DashStyle.Dot }, 56, top + 32, bound.Width - 10, top + 32); if (_isEditModel && subsubItem.Selected) { //switch (subsubItem.ButtonState) //{ // case 1: // g.FillRectangle(new SolidBrush(Color.Orange), subsubItem.EditRect); // g.FillRectangle(new SolidBrush(Color.FromArgb(240, 245, 249)), subsubItem.DeleteRect); // break; // case 2: // g.FillRectangle(new SolidBrush(Color.FromArgb(240, 245, 249)), subsubItem.EditRect); // g.FillRectangle(new SolidBrush(Color.Orange), subsubItem.DeleteRect); // break; // default: // g.FillRectangle(new SolidBrush(Color.FromArgb(240, 245, 249)), subsubItem.EditRect); // g.FillRectangle(new SolidBrush(Color.FromArgb(240, 245, 249)), subsubItem.DeleteRect); // break; //} g.FillRectangle(new SolidBrush(Color.FromArgb(240, 245, 249)), subsubItem.EditRect); g.FillRectangle(new SolidBrush(Color.FromArgb(240, 245, 249)), subsubItem.DeleteRect); //绘制编辑按扭 g.DrawImage(this.TimeLineIcons.Images[0], subsubItem.EditRect); //绘制删除按扭 g.DrawImage(this.TimeLineIcons.Images[1], subsubItem.DeleteRect); } } } } } StringFormat sf2 = new StringFormat(); sf2.LineAlignment = StringAlignment.Center; Rectangle itemTitleRect = new Rectangle(56, bound.Top + 5, bound.Width - 64, 36); //g.DrawRectangle(new Pen(Color.Orange), itemTitleRect); //g.DrawString("共计 " + (item.List == null ? 0 : item.List.Count()) + " 个事项", this.Font, Brushes.Black, itemTitleRect, sf2); }
绘制滚动条这里先贴一张不带上下箭头的滚动条截图
主要涉及到的计算如下:
/// <summary> /// 计算 Thumb 的高 /// </summary> /// <returns></returns> private int GetThumbHeight() { int disHeight = this.BorderStyle == NBorderStyle.None ? this.Height : this.Height - 2; if (MaxnumHeight == 0 || MaxnumHeight <= disHeight) return disHeight; int thumbHeight = (int)(disHeight * 1.0d / MaxnumHeight * disHeight); if (thumbHeight < 20) thumbHeight = 20; largeChange = DisplayRectangle.Height - thumbHeight; return thumbHeight; } /// <summary> /// 绘制 Thumb 及计算 Thumb 的位置 /// </summary> /// <param name="g"></param> private void DrawScrollThumb(Graphics g) { int thumbOffsetY = (int)(scrollerBarValue * 1.0 / scrollerBarMaxnum * (scrollerRect.Height - thumbRect.Height)); thumbRect = new Rectangle(scrollerRect.X, scrollerRect.Y + thumbOffsetY, scrollerRect.Width, scrollerThumbHeight); g.FillRectangle(Brushes.Gray, thumbRect); } /// <summary> /// 计算所以元素 累加起来总的高度(包话不可见部分) /// </summary> private int MaxnumHeight { get { int maxnum = 0; Graphics g = null; if (this.DataList != null) { for (int i = 0; i < this.DataList.Count; i++) { var item = this.DataList[i]; maxnum += MeasureItemBound(g, i, item).Height; } } return maxnum; } }
第四步、处理控件中的事件
首先我们重写一个Click事件对外开放,usercontrol 自控件本身就有一个click事件,这里我们在定义事件的属性前面添加 new 关键字表达用新的事件属性来替换掉原有的click事件。代码如下:
private static readonly object itemEventObject = new object(); /// <summary> /// 重写一个Click事件对外开放 /// </summary> public new event EventHandler<TimeLineEventArgs> Click { add { Events.AddHandler(itemEventObject, value); } remove { Events.RemoveHandler(itemEventObject, value); } }
这里可以看到我们定义了一个 TimeLineEventArgs 实体类,里面主要有 Command 命令 Data 数据两个属性,在控件内类我们通过判断点击鼠标的位置来判断触发哪个元素,哪种操作类型的事件,控件在编辑模式下有 添加,编辑,删除三种事件类型通过 Command 传递给外部调用,实现代码如下:
protected override void OnMouseClick(MouseEventArgs e) { base.OnMouseClick(e); if (!newItemModel) { //是否继续 当选择项为 DateItem 时 就跳过 DateTimeItem 循环 bool isChidrenContinueForeach = true; foreach(MonthItem monthItem in DataList) { foreach (DateItem dateItem in monthItem.List) { if (dateItem.ClickRect.Contains(e.Location)) { var eventArg = new TimeLineEventArgs(dateItem); if (_isEditModel && dateItem.AddRect != Rectangle.Empty && dateItem.AddRect.Contains(e.Location)) { eventArg.Command = "new"; } DoItemClick(eventArg); isChidrenContinueForeach = false; } foreach (DateTimeItem dateTimeItem in dateItem.List) { if (isChidrenContinueForeach && dateTimeItem.ClickRect.Contains(e.Location)) { var eventArg = new TimeLineEventArgs(dateTimeItem); eventArg.Command = "detail"; if (_isEditModel && dateTimeItem.EditRect != Rectangle.Empty && dateTimeItem.EditRect.Contains(e.Location)) { eventArg.Command = "edit"; } if (_isEditModel && dateTimeItem.DeleteRect != Rectangle.Empty && dateTimeItem.DeleteRect.Contains(e.Location)) { eventArg.Command = "delete"; } DoItemClick(eventArg); isChidrenContinueForeach = false; } if (!isChidrenContinueForeach) break; } if (!isChidrenContinueForeach) break; } } } else { if (newItemRect.Contains(e.Location)) { var eventArg = new TimeLineEventArgs(null); eventArg.Command = "new"; DoItemClick(eventArg); } } }
控件中处理滚动条事件代码如下:
/// <summary> /// 滑动滚动条时 由 OnMouseMove 触发 /// </summary> /// <param name="e"></param> private void DoMouseScrolling(MouseEventArgs e) { if (!scrollerBarVisable) return; int d = mouseDownOffset.Y + e.Location.Y - mouseDownPos.Y; scrollerBarValue = (int)(d * 1.0 / (scrollerRect.Height - thumbRect.Height) * scrollerBarMaxnum); if (scrollerBarValue < 0) { scrollerBarValue = 0; } if (scrollerBarValue > 100) { scrollerBarValue = 100; } int drawOffsetY = (int)(-scrollerBarValue * 1.0 / scrollerBarMaxnum * (MaxnumHeight + smallChange - this.Height)); drawPositionOffset = new Point(0, drawOffsetY); this.Invalidate(); } /// <summary> /// 处理鼠标滚轮事件 由 OnMouseWheel 触发 /// </summary> /// <param name="e"></param> private void DoMouseWheel(MouseEventArgs e) { if (!scrollerBarVisable) return; int olePositionOffsetY = drawPositionOffset.Y; int mouseWheelScrollLines = e.Delta / NativeMethods.WHEEL_DELTA; int newPositionOffsetY = drawPositionOffset.Y + mouseWheelScrollLines * smallChange; int d = newPositionOffsetY - olePositionOffsetY; scrollerBarValue += (int)(-d * 1.0 / (MaxnumHeight + smallChange - this.Height) * scrollerBarMaxnum); if (scrollerBarValue < 0) { scrollerBarValue = 0; } if (scrollerBarValue > 100) { scrollerBarValue = 100; } int drawOffsetY = (int)(-scrollerBarValue * 1.0 / scrollerBarMaxnum * (MaxnumHeight + smallChange - this.Height)); drawPositionOffset = new Point(0, drawOffsetY); this.Invalidate(); }
第五步、控件性能优化
关于控件性能的优化主要体现在判断元素是否处于可视区域,如果不在可视区域则不绘制该元素及其子元素,特别是针对控件拥有上百上千甚至上万元素时,绘制所以元素系统开销很大(内存占用和CPU计算),往往一个控件在可视区域能展现出来的元素个数是有限的。
/// <summary> /// 判断是否在可视区域的方法 /// </summary> /// <param name="bound"></param> /// <returns></returns> private bool IsRectangleVisible(Rectangle bound) { bool isItemDrawModel = true; if (bound.Bottom < this.DisplayRectangle.Top || bound.Top > this.DisplayRectangle.Bottom) { isItemDrawModel = false; } return isItemDrawModel; } /// <summary> /// 在绘制元素添加判断,不在可视范围内跳过不进行绘制 /// </summary> /// <param name="g"></param> private void DrawTimelineItems(Graphics g) { position = 0; g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality; if (DataList != null) { for (int index = 0; index < DataList.Count; index++) { MonthItem item = DataList[index]; item.Bound = MeasureItemBound(g, index, item); if (IsRectangleVisible(item.Bound)) { DrawTimelineItem(g, index, item); } } } g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.Default; }
以上就是时间控件的开发过程,其中界面设计参照了一下开源中国码云的个人主页动态:
完整代码可以点击上面的地址进入链接后下载,觉得不错希望可以给个赞,谢谢!