☰
WinForm左侧导航栏实现:自绘控件与页面切换联动
2026/10/8 15:50:14 网站建设 项目流程

简介:这是一份面向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文件夹放图标 png,Themes文件夹放颜色配置。

文件职责关键点
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 管起来,核心是维护一个List<NavButton>,并在某个按钮被点击时,取消其他按钮的选中态,同时触发页面切换事件。

public class SideNavPanel : Panel { private List<NavButton> _buttons = new List<NavButton>(); public event EventHandler<NavItemEventArgs> 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不要写成Refresh,Refresh会强制立即重绘,频繁调用会闪屏,Invalidate只是标记区域,等系统下次重绘时统一处理,性能更好。

3.3 页面切换与内容区嵌入

右侧内容区的切换,常见做法是预先创建好所有UserControl实例,切换时只显示对应的那个,隐藏其他的。这样做切换速度快,但启动时内存占用高。另一种做法是懒加载,第一次切换到某个页面时才创建实例,之后缓存起来。资源包里用的是懒加载加缓存的方式。

private Dictionary<Type, UserControl> _pageCache = new Dictionary<Type, 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 子文件夹下,打包安装程序时要记得把整个文件夹带上,否则运行时会报找不到文件。颜色用十六进制字符串存,比如#FF2D89EF,ColorTranslator.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。每次改 8px,15ms 一次,200 到 56 大约 270ms,手感比较顺。动画结束后统一设置按钮的ShowText,避免每个按钮自己判断宽度导致不同步。折叠状态下,鼠标悬停按钮时可以用ToolTip显示完整文字,ToolTip的InitialDelay设成 300ms,ReshowDelay设成 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% 三档缩放下各截一张图对比,确认没有错位才提交。希望这些经验能帮到你,少走点弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询