ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WinForm左侧导航栏实现:自绘控件与页面切换联动

WinForm左侧导航栏实现:自绘控件与页面切换联动 简介这是一份面向C# WinForm开发者的侧边导航栏控件资源参考主流网站导航UI设计适合需要为桌面应用快速搭建左侧导航菜单的初中级开发者。控件采用扁平化风格图标、尺寸位置、文字颜色与样式均可灵活调整并附带VS2017可直接运行的Demo兼容.NET 2.0框架便于在旧项目中集成或作为自定义控件学习参考。压缩包共37个文件约1.06MB以cs源码、png图标、resx与resources资源文件为主另含sln解决方案、csproj工程文件及编译产物结构完整可直接打开调试。目前已有7753人学习下载。需要说明的是导航项呈现为非树形结构作者也坦言部分能力边界问题未能解决但不影响学习与常规应用。读者可从中获取完整的控件封装思路、分组项实现方式与界面定制方法适合作为WinForm导航模块的练手与改造基础。1. 从一次 WinForm 界面重构说起左侧导航栏到底该怎么选去年接手一个工控上位机项目主界面用的是 WinForm原本顶部一排按钮功能加到二十多个之后整个界面挤成一团客户现场操作员抱怨“找个按钮要盯半天”。后来改成左侧导航栏加右侧内容区的布局操作路径一下子清晰了。这件事让我意识到WinForm 虽然老但做桌面端工具尤其是上位机、MES 客户端、数据采集软件这类场景依然是主力。而侧边栏导航控件几乎是每个 WinForm 项目都绕不开的一环。这份资源包围绕的就是这个需求在 C# WinForm 里实现一个可用的左侧导航栏。它不是一个单独的控件库而是一套可以直接嵌入项目的实现方案包含导航按钮的绘制、选中状态切换、图标与文字排版、折叠展开逻辑以及和右侧内容面板的联动。适合正在做 WinForm 项目、需要把功能入口从顶部或菜单栏迁移到侧边栏的开发者也适合想自己动手写一个轻量导航控件、不想引入重型 UI 框架的人。下面我从选型、实现、参数、踩坑几个角度把这份资源拆开讲清楚。2. 导航栏方案选型自绘控件、TabControl 改造还是第三方库2.1 三种常见路线的取舍在 WinForm 里做左侧导航栏常见做法有三类。第一类是直接用TabControl把选项卡标签设成左对齐再隐藏选项卡头用按钮切换页面。这种做法上手快但选项卡头隐藏后选中状态、悬停效果、图标排版都得自己补改到后面往往比自绘还麻烦。第二类是引入第三方 UI 库比如 SunnyUI、HZHControls、DevExpress 的 AccordionControl。这类库功能全但依赖重有些还涉及授权项目打包体积也会上去。第三类就是自绘一个Panel加一组自定义按钮完全掌控绘制逻辑和交互细节。这份资源走的是第三条路。核心思路是用一个Panel作为导航容器里面动态添加自定义的NavButton控件每个按钮自己处理OnPaint绘制背景、图标、文字和选中指示条。右侧内容区用一个Panel做容器切换时把对应的UserControl或Form嵌进去。这样做的好处是零外部依赖代码量可控样式想怎么调就怎么调。代价是图标资源、字体、颜色这些都要自己管没有现成的主题包。提示如果你的项目已经用了 DevExpress 或 SunnyUI优先用它们自带的导航控件没必要重复造轮子。这份资源更适合从零起步、或者对界面有定制要求、不想被第三方库绑定的场景。2.2 资源包里的文件结构与职责资源包解压后主要包含以下几个部分。NavButton.cs是自定义导航按钮的核心类继承自Control重写了OnPaint、OnMouseEnter、OnMouseLeave、OnMouseDown、OnMouseUp等事件。SideNavPanel.cs是导航容器负责管理按钮集合、处理选中互斥、触发页面切换事件。NavItem.cs是一个数据模型描述每个导航项的文字、图标、关联页面类型。MainForm.cs是示例主窗体演示如何把导航栏和内容区拼起来。Resources文件夹放图标 pngThemes文件夹放颜色配置。文件职责关键点NavButton.cs单个导航按钮的绘制与交互选中态、悬停态、按下态三套颜色SideNavPanel.cs导航容器管理按钮列表互斥选中、事件冒泡、滚动支持NavItem.cs导航项数据模型文字、图标、页面类型、是否可用MainForm.cs示例主窗体导航栏与内容区联动Themes/颜色与字体配置深色/浅色两套预设2.3 为什么不用 TabControl 直接改有人会问TabControl 设成左对齐再把ItemSize调大不就能当导航栏用吗。实测下来有几个硬伤。一是选项卡头的绘制受系统主题影响不同 Windows 版本下高度和边距不一致想统一很难。二是选中态只有系统默认的那一种想加左侧竖条指示器、想改悬停背景色都得走DrawItem自绘代码量和自绘按钮差不多。三是 TabControl 的页面切换是整体切换如果右侧内容区需要保留滚动位置或者做懒加载控制起来不灵活。所以这份资源没有走 TabControl 路线而是从Control基类开始自绘虽然多写了一些代码但后面调样式和加动效的时候省心很多。3. 动手实现从 NavButton 自绘到页面切换联动3.1 NavButton 的绘制逻辑与关键参数先看导航按钮的绘制。核心是重写OnPaint按状态取不同颜色依次画背景、左侧指示条、图标、文字。下面是一段简化后的代码保留了关键结构。protected override void OnPaint(PaintEventArgs e) { Graphics g e.Graphics; g.SmoothingMode SmoothingMode.AntiAlias; g.TextRenderingHint TextRenderingHint.ClearTypeGridFit; // 根据当前状态取背景色 Color backColor _isSelected ? _selectedBackColor : _isHover ? _hoverBackColor : _normalBackColor; using (SolidBrush brush new SolidBrush(backColor)) { g.FillRectangle(brush, ClientRectangle); } // 选中时左侧画一条 3px 指示条 if (_isSelected) { using (SolidBrush bar new SolidBrush(_indicatorColor)) { g.FillRectangle(bar, 0, 0, 3, Height); } } // 画图标图标大小固定 20x20左边距 16px if (_icon ! null) { g.DrawImage(_icon, new Rectangle(16, (Height - 20) / 2, 20, 20)); } // 画文字左边距 48px垂直居中 using (StringFormat sf new StringFormat()) { sf.LineAlignment StringAlignment.Center; sf.Alignment StringAlignment.Near; using (SolidBrush textBrush new SolidBrush(_isSelected ? _selectedTextColor : _normalTextColor)) { g.DrawString(_text, _font, textBrush, new RectangleF(48, 0, Width - 56, Height), sf); } } }这段代码里几个参数值得注意。_indicatorColor是选中指示条的颜色默认取主题色宽度写死 3px如果你想要更细或更粗改FillRectangle的第三个参数即可。图标位置固定在左边距 16px、垂直居中图标尺寸 20x20如果你的图标是 24x24 或 32x32需要同步调整DrawImage的目标矩形和文字左边距否则图标和文字会重叠。文字区域从 48px 开始右边留 8px 余量防止长文字贴边。字体默认用SystemFonts.DefaultFont建议在主题配置里统一指定比如微软雅黑 10pt避免不同机器上字体差异导致排版错位。3.2 SideNavPanel 的按钮管理与互斥选中导航容器负责把多个 NavButton 管起来核心是维护一个ListNavButton并在某个按钮被点击时取消其他按钮的选中态同时触发页面切换事件。public class SideNavPanel : Panel { private ListNavButton _buttons new ListNavButton(); public event EventHandlerNavItemEventArgs NavItemSelected; public void AddNavItem(NavItem item) { NavButton btn new NavButton(item); btn.Dock DockStyle.Top; btn.Height 48; // 每个导航项高度 48px btn.Click (s, e) { SetSelected(btn); NavItemSelected?.Invoke(this, new NavItemEventArgs(item)); }; _buttons.Add(btn); Controls.Add(btn); btn.BringToFront(); // 保证 Dock 顺序正确 } private void SetSelected(NavButton target) { foreach (var btn in _buttons) { btn.IsSelected (btn target); btn.Invalidate(); // 触发重绘 } } }这里有几个细节。Dock DockStyle.Top配合BringToFront可以保证按钮从上往下排列后添加的按钮不会盖住先添加的。按钮高度设成 48px这是桌面端导航项比较舒服的高度触摸屏场景可以调到 56px 或 60px。SetSelected里遍历所有按钮只把目标按钮设为选中然后调Invalidate触发重绘。注意Invalidate不要写成RefreshRefresh会强制立即重绘频繁调用会闪屏Invalidate只是标记区域等系统下次重绘时统一处理性能更好。3.3 页面切换与内容区嵌入右侧内容区的切换常见做法是预先创建好所有UserControl实例切换时只显示对应的那个隐藏其他的。这样做切换速度快但启动时内存占用高。另一种做法是懒加载第一次切换到某个页面时才创建实例之后缓存起来。资源包里用的是懒加载加缓存的方式。private DictionaryType, UserControl _pageCache new DictionaryType, UserControl(); private void OnNavItemSelected(object sender, NavItemEventArgs e) { Type pageType e.Item.PageType; if (!_pageCache.ContainsKey(pageType)) { UserControl page (UserControl)Activator.CreateInstance(pageType); page.Dock DockStyle.Fill; _pageCache[pageType] page; contentPanel.Controls.Add(page); } // 隐藏所有已缓存页面只显示目标页面 foreach (var kv in _pageCache) { kv.Value.Visible (kv.Key pageType); } }Activator.CreateInstance要求页面类型有无参构造函数如果你的页面需要传参可以在 NavItem 里加一个工厂委托或者用依赖注入容器来创建。Dock DockStyle.Fill让页面自动填满内容区。切换时遍历缓存只显示目标页面。这里有个坑如果页面里有定时器或者后台线程隐藏时不会自动停止需要在页面的VisibleChanged事件里自己处理暂停和恢复否则切走了还在跑资源白白消耗。3.4 图标资源与主题配置图标建议用 png 格式背景透明尺寸统一 20x20 或 24x24。资源包里 Themes 文件夹下有两个 xml 文件分别对应深色和浅色主题里面定义了正常背景色、悬停背景色、选中背景色、指示条颜色、文字颜色、字体名称和字号。加载主题的代码大致如下。public static class ThemeManager { public static NavTheme Current { get; private set; } public static void Load(string themeName) { string path Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Themes, themeName .xml); XDocument doc XDocument.Load(path); Current new NavTheme { NormalBack ColorTranslator.FromHtml(doc.Root.Element(NormalBack).Value), HoverBack ColorTranslator.FromHtml(doc.Root.Element(HoverBack).Value), SelectedBack ColorTranslator.FromHtml(doc.Root.Element(SelectedBack).Value), Indicator ColorTranslator.FromHtml(doc.Root.Element(Indicator).Value), NormalText ColorTranslator.FromHtml(doc.Root.Element(NormalText).Value), SelectedText ColorTranslator.FromHtml(doc.Root.Element(SelectedText).Value), FontName doc.Root.Element(FontName).Value, FontSize float.Parse(doc.Root.Element(FontSize).Value) }; } }主题文件放在输出目录的 Themes 子文件夹下打包安装程序时要记得把整个文件夹带上否则运行时会报找不到文件。颜色用十六进制字符串存比如#FF2D89EFColorTranslator.FromHtml能直接解析。字体名称建议用系统自带字体比如“微软雅黑”或“Segoe UI”不要用第三方字体否则客户机器上没装会回退到默认字体排版就乱了。4. 避坑与排查导航栏开发中最容易翻车的五个点4.1 按钮闪烁严重鼠标划过时整条导航栏都在抖现象是鼠标在导航按钮上移动时按钮背景色切换伴随明显闪烁快速划过时整个导航栏都在抖。原因是OnMouseEnter和OnMouseLeave里直接调了Refresh强制立即重绘而 WinForm 的默认双缓冲没开。解决办法是在 NavButton 和 SideNavPanel 的构造函数里设置SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true)开启双缓冲。同时把Refresh换成Invalidate让系统合并重绘请求。改完之后闪烁基本消失快速划过也稳了。4.2 图标和文字在不同 DPI 缩放下错位现象是在 125% 或 150% 缩放的显示器上图标和文字挤在一起或者间距过大。原因是代码里写死的像素值没有随 DPI 缩放。解决办法是重写OnPaint时用Graphics.DpiX / 96f算出缩放比例把图标位置、文字左边距、按钮高度都乘以这个比例。或者更省事的做法在 app.manifest 里声明 DPI 感知让系统统一缩放但这样在高分屏上界面会有点糊。推荐前者虽然多写几行代码但清晰度有保证。4.3 导航按钮的 Click 事件不触发现象是点击导航按钮没反应页面不切换。排查下来通常是两个原因。一是按钮上盖了别的控件比如图标用的PictureBox没有设置Enabled false或者Click事件没透传鼠标点在了 PictureBox 上而不是按钮本身。解决办法是图标不要用子控件直接在OnPaint里画或者把子控件的Click事件转发给父按钮。二是Dock顺序问题后添加的按钮盖住了先添加的点击区域被遮挡。用BringToFront调整顺序或者改用FlowLayoutPanel加FlowDirection.TopDown来管理布局。4.4 页面切换后旧页面的定时器还在跑现象是切到别的页面后CPU 占用没降下来日志还在刷。原因是页面里的Timer或者后台线程没有随页面隐藏而暂停。解决办法是在页面的VisibleChanged事件里判断Visible属性为 false 时停掉定时器为 true 时重新启动。如果页面里有Task在跑循环需要加一个CancellationTokenSource隐藏时取消显示时重新创建。这个坑在数据采集类上位机里特别常见切走了还在读串口白白占资源。4.5 打包安装后主题文件丢失现象是开发机上跑得好好的打包成安装程序装到客户机器上一启动就报FileNotFoundException找不到 Themes 下的 xml。原因是打包时只带了 exe 和 dll没把 Themes 文件夹和图标资源带进去。解决办法是在安装程序项目里把 Themes 文件夹和 Resources 文件夹标记为“内容”并设置“始终复制”或者用 Costura.Fody 把资源嵌进程序集。如果不想改打包配置也可以在代码里做兜底找不到主题文件时用内置的默认颜色至少保证程序能启动。5. 进阶技巧让导航栏支持折叠、动效与状态持久化5.1 折叠展开的实现思路侧边栏折叠是常见需求展开时显示图标加文字折叠后只显示图标鼠标悬停时弹出提示文字。实现上给 SideNavPanel 加一个IsCollapsed属性切换时把宽度从 200px 动画过渡到 56px同时通知每个 NavButton 切换绘制模式。NavButton 里加一个ShowText布尔值折叠时只画图标图标水平居中展开时画图标加文字。动画可以用Timer每 15ms 改一次宽度每次减 8px到目标值停止。注意动画期间要禁用按钮点击否则用户连点会出乱子。private void AnimateWidth(int targetWidth) { _animTimer new Timer { Interval 15 }; _animTimer.Tick (s, e) { int step Width targetWidth ? 8 : -8; if (Math.Abs(Width - targetWidth) 8) { Width targetWidth; _animTimer.Stop(); _animTimer.Dispose(); foreach (var btn in _buttons) btn.ShowText (targetWidth 100); return; } Width step; }; _animTimer.Start(); }这段代码里targetWidth是目标宽度展开 200折叠 56。每次改 8px15ms 一次200 到 56 大约 270ms手感比较顺。动画结束后统一设置按钮的ShowText避免每个按钮自己判断宽度导致不同步。折叠状态下鼠标悬停按钮时可以用ToolTip显示完整文字ToolTip的InitialDelay设成 300msReshowDelay设成 100ms体验比较自然。5.2 选中状态持久化用户上次点的是哪个导航项下次打开软件应该还停在那页。实现很简单在SetSelected里把当前选中项的PageType全名写进配置文件启动时读出来找到对应的 NavButton 调PerformClick。配置文件可以用Properties.Settings也可以用 json 文件放在AppData目录下。注意写配置的时机不要每次点击都写可以在窗体关闭时统一写一次减少磁盘 IO。// 窗体关闭时保存 private void MainForm_FormClosing(object sender, FormClosingEventArgs e) { Properties.Settings.Default.LastPage _currentPageType?.FullName ?? ; Properties.Settings.Default.Save(); } // 启动时恢复 private void MainForm_Load(object sender, EventArgs e) { string lastPage Properties.Settings.Default.LastPage; if (!string.IsNullOrEmpty(lastPage)) { var btn _navPanel.FindButtonByPageType(lastPage); btn?.PerformClick(); } }FindButtonByPageType是 SideNavPanel 里的一个辅助方法遍历_buttons找到PageType.FullName匹配的那个。如果找不到比如页面被删了或者改名了就回退到第一个导航项不要报错。这个功能对经常切换页面的操作员来说很实用省得每次打开都要重新点一遍。5.3 验证导航栏是否真的可用写完导航栏别急着交付按下面几步过一遍。第一步把所有导航项挨个点一遍确认页面都能正常加载没有报错。第二步快速在多个导航项之间来回切换看有没有闪烁、卡顿、内存持续上涨。第三步把窗口拉大拉小看导航栏和内容区是否跟随缩放按钮有没有错位。第四步在 125% 和 150% DPI 的显示器上各跑一次确认图标文字不重叠。第五步关掉软件再打开确认上次选中的页面被正确恢复。第六步打包安装到一台没装过开发环境的机器上确认主题文件和图标资源都在程序能正常启动。我自己的习惯是每次改完导航栏的绘制逻辑都会在虚拟机里用 150% 缩放跑一遍因为高分屏下最容易暴露坐标计算的问题。有一次偷懒没测交付后客户反馈图标和文字叠在一起远程连过去一看人家用的是 4K 显示器加 150% 缩放回来改了一版 DPI 适配才过。从那以后我每次做 WinForm 界面都强制在 100%、125%、150% 三档缩放下各截一张图对比确认没有错位才提交。希望这些经验能帮到你少走点弯路。本文还有配套的精品资源点击获取
返回列表