WinUI NavigationView 渲染机制深度解析:从 ControlTemplate 部件到双显示模式的 UI 实现
【免费下载链接】microsoft-ui-xamlWinUI: a modern UI framework with a rich set of controls and styles to build dynamic and high-performing Windows applications.项目地址: https://gitcode.com/GitHub_Trending/mi/microsoft-ui-xaml
本指南以微软 WinUI 3(microsoft-ui-xaml)仓库中 NavigationView 渲染文档 为主体,结合 NavigationView.xaml 控制模板、NavigationView.cpp 代码后台与 NavigationViewItemsFactory.cpp 实现,完整剖析 NavigationView 的 UI 构成:ItemTemplate 如何与 NavigationViewItem 协作、左右两种 DisplayMode 各自使用哪些模板部件、代码后台如何引用这些部件完成布局与交互。读完你将能够理解 NavigationView 模板的每一块拼图,并具备自定义/复刻其模板结构的能力。
NavigationView 渲染概述
NavigationView 的 UI 渲染与DisplayMode(显示模式)强相关:左侧导航(Left)与顶部导航(Top)两种模式下,控件模板(ControlTemplate)中处于激活状态的可视化部件完全不同。因此在 rendering.md 中,作者将模板部件按"两种模式共用 / 仅 Left 使用 / 仅 Top 使用"三组分别说明。
实际的模板定义位于 NavigationView.xaml 的ControlTemplate TargetType="controls:NavigationView"中,模板根部是一个x:Name="RootGrid"的Grid,内部通过VisualStateManager.VisualStateGroups声明了DisplayModeGroup、TogglePaneGroup、PaneStateGroup、PaneOverlayGroup等状态组,再由VisualState.Setters动态切换各个命名部件的可见性、边距与样式——这正是渲染差异的实现机制。
ItemTemplates 与 NavigationViewItems:容器化的两条路径
文档明确了 NavigationView 同时支持MenuItemsSource与ItemTemplate,二者叠加时的行为规则如下:
- 如果提供的
MenuItems本身就是NavigationViewItem,且未设置ItemTemplate,则这些NavigationViewItem会原样直接使用,不做任何包装; - 如果设置了
ItemTemplate,则NavigationViewItem根据模板内容行动:- 模板返回的是
NavigationViewItem→ 直接使用该模板结果,不加包装; - 模板返回的不是
NavigationViewItem(例如一个Button)→ 返回的元素会被包装进一个NavigationViewItem内。
- 模板返回的是
这套规则在源码中有直接对应的实现。NavigationView 内部为所有项容器统一使用NavigationViewItemsFactory(一个ElementFactory),其GetElementCore方法(见 NavigationViewItemsFactory.cpp)按如下逻辑解析数据项:
- 设置项(Settings)不做模板化,直接返回;
- 存在 ItemTemplateWrapper 时,用模板生成元素;
- 如果解析出的元素已经派生自
NavigationViewItemBase,直接返回——对应"模板返回 NavigationViewItem 时不加包装"; - 否则从对象池取出或新建一个
NavigationViewItem,把模板结果作为其 Content 装入——对应"非 NavigationViewItem 元素被包装"。
此外,NavigationViewItemsFactory内部维护了一个navigationViewItemPool对象池,被包装的 NavigationViewItem 在回收后可以复用,这与容器化(wrapping)流程配合,减少了频繁创建容器的开销。另外文档还提示:设置(Settings)项会被渲染为 FooterMenu 项列表中的一个元素,对应代码中NavigationViewItemsFactory::SettingsItem的注入逻辑。
ControlTemplate 共用部件:两种模式都需要的模板结构
文档列出的两个 DisplayMode 都需要的模板部件及其在代码后台(code behind)中的用途如下(完整继承自原文档):
| 模板部件名 | 代码后台中的用途 |
|---|---|
RootGrid | 模板根,用于键盘导航(XYFocusKeyboardNavigation) |
PaneToggleButtonGrid | 未使用 |
TogglePaneTopPadding | 被引用,用于调整内边距(标题栏 TopPadding) |
ButtonHolderGrid | 未使用 |
NavigationViewBackButton | 允许触发BackRequested事件 |
NavigationViewBackButtonToolTip | 返回按钮 ToolTip 的本地化 |
NavigationViewCloseButton | 关闭按钮功能 |
NavigationViewCloseButtonToolTip | 关闭按钮 ToolTip 的本地化 |
TogglePaneButton | 打开/关闭 Pane 的功能 |
PaneTitleTextBlock | Pane 标题的绑定与渲染 |
PaneTitleHolder | 承载 Pane 标题 presenter |
PaneTitlePresenter | 渲染 Pane 标题 |
这些部件的实际引用逻辑位于 NavigationView.cpp 的OnApplyTemplate:代码通过GetTemplateChildT<T>(名称)逐一抓取模板子元素并挂接事件。例如:
TogglePaneButton抓取后挂接Click事件(OnPaneToggleButtonClick),并追加Win+Backspace键盘加速键(见 NavigationView.cpp);NavigationViewBackButton抓取后挂接点击事件以触发BackRequested,并设置自动化名称(NavigationView.cpp);NavigationViewCloseButton与TogglePaneButton共享同一个点击处理函数(NavigationView.cpp);NavigationViewBackButtonToolTip/NavigationViewCloseButtonToolTip在模板应用时通过ResourceAccessor::GetLocalizedStringResource写入本地化文本;RootGrid上开启XYFocusKeyboardNavigation(Enabled),而内容区域的ContentGrid被显式设为Disabled——这样游戏手柄/键盘方向键导航只作用于 Pane 与汉堡按钮区域,不进入内容区(NavigationView.cpp)。
模式切换时的部件"搬运"
文档特别指出:当在 Top 与 Left 两种显示模式之间切换时,NavigationView 会"移动"某些元素(如 PaneHeader 与 AutoSuggestBox),原因是 UI 元素只能被加入 VisualTree 一次,而这些元素必须出现在当前可见的区域中。
从模板结构可以印证这一点:模板中同时声明了两组"宿位"——左侧模式下的PaneHeaderContentBorder、PaneAutoSuggestBoxPresenter与顶部模式下的PaneHeaderOnTopPane、TopPaneAutoSuggestBoxPresenter是各自独立的ContentControl。代码后台在OnApplyTemplate中同时抓取这两组宿位(NavigationView.cpp),并在切换显示模式时通过UpdatePaneTitleFrameworkElementParents等更新方法决定将同一个逻辑内容(如 AutoSuggestBox)挂到哪一组 presenter 上,实现单实例元素的"搬家"。
DisplayMode Left:基于 SplitView 的左侧导航渲染
左侧模式使用的模板部件
文档列出的 Left 模式部件及用途如下(完整继承):
| 模板部件名 | 代码后台中的用途 |
|---|---|
RootSplitView | 渲染内容区与左侧 Pane |
PaneContentGrid | 左侧 Pane 的布局,动画也需要它 |
ItemsContainerRow | 菜单项/页脚项的高度分配 |
ContentPaneTopPadding | 高度调整 |
PaneHeaderContentBorderRow | 尺寸调整时被引用 |
PaneHeaderCloseButtonColumn | 用于 CompactPaneLength 下的关闭按钮列宽调整 |
PaneHeaderToggleButtonColumn | 用于 CompactPaneLength 下的切换按钮列宽调整 |
PaneHeaderContentBorder | Pane 头的手动尺寸控制 |
AutoSuggestArea | 未被引用 |
PaneAutoSuggestBoxPresenter | 用于检查 AutoSuggest 区域是否有内容 |
PaneAutoSuggestButton | 按钮被调用时打开 Pane(搜索按钮) |
PaneCustomContentBorder | Pane 头渲染(自定义内容) |
ItemsContainerGrid | 菜单项与页脚项视图的尺寸计算 |
MenuItemsScrollViewer | 限制菜单项宿主的最大高度 |
MenuItemsHost | 渲染 NavigationViewItems |
VisualItemsSeparator | 需要时动态显示/隐藏分隔线 |
FooterContentBorder | 渲染 Pane 页脚(PaneFooter) |
FooterMenuItemsHost | 渲染页脚菜单项 |
ContentGrid | 阴影处理与焦点行为 |
ContentTopPadding | 未被引用 |
ContentLeftPadding | 内容区左内边距 |
HeaderContent | 未被引用 |
在 NavigationView.xaml 中可以看到这些部件的真实形态:RootSplitView使用DisplayMode="Inline",IsPaneOpen与NavigationView.IsPaneOpen双向绑定;Pane 内PaneContentGrid以 7 行 RowDefinition 组织(顶栏 → 关闭/返回按钮行 → PaneHeader → AutoSuggestArea → 自定义内容 → 菜单/页脚区);MenuItemsHost与FooterMenuItemsHost都是ItemsRepeater,分别以StackLayout纵向排布,并被包在ItemsRepeaterScrollHost+ScrollViewer中。VisualItemsSeparator是一个默认Collapsed的NavigationViewItemSeparator,由PaneSeparatorStates状态组控制显隐。
SplitView 驱动的显隐逻辑
文档指出:Left 模式下由 SplitView 根据显示模式与 NavigationView 宽度来决定 Pane 的显示/隐藏;当PaneDisplayMode为Auto时,Pane 依据宽度自动隐藏。
这一自适应逻辑在 NavigationView.cpp 的UpdateAdaptiveLayout中实现(由OnSizeChanged触发,见 NavigationView.cpp):
PaneDisplayMode == Auto:宽度>= ExpandedModeThresholdWidth时切换为Expanded;宽度> 0且< CompactModeThresholdWidth时切换为Minimal;介于两者之间保持Compact;PaneDisplayMode == Left:恒定Expanded;PaneDisplayMode == LeftCompact:恒定Compact;PaneDisplayMode == LeftMinimal:恒定Minimal。
切换过程中代码还会协同调用OpenPane/ClosePane(如进入Expanded且 Pane 可见时自动打开,进入Minimal时自动关闭),并同步更新NavigationViewTemplateSettings.OpenPaneLength供模板绑定使用(UpdateOpenPaneLength会把OpenPaneLength限制在不超过当前宽度范围内,见 NavigationView.cpp)。
菜单区与页脚区的空间分配算法
ItemsContainerRow、MenuItemsScrollViewer与FooterItemsScrollViewer的真正用途体现在 NavigationView.cpp 的UpdatePaneLayout中:代码将 Pane 内可用高度在"菜单项"与"页脚组(FooterItems + PaneFooter)"之间按优先级划分——页脚优先(因为通常包含设置、个人资料等重要入口)。其分配策略:
- 没有页脚项且不显示设置项:收起分隔线,菜单项占用全部高度;
- 没有菜单项:限制页脚滚动区域高度为全部可用高度,收起分隔线;
- 空间足够容纳两者:各自按所需高度分配,收起分隔线;
- 页脚超过一半高度:限制页脚高度,显示分隔线;
- 菜单超过一半高度:限制菜单高度,显示分隔线;
- 双方都超过一半:对半平分,显示分隔线。
这就是VisualItemsSeparator"动态显示/隐藏"的完整决策逻辑——它并不是装饰,而是"空间不足、双方开始争抢高度"时出现的信号。当m_footerItemsSource.Count() == 0 && !IsSettingsVisible()等条件变化、或OnSizeChanged、OnItemsContainerSizeChanged、页脚集合变化时,UpdatePaneLayout都会被再次调用以重新计算。
DisplayMode Top:基于 ItemsRepeater 的顶部导航渲染
顶部模式使用的模板部件
文档列出的 Top 模式部件及用途如下(完整继承):
| 模板部件名 | 代码后台中的用途 |
|---|---|
TopNavArea | 未被引用 |
TopNavLeftPadding | 未被引用 |
TopNavGrid | 用于低版本(down level)支持 |
BackButtonPlaceholderOnTopNav | 未被引用 |
PaneHeaderOnTopPane | 渲染头部(PaneHeader) |
PaneTitleOnTopPane | 渲染 Pane 标题 |
TopNavMenuItemsHost | 在顶部模式下渲染 NavigationViewItems |
TopNavOverflowButton | 空间不足时打开溢出菜单 |
TopNavMenuItemsOverflowHost | 渲染溢出 Flyout 中的项 |
PaneCustomContentOnTopPane | 渲染自定义 Pane 内容 |
TopPaneAutoSuggestArea | 为搜索框预留空间 |
TopPaneAutoSuggestBoxPresenter | 渲染 AutoSuggestBox |
TopFooterMenuItemsHost | 渲染页脚菜单项 |
PaneFooterOnTopPane | 显示 Pane 页脚 |
TopNavContentOverlayAreaGrid | 用于内容叠加(ContentOverlay) |
模板中,TopNavArea是一个StackPanel,内含TopNavGrid(NavigationView.xaml)。TopNavGrid是一个 9 列 Grid:返回按钮占位列、TopNavLeftPadding、PaneHeaderOnTopPane、PaneTitleOnTopPane、横向ItemsRepeater的TopNavMenuItemsHost、TopNavOverflowButton、PaneCustomContentOnTopPane、TopPaneAutoSuggestArea(内含TopPaneAutoSuggestBoxPresenter)、PaneFooterOnTopPane以及TopFooterMenuItemsHost。
溢出(Overflow)机制的模板结构
TopNavOverflowButton在模板中是一个带Flyout的按钮,TopNavMenuItemsOverflowHost位于该 Flyout 内部(NavigationView.xaml)。从代码后台(NavigationView.cpp)可以看到:
- 顶部主菜单与溢出菜单各自持有独立的
ItemsRepeater(m_topNavRepeater与m_topNavRepeaterOverflowView),数据源分别来自TopNavigationViewDataProvider::GetPrimaryItems()与GetOverflowItems(); - 溢出按钮在模板应用时写入本地化文本与 ToolTip,并监听 Flyout 的
Closing事件; - 当溢出集合变为空时,溢出按钮会被折叠(
OnOverflowItemsSourceCollectionChanged,见 NavigationView.cpp)。
当用户在溢出菜单中选中某个叶子项时,NavigationView 会把该项从溢出区移入主菜单区(SelectandMoveOverflowItem,见 NavigationView.cpp),这正是"溢出项被选中后提升为主项"的行为来源。
顶部模式的层级结构(Hierarchical NavigationView)
与左栏不同,顶部导航的层级子项并非展开在面板内,而是通过ChildrenFlyout弹出。相关结构在 NavigationViewItem 渲染文档 中有配套说明:NavigationViewItem的模板由NVIRootGrid(根)、NavigationViewItemPresenter(负责项的实际渲染)、NavigationViewItemMenuItemsHost(渲染子项)与ChildrenFlyout(顶部模式下子项弹出的 Flyout)组成。在 NavigationView.xaml 的 NavigationViewItem 模板中可以看到:OnTopNavigationPrimary状态会把 presenter 切换为顶部专用样式、并把ChildrenFlyout的Placement改为BottomEdgeAlignedLeft;OnTopNavigationOverflow状态则使用溢出专用样式。NavigationViewItemPresenter在 NavigationViewItemPresenter.idl 中定义,其具体样式定义在 NavigationView_themeresources.xaml 中。
从仓库历史看,NavigationView 内部容器已从早期 ListView 全面迁移到 ItemsRepeater + SelectionModel(详见 NavigationView_Overview.md),顶部导航的所有项宿主(主菜单、溢出菜单、左右页脚菜单)均为ItemsRepeater,这为层级展开、溢出提升等复杂交互提供了基础。
总结:如何阅读与自定义 NavigationView 模板
把本文档与模板、代码后台对照阅读的路径可以归纳为:
- 看模板:NavigationView.xaml 中的
ControlTemplate声明了全部命名部件,按TopNavArea(顶部区)与RootSplitView(左侧区)两大块组织; - 对状态组:模板中的
VisualStateGroup(DisplayModeGroup、PaneStateGroup、PaneSeparatorStates等)决定了各部件在不同模式/状态下的可见性与样式; - 追代码:NavigationView.cpp 的
OnApplyTemplate(L392-L709)列出了所有被实际引用的部件名常量(L33-L102),凡是在这份名单里的部件才会被代码后台驱动;文档中标注"未被引用/Not referenced"的部件,则仅作为模板结构占位存在; - 验行为:
UpdateAdaptiveLayout(自适应宽度)、UpdatePaneLayout(菜单/页脚空间分配)、NavigationViewItemsFactory::GetElementCore(项容器化)分别对应文档中"按宽度隐藏 Pane"、"分隔线动态显隐"、"模板返回非 NavigationViewItem 时自动包装"三条核心结论。
若你要为 NavigationView 编写自定义模板,最稳妥的做法是以本模板为基线,保留所有被代码后台引用的x:Name(尤其是上表与OnApplyTemplate常量表中出现的名称),再针对性地修改样式与视觉状态;删除或重命名被引用的部件会导致代码后台无法抓取到对应元素,进而使返回按钮、Pane 开关、搜索按钮等交互失效。进一步的控件实现细节可参考 NavigationView.cpp 全文、NavigationView.idl 的 API 定义以及 NavigationView 目录 下的交互测试(如 NavigationView_InteractionTests/PaneBehaviorTests.cs 与 TopModeTests.cs)中针对各模式行为的验证用例。
【免费下载链接】microsoft-ui-xamlWinUI: a modern UI framework with a rich set of controls and styles to build dynamic and high-performing Windows applications.项目地址: https://gitcode.com/GitHub_Trending/mi/microsoft-ui-xaml
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考