WinForm侧边栏导航控件:从零实现可折叠高亮与DPI适配 简介这是一份面向C# WinForm开发者的侧边导航栏控件资源参考主流网站导航UI设计适合需要为桌面应用快速搭建左侧导航菜单的初中级开发者。控件采用扁平化风格图标、尺寸位置、文字颜色与样式均可灵活调整并附带VS2017可直接运行的Demo兼容.NET 2.0框架便于在旧项目中集成或作为自定义控件学习参考。压缩包共37个文件约1.06MB以cs源码、png图标、resx与resources资源文件为主另含sln解决方案、csproj工程文件及编译产物结构完整可直接打开调试。目前已有7753人学习下载说明该控件在实际项目中具备一定参考价值。需要说明的是导航项呈现为非树形结构作者也坦言部分能力受限的问题未能解决但不影响学习与常规应用读者可借此掌握自绘导航控件的布局、分组与事件处理思路。1. WinForm 侧边栏导航从一堆 Button 到可折叠导航控件的落地路径做过 C# 上位机或者内部管理系统的同行大概都有这个经历主界面左侧要放一排导航按钮刚开始用 Panel 堆几个 Button能跑加到十几个菜单项之后滚动、折叠、高亮、图标对齐全乱套改一处崩三处。WinForm 自带的 ToolStrip、TabControl 都不是为「侧边导航」这个场景设计的第三方控件库又重又贵很多时候项目根本不允许引入。于是「自己写一个 WinForm 侧边栏导航控件」就成了绕不开的一步。这篇笔记讲的就是这件事怎么从零做一个能折叠、能高亮、能带子菜单、能适配 DPI 的左侧导航栏控件以及在这个过程中真正会翻车的地方。适合两类人一类是刚接手 WinForm 项目、被要求「把左边菜单做得像样点」的新手另一类是写过几版导航栏、每次都在重绘和事件冒泡上踩坑的老手。下面按「控件怎么设计 → 怎么画出来 → 怎么接业务 → 怎么避坑 → 怎么进阶」的顺序推下去每一步都能直接抄进项目里跑。2. 导航控件怎么拆从容器、项、渲染三层设计说起2.1 为什么不用 ToolStrip 和 TabControl 硬凑先说选型。WinForm 里能直接当导航用的现成控件就那么几个但都不对口。ToolStrip 本质是工具栏它的布局逻辑是横向优先竖排要设LayoutStyle VerticalStackWithOverflow一旦项多了会冒出溢出箭头而且它没有「选中态」这个概念高亮得自己画。TabControl 是页签容器标签位置固定在边缘做左侧导航时标签文字是竖排的可读性差而且它把「导航」和「内容区」耦合在一起业务上不好解耦。真正合适的做法是自绘控件一个继承自Panel或Control的容器内部维护一个导航项集合每一项自己负责绘制。这样布局、状态、事件全在掌控里。常见做法是分三层容器层SidebarNav负责整体布局、滚动、折叠展开、项集合管理。项层NavItem一个数据类存标题、图标、子项、选中态、展开态、Tag。渲染层在容器的OnPaint里统一绘制或者每个项做成独立子控件。我一般选「容器统一绘制」这条路因为项做成独立控件后几十个项就是几十个窗口句柄滚动和重绘会明显卡顿而且事件冒泡处理起来很烦。2.2 导航项的数据结构怎么定项层不要用控件用纯数据类。下面这个结构是我在几个项目里收敛出来的够用且不臃肿public class NavItem { public string Id { get; set; } // 业务唯一标识用于路由 public string Text { get; set; } // 显示文字 public Image Icon { get; set; } // 图标建议 20x20 或 24x24 public string IconKey { get; set; } // 图标资源键便于换肤 public ListNavItem Children { get; set; } new ListNavItem(); public object Tag { get; set; } // 挂业务对象比如窗体类型 public bool IsExpanded { get; set; } // 有子项时是否展开 public bool IsSelected { get; set; } // 是否当前选中 public bool Visible { get; set; } true; // 支持按权限隐藏 public int Level { get; set; } // 层级0 为顶级 }Id和Tag是关键。Id用来做路由匹配避免用显示文字当键——文字一改路由全断。Tag我习惯直接挂Type点击时用Activator.CreateInstance反射出窗体这样新增菜单不用改 switch。Level决定缩进子项缩进 20px 是视觉上比较舒服的值。2.3 容器要暴露哪些属性和事件容器层对外要干净。属性上至少要有Items、SelectedId、ItemHeight、Indent、Collapsed整体折叠成图标条、ShowIcons。事件上核心就两个ItemSelected选中变化和ItemExpanded展开折叠。不要暴露太多绘制细节否则调用方会依赖内部实现后面改渲染就痛苦。public class SidebarNav : Control { public event EventHandlerNavItemEventArgs ItemSelected; public event EventHandlerNavItemEventArgs ItemExpanded; private readonly ListNavItem _items new ListNavItem(); public IReadOnlyListNavItem Items _items; public string SelectedId { get; private set; } public int ItemHeight { get; set; } 44; public int Indent { get; set; } 20; public bool Collapsed { get; set; } false; public bool ShowIcons { get; set; } true; public void SetItems(IEnumerableNavItem items) { /* 赋值并重算布局 */ } public void SelectItem(string id) { /* 设置选中并触发事件 */ } }这里有个细节Control默认不支持滚动项多了要自己处理。要么继承ScrollableControl要么自己维护一个_scrollOffset配合鼠标滚轮。我倾向后者因为ScrollableControl的自动滚动条样式和自绘风格不搭还得额外处理。3. 把导航栏画出来OnPaint 里的坐标、状态与 DPI 处理3.1 双缓冲和绘制入口自绘控件第一件事是开双缓冲否则滚动和悬停时闪得没法看。在构造函数里设public SidebarNav() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.UserPaint | ControlStyles.ResizeRedraw, true); BackColor Color.FromArgb(33, 37, 43); // 深色侧边栏底色 ForeColor Color.FromArgb(200, 205, 210); }AllPaintingInWmPaint配合OptimizedDoubleBuffer是 WinForm 自绘的标准组合能消掉绝大部分闪烁。ResizeRedraw保证尺寸变化时重绘不然拉伸窗口会出现残影。3.2 每一项的绘制顺序和状态色绘制逻辑集中在OnPaint按「背景 → 选中态 → 悬停态 → 图标 → 文字 → 展开箭头」的顺序画。状态色建议定成常量别散在代码里protected override void OnPaint(PaintEventArgs e) { var g e.Graphics; g.SmoothingMode SmoothingMode.AntiAlias; g.TextRenderingHint TextRenderingHint.ClearTypeGridFit; int y -_scrollOffset; foreach (var item in FlattenVisible(_items)) { var rect new Rectangle(0, y, Width, ItemHeight); if (rect.Bottom 0 || rect.Top Height) { y ItemHeight; continue; } // 选中态左侧 3px 高亮条 整行浅色背景 if (item.IsSelected) { using (var b new SolidBrush(Color.FromArgb(45, 55, 70))) g.FillRectangle(b, rect); using (var b new SolidBrush(Color.FromArgb(64, 158, 255))) g.FillRectangle(b, new Rectangle(0, y, 3, ItemHeight)); } else if (rect.Contains(PointToClient(MousePosition))) { using (var b new SolidBrush(Color.FromArgb(40, 46, 54))) g.FillRectangle(b, rect); } // 图标 文字 箭头 DrawItemContent(g, item, rect); y ItemHeight; } }FlattenVisible把树形结构按展开状态拍平成一维列表绘制和命中测试都用它保证两边逻辑一致——这是避免「点到的项和看到的项对不上」的关键。3.3 文字、图标和展开箭头的对齐文字用TextRenderer.DrawText而不是Graphics.DrawString前者渲染质量更接近系统原生中文不会发虚。图标用g.DrawImage并指定目标矩形避免原图尺寸不一致导致错位。展开箭头别用字符「▼」字符在不同字体下基线飘自己画两条线更稳private void DrawArrow(Graphics g, Rectangle rect, bool expanded) { var cx rect.Right - 18; var cy rect.Top rect.Height / 2; using (var pen new Pen(Color.FromArgb(150, 160, 170), 1.6f)) { if (expanded) { g.DrawLine(pen, cx - 4, cy - 2, cx, cy 2); g.DrawLine(pen, cx, cy 2, cx 4, cy - 2); } else { g.DrawLine(pen, cx - 2, cy - 4, cx 2, cy); g.DrawLine(pen, cx 2, cy, cx - 2, cy 4); } } }3.4 高 DPI 下不糊的三个设置WinForm 在高分屏上默认会做位图拉伸自绘控件直接糊成一片。要处理三处一是 app.manifest 里声明 DPI 感知级别二是窗体构造函数里调Application.SetHighDpiMode.NET Core/5 才有三是绘制时所有尺寸按DeviceDpi / 96f缩放。private float Scale DeviceDpi / 96f; // 绘制时 int itemH (int)(ItemHeight * Scale); int indent (int)(Indent * Scale);图标资源最好准备 1x 和 2x 两套或者用矢量绘制。直接放大 20x20 的 PNG 在 150% 缩放下边缘会发毛这是血泪经验。4. 接业务选中路由、折叠动画与权限过滤4.1 点击命中测试与选中路由命中测试要和绘制用同一份拍平列表否则展开折叠后坐标就错位。鼠标事件里算出索引反查 NavItemprotected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); int index (e.Y _scrollOffset) / ItemHeight; var flat FlattenVisible(_items).ToList(); if (index 0 || index flat.Count) return; var item flat[index]; if (item.Children.Count 0) { item.IsExpanded !item.IsExpanded; ItemExpanded?.Invoke(this, new NavItemEventArgs(item)); } else { SelectItem(item.Id); } Invalidate(); } public void SelectItem(string id) { foreach (var it in FlattenVisible(_items)) it.IsSelected it.Id id; SelectedId id; ItemSelected?.Invoke(this, new NavItemEventArgs(FindById(id))); Invalidate(); }路由交给外部处理控件只负责抛ItemSelected。主窗体订阅后根据Tag里的窗体类型切换内容区这样导航控件和业务窗体彻底解耦。4.2 折叠动画用 Timer 还是直接跳变整体折叠收成 60px 图标条和子菜单展开要不要动画我的建议是子菜单展开直接跳变整体折叠可以加个 120ms 的宽度动画。Timer 间隔设 15ms每帧改 8~10px超过就停。动画期间要禁用重入否则快速点击会叠出多个 Timer。private Timer _animTimer; private int _targetWidth; private void AnimateWidth(int target) { _targetWidth target; if (_animTimer null) { _animTimer new Timer { Interval 15 }; _animTimer.Tick (s, e) { int diff _targetWidth - Width; if (Math.Abs(diff) 8) { Width _targetWidth; _animTimer.Stop(); } else Width Math.Sign(diff) * 8; }; } _animTimer.Start(); }注意动画改的是控件Width父容器的布局要能跟着走否则内容区不会自动让位。用Dock Left加Splitter或者TableLayoutPanel都行别用绝对坐标。4.3 按权限动态过滤菜单项内部系统经常要按角色显示不同菜单。过滤放在数据层别在绘制层判断否则拍平列表和命中测试都要重复写权限逻辑。做法是加载时递归标记Visibleprivate void ApplyPermission(ListNavItem items, HashSetstring allowedIds) { foreach (var item in items) { item.Visible allowedIds.Contains(item.Id); if (item.Children.Count 0) { ApplyPermission(item.Children, allowedIds); // 子项全隐藏时父项也隐藏 if (!item.Children.Any(c c.Visible)) item.Visible false; } } }FlattenVisible里只遍历Visible true的项这样权限、绘制、命中三处自动一致。5. 避坑与排查自绘导航栏最容易翻车的 5 个点5.1 滚动后点击错位现象项多了一屏放不下滚动之后点第 5 项选中的却是第 3 项。原因命中测试没把_scrollOffset算进去或者绘制和命中用了两份不同的列表。解决统一用FlattenVisible生成列表命中时index (e.Y _scrollOffset) / ItemHeight绘制时起始y -_scrollOffset两边严格对称。5.2 悬停高亮闪烁现象鼠标移动时整条导航栏闪。原因OnMouseMove里每次都Invalidate()整个控件且没开双缓冲。解决开OptimizedDoubleBuffer并且只在悬停项发生变化时才重绘用一个_hoverIndex记录上一次的值相同就直接 return。5.3 中文文字发虚、被截断现象文字边缘模糊或者长菜单名后面直接没了。原因用了Graphics.DrawString且没设TextRenderingHint或者绘制矩形宽度没减去图标和箭头的占位。解决改用TextRenderer.DrawText并传TextFormatFlags.EndEllipsis矩形宽度算成Width - iconWidth - arrowWidth - padding。5.4 展开子菜单后父项文字被顶掉现象展开子项父项那一行文字消失或错位。原因拍平列表时把父项和子项都算进去了但绘制时父项的Level缩进没重置或者y累加逻辑在递归里重复加了。解决拍平函数里每项只加一次ItemHeight缩进按Level * Indent单独算别混进y。5.5 高 DPI 下图标和文字不同步缩放现象150% 缩放下图标变大了文字还是原大小或者反过来。原因只对部分尺寸做了Scale换算。解决把ItemHeight、Indent、图标目标矩形、字体大小全部走同一个Scale系数字体用new Font(Font.FontFamily, Font.Size * Scale)重建别只改一处。6. 进阶把导航栏做成可配置、可换肤的独立控件库做到上面那步导航栏已经能用了。但如果项目里多个窗体都要用或者要交付给别的团队就该把它抽成独立类库。我一般的做法是建一个NavSidebar.Controls类库项目把SidebarNav、NavItem、NavItemEventArgs和一套主题类放进去主项目引用 DLL 即可。主题这块别把颜色写死在OnPaint里。定义一个NavTheme类把背景色、选中色、悬停色、文字色、高亮条色、字体全收进去public class NavTheme { public Color Background { get; set; } Color.FromArgb(33, 37, 43); public Color SelectedBg { get; set; } Color.FromArgb(45, 55, 70); public Color HoverBg { get; set; } Color.FromArgb(40, 46, 54); public Color AccentBar { get; set; } Color.FromArgb(64, 158, 255); public Color TextColor { get; set; } Color.FromArgb(200, 205, 210); public Color ArrowColor { get; set; } Color.FromArgb(150, 160, 170); public Font ItemFont { get; set; } new Font(微软雅黑, 10f); public static NavTheme Dark new NavTheme(); public static NavTheme Light new NavTheme { Background Color.FromArgb(245, 246, 248), SelectedBg Color.FromArgb(225, 235, 250), HoverBg Color.FromArgb(235, 238, 242), TextColor Color.FromArgb(50, 55, 65), ArrowColor Color.FromArgb(120, 130, 140) }; }控件加一个Theme属性OnPaint里全部从Theme取色。换肤时只改这一个属性再Invalidate()不用动绘制代码。这套结构我在三个项目里复用下来新增一个主题基本就是填几个颜色值的事。验证方法也简单写个测试窗体左边放导航栏右边放Panel当内容区菜单项挂几个空窗体跑起来点一遍重点看滚动、展开、DPI 切换把系统缩放从 100% 调到 150% 再调回来这三件事。这三关过了基本就能进生产。最后说个我自己的习惯每次写完导航控件我都会把FlattenVisible单独拎出来写几个单元测试喂进去一棵三层树断言拍平后的顺序和数量。因为这个函数是绘制、命中、权限三处的公共依赖它错了表现出的症状会五花八门排查起来最费时间。提前把它测住后面省下的时间远超写测试的那十分钟。希望帮到你。本文还有配套的精品资源点击获取