十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

C# WinForms手写可折叠侧边导航:主界面布局与折叠动画完整实现

C# WinForms手写可折叠侧边导航:主界面布局与折叠动画完整实现 简介面向使用C#构建桌面应用并希望实现折叠导航界面的开发者这份资源以完整的Visual Studio项目呈现软件主界面的折叠导航设计覆盖WPF与WinForms两大框架的常见实现路径。压缩包共12个文件含6个C#源码文件、2个resx资源文件、项目与解决方案文件及用户设置文件整体大小仅20KB结构精炼。源码演示了WPF中Expander、TreeView、DockPanel等控件的组合布局以及WinForms下利用TabControl、SplitContainer、Panel可见性模拟折叠导航的替代方案同时涉及按钮事件处理、自定义样式、数据绑定与MVVM分层设计帮助读者掌握从导航栏搭建到交互状态管理的完整思路。资源内sln文件可直接打开运行便于边看源码边验证折叠展开效果也适合作为主界面框架的原型参考。该资源已有497人学习浏览适合有一定C#基础、正在设计软件主界面或希望强化导航交互能力的开发者研读。 做上位机开发和内部工具的朋友应该都有同感软件本身的反而不难真正让人花时间的是主界面。尤其是一个“能拿得出手”的主界面侧边导航、内容区切换、状态栏这些都得上导航还得能折叠收起给内容区腾空间。这篇博文我就完整记录一下用 C# WinForms 从零手写一个带折叠导航的主界面的过程代码、思路、踩坑都有直接照着抄就行。这套东西特别适合设备控制软件、数据采集工具、管理系统这类偏桌面端的项目。如果你是刚学 C# 想找一个练手项目或者正在做一个内部工具但不想套别人写好的第三方界面库那这篇文章基本可以帮你把主界面的底子搭好。1. 整体设计先想清楚折叠导航到底要解决什么问题1.1 为什么不用WPF、不用现成控件库很多朋友一听说要做折叠导航第一反应是换 WPF或者去 NuGet 上找现成的 sidebar 控件。我不否认 WPF 做动画确实顺手但现实情况是很多公司内部工具、设备配套软件、工厂产线上的程序还是 WinForms 的存量居多。你接手一个项目老板说“在主界面上加个可折叠的导航”大概率就是一个 WinForms 老工程。这种时候换 UI 框架的成本太高业务逻辑、通讯代码全都要动一遍明显不划算。WinForms 能不能做折叠导航能而且做出来效果不差。关键思路是用 Panel 容器加上 Timer 控制二级菜单区域的高度变化视觉上就实现了“展开”和“收起”。不需要引入任何第三方依赖纯手写就能搞定。这类方案我实际用下来稳定的很占用的系统资源也低对设备现场的老电脑极其友好。提示如果是从零开始的新项目并且未来界面复杂度会很高比如有大量数据绑定的表格、复杂的样式需求那 WPF 确实值得考虑。但如果你现在就在一个 WinForms 项目里折腾别犹豫往下看就行。1.2 界面整体布局分成哪几块一个标准的主界面通常分成三大块左侧导航面板、右侧主内容区、底部状态栏。导航面板负责模块入口切换内容区负责展示当前模块的实际页面状态栏用来显示一些状态信息比如当前登录用户、服务连接状态、时间等。折叠导航的核心逻辑就集中在左侧导航面板上。一级菜单代表大的功能模块每个一级菜单下挂若干个二级菜单二级菜单才是真正打开页面的入口。点击一级菜单时它下面挂的二级菜单区域平滑展开再点一次就收回去。同时需要保证同一时间只能有一个一级菜单展开不然整个导航区域乱糟糟的界面就失去层次了。我用 WinForms 实现时左侧导航面板用了一个全屏高度的 Panel导航面板内部动态添加控件。一级菜单是一个自定义样式的 Button二级菜单也是 Button但缩进一些、样式不一样。每个二级菜单的 Click 事件统一绑定一个页面切换函数。这种纯代码动态创建的方式后续加一个模块只需要在数据源里加一条记录不需要拖一个控件。2. 界面布局手写侧边导航面板的完整过程2.1 初始化主窗体和全局配色在动手写代码前先把主窗体和颜色基调定下来。这步对最终效果影响很大我踩过教训先随便放几个按钮凑合后面调样式时要改一堆地方。主窗体建议设置最小尺寸防止用户把窗口缩得太小导航栏和内容区挤在一起没法看。背景色我用的深灰蓝作为底色这样后续做深色主题比较容易平滑过渡。public partial class MainForm : Form { private Panel navPanel; // 左侧导航容器 private Panel contentPanel; // 右侧内容区域 private Panel statusPanel; // 底部状态栏 private Timer animTimer; // 折叠动画计时器 private int targetHeight 0; // 二级菜单目标高度 private int currentHeight 0; // 二级菜单当前高度 private Button currentExpandedMenu null; // 当前展开的一级菜单 public MainForm() { InitializeComponent(); this.Text 设备管理系统; this.StartPosition FormStartPosition.CenterScreen; this.MinimumSize new Size(1024, 700); this.BackColor Color.FromArgb(240, 242, 245); InitLayout(); BuildNavMenu(); } }窗体的 ClientSize 我一般设置成 1280x800。导航面板宽度固定 220 像素折叠动画只动二级菜单的高度不动导航面板的宽度这样实现最简单交互也直观。如果你想要那种“整个导航栏收起成一条窄边”的效果那是另外一种方案这里先不讨论。2.2 封装统一的菜单创建函数直接在构造函数里挨个 new Button 也可以但代码会非常冗余而且后续想改样式要到处找。推荐的写法是封装一个 CreateNavButton 函数通过参数区分是“一级菜单”还是“二级菜单”。一级菜单文字靠左、带底色区分二级菜单缩进 20 像素看起来更有层级感。private Button CreateNavButton(string text, bool isMainMenu, EventHandler clickHandler) { Button btn new Button(); btn.Text text; btn.Dock DockStyle.Top; btn.Height isMainMenu ? 48 : 38; btn.FlatStyle FlatStyle.Flat; btn.FlatAppearance.BorderSize 0; btn.TextAlign ContentAlignment.MiddleLeft; btn.Padding new Padding(isMainMenu ? 15 : 35, 0, 0, 0); btn.Font new Font(微软雅黑, isMainMenu ? 10f : 9f, isMainMenu ? FontStyle.Bold : FontStyle.Regular); btn.BackColor isMainMenu ? Color.FromArgb(52, 73, 94) : Color.FromArgb(44, 62, 80); btn.ForeColor Color.White; btn.Cursor Cursors.Hand; btn.Tag isMainMenu ? main : sub; btn.Click clickHandler; navPanel.Controls.Add(btn); btn.BringToFront(); return btn; }这里有个细节值得注意控件的添加顺序和 Dock 属性的关系。WinForms 里 Dock 属性是依据 Z 轴顺序来决定停靠位置的后来添加的控件会优先占靠边的位置所以需要正确控制添加顺序。我的做法是定义一个菜单数据源结构体列表遍历创建同时把二级菜单放入对应一级菜单展开区域的容器里。2.3 用数据驱动方式创建多级菜单菜单项不应该写死在代码里我用一个 List 保存菜单配置每一项包含主菜单名称、图标、子菜单列表。这样以后加模块只需要在该集合中追加记录。public class MenuItemInfo { public string MainTitle { get; set; } public Liststring SubItems { get; set; } } private ListMenuItemInfo menuDataSource new ListMenuItemInfo() { new MenuItemInfo { MainTitle 系统管理, SubItems new Liststring { 用户管理, 角色权限, 操作日志 } }, new MenuItemInfo { MainTitle 设备监控, SubItems new Liststring { 实时状态, 报警记录, 参数配置 } }, new MenuItemInfo { MainTitle 数据报表, SubItems new Liststring { 日报表, 月报表, 趋势分析 } }, new MenuItemInfo { MainTitle 系统设置, SubItems new Liststring { 通信设置, 界面主题, 关于软件 } } };展开区域的容器在创建时设置 Height 为 0 且 Visible false。这里有一个经常被忽视的坑如果你先往 Panel 里塞了子按钮、再设置 Panel 的 Visible false会触发布局系统重新计算可能导致子按钮Dock Top方向错乱。正确的顺序是先把子按钮全部 add 进来并设置 Panel 高度为 0但要先用panel.SuspendLayout()挂起布局加完后再恢复布局最后统一隐藏。3. 折叠交互让二级菜单真正“动”起来3.1 核心原理控制高度模拟折叠效果折叠导航的动画效果本质上不是真的让控件变换形状而是动态改变二级菜单容器的高度。展开时把高度从 0 逐步增加到子按钮总高度收起时反向操作。每次动画更新一帧用 Timer 定时触发。Timer 的 Interval 我设置为 15 毫秒这个值接近 60fps肉眼看起来动画比较平滑。展开时长控制在 200 到 250 毫秒太短了显得生硬太长又拖沓。计算每一帧的增量时用总高度除以总帧数再乘以一个缓动系数。WinForms 做复杂缓动曲线比较麻烦我直接用了线性速度实测体验完全 OK。private bool isAnimating false; private bool isExpanding false; private Button animatingMenu null; private Panel animatingPanel null; private void MainMenu_Click(object sender, EventArgs e) { Button clickedBtn sender as Button; Panel subPanel GetSubPanelByMainButton(clickedBtn); if (subPanel null) return; if (animatingMenu ! null animatingMenu ! clickedBtn) { // 如果其他菜单正在动画中先强制结束 FinishAnimation(); } // 如果点的是同一个菜单 if (animatingMenu clickedBtn isAnimating) { return; } // 如果当前有展开的其他菜单先收起 if (currentExpandedMenu ! null currentExpandedMenu ! clickedBtn) { CollapseMenu(currentExpandedMenu); } // 切换当前菜单的展开/收起状态 if (subPanel.Visible subPanel.Height 0) { CollapseMenu(clickedBtn); } else { ExpandMenu(clickedBtn); } }上面这段只是为了展示状态判断的大致框架。实际项目里折叠逻辑要处理“动画过程中再次点击”“多个菜单同时展开”“快速点击不同菜单”这些边界条件我把完整方案整合在了下面的代码中。3.2 展开与收起的完整实现private void ExpandMenu(Button mainBtn) { Panel subPanel GetSubPanelByMainButton(mainBtn); if (subPanel null) return; subPanel.Visible true; targetHeight subPanel.Controls.Count * 38; isAnimating true; isExpanding true; animatingMenu mainBtn; animatingPanel subPanel; animTimer.Start(); mainBtn.BackColor Color.FromArgb(41, 128, 185); // 展开高亮色 } private void CollapseMenu(Button mainBtn) { Panel subPanel GetSubPanelByMainButton(mainBtn); if (subPanel null) return; if (animatingPanel subPanel) { animTimer.Stop(); isAnimating false; } else { currentHeight subPanel.Height; targetHeight 0; isAnimating true; isExpanding false; animatingMenu mainBtn; animatingPanel subPanel; animTimer.Start(); } mainBtn.BackColor Color.FromArgb(52, 73, 94); // 回到普通色 } private void AnimTimer_Tick(object sender, EventArgs e) { if (animatingPanel null) return; int speed 30; if (isExpanding) { currentHeight speed; if (currentHeight targetHeight) { currentHeight targetHeight; FinishAnimation(); } } else { currentHeight - speed; if (currentHeight 0) { currentHeight 0; animatingPanel.Visible false; FinishAnimation(); } } animatingPanel.Height currentHeight; } private void FinishAnimation() { animTimer.Stop(); isAnimating false; if (animatingPanel ! null) { animatingPanel.Height targetHeight; } if (targetHeight 0) { currentExpandedMenu null; } else { currentExpandedMenu animatingMenu; } animatingMenu null; animatingPanel null; }这套代码看起来简单但我是踩过坑才这么写的。一开始我只用subPanel.Visible配合 Timer 做动画发现收起二级菜单后 Visible 直接设 false下次展开时高度还停在旧值导致展开动画又“闪”了一下。正确处理是每次动画开始前先把 currentHeight 捕获为 panel 当前的实际高度再朝目标高度匀速靠近。3.3 二级菜单点击切换主内容区二级菜单点击后主内容区要加载对应的功能页面。为了避免在主窗体上堆几千行代码我建议用 UserControl 承载具体业务页面主窗体只负责实例化并填充到内容面板。private Dictionarystring, UserControl pageCache new Dictionarystring, UserControl(); private void SubMenu_Click(object sender, EventArgs e) { Button subBtn sender as Button; if (subBtn null) return; string pageName subBtn.Name.Replace(btn_, Page_); OpenPage(pageName, subBtn.Text); } private void OpenPage(string pageKey, string title) { if (contentPanel.Controls.Count 0) { contentPanel.Controls.Clear(); } if (!pageCache.ContainsKey(pageKey)) { Type pageType Type.GetType(YourNamespace. pageKey); if (pageType ! null) { UserControl page Activator.CreateInstance(pageType) as UserControl; page.Dock DockStyle.Fill; pageCache[pageKey] page; } } if (pageCache.ContainsKey(pageKey)) { contentPanel.Controls.Add(pageCache[pageKey]); } }这里用到了反射机制把页面类的名称字符串转换为真正的 Type 并创建实例。好处是新增页面不需要改动主窗体的分支判断代码只要 UserControl 类名符合Page_xxx的命名约定就能自动被加载。反射第一次调用时有一点性能开销但后续页面缓存到字典里再次切换就是毫秒级响应。注意页面切换时我做了一个Controls.Clear()操作这会触发 UserControl 的 Dispose 流程吗答案是不会自动调用 Dispose只是从 Controls 集合中移除。由于我们用了 pageCache 缓存这个 UserControl 对象仍然存活下次切换回来还可以直接复用内存分配的压力很小。如果你不想缓存改为真正的页面切换需要在移除时手动调用 Dispose。4. UI细节图标、选中态与主题切换4.1 图标解决方案用字体图标代替图片导航菜单光有文字显得很干加上图标才像回事。业内常见方案是用图片但图片要设计、要适配不同分辨率切换深浅主题时还可能看不清。我推荐用字体图标比如 FontAwesome 的 WinForms 版本引用之后每个图标就是字符串里的一个特殊字符颜色可以随意设置放大缩小不模糊。在 WinForms 中加载字体图标有两个途径一是安装 FontAwesome.Sharp 这个 NuGet 包二是自己在项目里内嵌 ttf 字体文件用PrivateFontCollection加载。前者上手快后者依赖少、体积小。如果只是几个菜单图标建议手写嵌入字体文件防止装机时缺字体导致图标显示成方框。private Font iconFont; PrivateFontCollection pfc new PrivateFontCollection(); private void LoadIconFont() { string fontPath Application.StartupPath \Resources\fa-solid-900.ttf; pfc.AddFontFile(fontPath); iconFont new Font(pfc.Families[0], 12f); } private Button CreateIconButton(string iconChar, string text) { Button btn new Button(); btn.Text iconChar text; btn.Font new Font(微软雅黑, 10f); // 利用合并字体显示使用FontAwesome字体渲染图标部分 ... }这个方案有个坑要提前说一个控件只能设置一种 Font而 FontAwesome 图标字符放在微软雅黑字体里是显示不出图标的。解决办法是用绘图方式不让 Button 渲染 Text 的图标部分而是在OnPaint里用 iconFont 单独画图标。更省事的办法是装 FontAwesome.Sharp 包它提供的 IconButton 控件处理好了这个事。我在实际项目里倾向 NuGet 包毕竟自己处理字体合并渲染的细节确实麻烦。4.2 菜单状态的三种视觉样式折叠导航用久了你会发现用户其实只关心三件事我现在在哪个模块、哪个菜单是展开的、哪个子菜单被选中了。所以视觉上至少要区分三种状态一级菜单普通态、一级菜单展开态、二级菜单选中态。普通态是深灰色背景白色文字展开态用蓝色背景表示当前模块激活选中态用更亮的浅蓝色背景加左侧一条竖线指示条。左侧竖线我用的是 Button 的Paint事件里画一条 4 像素宽的矩形。这样视觉层次非常清晰用户在大型菜单体系里也不会迷路。private void SubMenuItem_Paint(object sender, PaintEventArgs e) { Button btn sender as Button; if (btn ! null btn.Tag ! null btn.Tag.ToString() selected) { SolidBrush brush new SolidBrush(Color.FromArgb(52, 152, 219)); e.Graphics.FillRectangle(brush, 0, 0, 4, btn.Height); } }选中态的管理逻辑很关键每次点击二级菜单先把导航面板里所有 Tag 为 selected 的 Button 重置为普通样式再把当前点击的 Button 标记为 selected 并刷新。这块代码不要分散写在各个点击事件里建议封装成一个SetSelectedButton(Button target)函数保持状态管理唯一入口。4.3 深浅主题切换的简洁实现主界面做完后大概率会有人提需求“能不能加个深色模式”如果你一开始没规划后期改造会非常痛苦因为每个控件都硬编码颜色了。聪明做法是定义一组颜色变量所有控件颜色统一从变量读取切换主题就是换一组变量值再刷新界面。public class ThemeColors { public Color MainBackground Color.FromArgb(52, 73, 94); public Color SubBackground Color.FromArgb(44, 62, 80); public Color ActiveColor Color.FromArgb(41, 128, 185); public Color SelectedColor Color.FromArgb(52, 152, 219); public Color TextColor Color.White; public Color ContentBackground Color.FromArgb(240, 242, 245); } private ThemeColors theme; private void ApplyTheme() { foreach (Control ctl in navPanel.Controls) { if (ctl is Button btn) { if (btn.Tag?.ToString() main) { btn.BackColor theme.MainBackground; } else { btn.BackColor theme.SubBackground; } btn.ForeColor theme.TextColor; } } contentPanel.BackColor theme.ContentBackground; }按钮的选中态被盖掉的问题要注意如果主题切换函数里给所有按钮统一设置背景色会把之前标记为 selected 的按钮状态误伤。所以在 ApplyTheme 里设置颜色后要重新调用一次 SetSelectedButton把当前选中菜单再高亮一遍。顺序是先恢复基础色再恢复选中态。5. 问题排查与性能优化实录5.1 折叠动画期间的快速点击引发的状态错乱这是我在开发过程中遇到的最典型的 BUG。测试人员连续快速点击不同的一级菜单动画还没走完就点了下一个结果两个二级菜单同时展开或者某个二级菜单高度停在中间值上不去了。原因很简单折叠动画是异步的动画进行中状态变量处于“过渡态”这时候新的点击事件到达判断条件走不通。解决办法就是在动画执行期间对新的点击先做强制处理要么立即完成上一次动画要么忽略这次点击。我上面给出的代码里用的是FinishAnimation()强制结束把状态归位后再处理新点击。这个方案体验最好用户感觉不到卡顿。5.2 控件闪烁问题WinForms 快速改变控件尺寸时很容易出现界面闪烁专业说法叫 flicker。折叠动画每一帧都在改 Panel 高度如果窗体启用了双缓冲效果会好很多。WinForms 的 Control 类内置了DoubleBuffered属性但它是受保护的外部不能直接访问。解决办法有两个。一是写一个继承 Panel 的类把 DoubleBuffered 设为 true。二是用反射强制开启该属性。我个人建议写一个自定义控件类把常用的缓动动画、双缓冲都封装进去以后任何 Panel 需要动画效果直接用这个类就行。public class SmoothPanel : Panel { public SmoothPanel() { this.DoubleBuffered true; this.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); this.UpdateStyles(); } }5.3 布局抖动问题另一个观感问题是折叠动画过程中右侧内容区不会抖动但导航面板下方如果还有别的控件子按钮和下方控件的位置会对不齐。这是因为每次改变 Height 时WinForms 的布局引擎都会重新计算 Dock 排列。解决办法是在动画开始时调用navPanel.SuspendLayout()动画结束时调用navPanel.ResumeLayout()。需要特别注意的是绝对不能在整个导航面板上做否则二级菜单内部的按钮排列也会被挂起子按钮可能不会正常显示。挂起和恢复一定要精确控制范围只挂起父容器不影响子面板内部的排列。5.4 一个容易被忽略的主题切换缺陷如果你按照 4.3 的代码做了主题切换第一次测试深色模式可能什么问题都没有但当你切回浅色时发现内容区的 UserControl 背景还是深色的。这是因为 UserControl 里有自己的控件它们用的颜色是写死在构造函数里的你不去改它们它们不会跟着主题变化。解决这类问题的思路是给所有页面控件定义一个统一的基类或者约定所有业务页面都实现一个IThemable接口主题切换时遍历所有打开的页面调用ApplyTheme()方法。这个工作量不小所以我一般建议刚开始做主题功能时就把内容区背景色统一由外层控制页面内部尽量不自定义 Draw 颜色。5.5 常见问题与排查思路速查现象可能原因排查/解决思路二级菜单展开时闪一下再收起Panel 高度没有重置为0初始化阶段调用 SetMenuHeight(0)再设 Visiblefalse点击菜单无反应点击事件重复绑定或没有绑定在创建按钮处设断点检查 Click 事件是否挂载成功动画过程中界面卡顿Timer 间隔过短或刷新频繁将 Interval 设为 15~20ms启用双缓冲字体图标显示为方框系统缺少字体文件把字体文件加入项目资源用 PrivateFontCollection 加载页面切换后内容区空白反射类型名称找不到检查 UserControl 命名空间和类名是否匹配约定第一次展开慢后续正常反射创建实例的开销使用页面缓存切换后不销毁直接复用写在最后的一个小建议如果让我给这条折叠导航的实现方案一个总体评价那就是“够用、稳定、不依赖外部组件”。WinForms 虽然老但它的成熟度和可控性在不少场景里比新框架更省心。把折叠导航做成一个独立的 UserControl 吧把菜单数据源、主题颜色、选中态管理都封装进去。这样以后任何新项目需要主界面拖一个控件进去填数据就行。我自己后来把这段代码整理成了公司内部的 SidebarNav 控件好几个项目的改造都直接复用了省下来的时间相当可观。本文还有配套的精品资源点击获取
返回列表