WinUI 参考: 完整的属性面与设计指引见 Navigation Basics。
Microsoft.UI.Reactor(Reactor)的导航是存放于 Hook 中的一个强类型路由栈。你在某个根组件里调用一次 UseNavigation(initialRoute),拿回一个 NavigationHandle<TRoute>,它的 Navigate、GoBack、Replace、Reset 方法负责改写这个栈。NavigationHost 每次渲染读取当前路由,并通过 route => Element 函数把它投影出来,于是页面树成为栈的纯函数 —— 与框架其余部分完全同构。这里没有 Frame.Navigate(typeof(Page)),没有挂在 CurrentPage 属性上的 INotifyPropertyChanged,也没有注册进 DI 的 NavigationService。栈是状态,页面是渲染输出。 正因如此,生命周期 Hook(UseNavigationLifecycle)可测试,深链接(DeepLinkMap<TRoute>)通过 GetState / SetState 可以轻松往返序列化,回退栈也能以普通 IReadOnlyList<TRoute> 的形式暴露给组件代码。如果你是新手,先读 定义路由;本文余下部分都围绕这一个形状展开。
导航¶
Reactor 采用基于栈的导航模型,配以类型安全的路由。你把路由定义为一个枚举,用 UseNavigation 创建导航句柄,再用 NavigationHost 渲染当前页面。
定义路由¶
先为你的页面定义一个枚举:
每个枚举值代表应用中的一个独立页面。导航系统用这个类型来保证你只能导航到合法的路由。对于携带数据的页面 —— 绑定到某一行 ID 的详情页、捕获了表单状态的向导步骤 —— 请改用「判别联合」模式:用 C# record 实现一个 sealed 接口,而不是扁平枚举。
基础导航¶
调用 UseNavigation(Route.Home) 创建一个以初始路由为根节点的导航句柄。用 NavigationHost 渲染当前页面:
class BasicNavDemo : Component
{
public override Element Render()
{
var nav = UseNavigation(Route.Home);
return VStack(12,
SubHeading($"Current: {nav.CurrentRoute}"),
TextBlock($"Stack depth: {nav.Depth}"),
HStack(8,
Button("Home", () => nav.Navigate(Route.Home)),
Button("Settings", () => nav.Navigate(Route.Settings)),
Button("Profile", () => nav.Navigate(Route.Profile)),
Button("Back", () => nav.GoBack())
.IsEnabled(nav.CanGoBack)
),
NavigationHost(nav, route => route switch
{
Route.Home => TextBlock("Welcome home!").FontSize(18).Padding(16),
Route.Settings => TextBlock("Settings page").FontSize(18).Padding(16),
Route.Profile => TextBlock("Your profile").FontSize(18).Padding(16),
_ => TextBlock("Not found").Padding(16)
})
).Padding(24);
}
}

各部分的作用如下:
UseNavigation(Route.Home)创建一个以Home为初始路由的NavigationHandle<Route>。在你的根组件里调用一次。nav.Navigate(route)把一个新路由压入栈。nav.GoBack()弹出当前路由并返回上一个。NavigationHost(nav, route => ...)渲染路由映射为当前路由返回的那个元素。
参考¶
| API | 形态 | 用途 |
|---|---|---|
UseNavigation<TRoute>(initial) |
Hook(根) | 为当前子树创建导航句柄。 |
UseNavigation<TRoute>() |
Hook(后代) | 通过上下文读取祖先的句柄。 |
UseNavigationLifecycle(...) |
Hook | 页面侧的 onNavigatedTo / onNavigatingFrom / onNavigatedFrom。from 回调会收到一个带 .Cancel() 的上下文。 |
UseSystemBackButton(nav, window) |
Hook | 把标题栏 / 硬件 Back 键接到 nav.GoBack。 |
NavigationHost(nav, routeMap) |
元素 | 渲染当前路由。可设置 Transition、CacheMode、CacheSize。 |
NavigationView(items, content) |
元素 | 侧边栏外壳。用 .SelectedTagChanged(handler) 处理选择事件,用 .IsPaneOpen(value, handler) 由状态驱动侧边栏。 |
TabView(tabs) |
元素 | 并行工作区;各标签页保有自己的状态。 |
BreadcrumbBar(items) |
元素 | 用于钻取式导航的祖先路由轨迹。 |
Frame(...) |
元素 | 原生 WinUI Frame,用于与已有 .xaml 文件的页面互操作;提供 .Navigating、.Navigated、.NavigationFailed。不用于 Reactor 应用内部的导航 —— 见下文。 |
DeepLinkMap<TRoute> |
类型 | URI 模式 → 路由工厂。.Resolve(uri) 返回匹配到的路由与回退栈。 |
NavigationDiagnostics |
静态 | 用于追踪的静态事件:NavigationRequested、NavigationCompleted、NavigationCancelled,以及缓存与过渡事件。 |
NavigationView¶
NavigationView 创建一个带图标的侧边菜单。把它与 NavigationHost 搭配就是标准的应用布局:
class NavViewDemo : Component
{
public override Element Render()
{
var nav = UseNavigation(Route.Home);
return NavigationView(
[
NavItem("Home", icon: "Home", tag: "Home"),
NavItem("Settings", icon: "Setting", tag: "Settings"),
NavItem("Profile", icon: "Contact", tag: "Profile")
],
content: NavigationHost(nav, route => route switch
{
Route.Home => VStack(12, Heading("Home"),
TextBlock("Welcome to the app."),
Button("Go to Settings",
() => nav.Navigate(Route.Settings))).Padding(24),
Route.Settings => VStack(12, Heading("Settings"),
TextBlock("Configure your preferences."),
Button("Back", () => nav.GoBack())).Padding(24),
Route.Profile => VStack(12, Heading("Profile"),
TextBlock("View your profile info.")).Padding(24),
_ => TextBlock("Not found").Padding(24)
})
);
}
}

NavItem 接受一个标签、一个可选的图标名(取自 Segoe Fluent Icons 字体)以及一个可选的 tag 字符串。NavigationView 自行处理选中状态,并显示你传入的 content。
栈操作¶
除简单的入栈与出栈外,NavigationHandle 还支持若干栈操作:
class StackOperationsDemo : Component
{
public override Element Render()
{
var nav = UseNavigation(Route.Home);
return VStack(12,
SubHeading($"Current: {nav.CurrentRoute}"),
TextBlock($"Back stack: {nav.BackStack.Count}"),
TextBlock($"Forward stack: {nav.ForwardStack.Count}"),
HStack(8,
Button("Navigate", () =>
nav.Navigate(Route.Settings)),
Button("Replace", () =>
nav.Replace(Route.Profile)),
Button("Reset", () =>
nav.Reset(Route.Home)),
Button("Back", () => nav.GoBack())
.IsEnabled(nav.CanGoBack),
Button("Forward", () => nav.GoForward())
.IsEnabled(nav.CanGoForward)
),
NavigationHost(nav, route =>
TextBlock($"Page: {route}")
.FontSize(18).Padding(16))
).Padding(24);
}
}

| 方法 | 效果 |
|---|---|
Navigate(route) |
把路由压入回退栈并导航过去 |
GoBack() |
弹出当前路由,返回上一个 |
GoForward() |
前进(在回退之后) |
Replace(route) |
替换当前路由,不触碰栈 |
Reset(route) |
清空所有栈,从目标路由重新开始 |
PopTo(predicate) |
持续弹出,直到找到匹配的路由 |
用 CanGoBack 与 CanGoForward 来启用/禁用导航按钮。
页面生命周期¶
UseNavigationLifecycle 让组件对导航事件作出反应。用它可以在页面出现时加载数据,或在页面消失时保存状态:
class LifecyclePage : Component
{
public override Element Render()
{
var (log, updateLog) = UseReducer(new List<string>());
UseNavigationLifecycle(
onNavigatedTo: ctx =>
updateLog(l => [.. l,
$"{l.Count + 1}: Arrived from {ctx.PreviousRoute}"]),
onNavigatingFrom: ctx =>
updateLog(l => [.. l,
$"{l.Count + 1}: Leaving to {ctx.TargetRoute}"]),
onNavigatedFrom: ctx =>
updateLog(l => [.. l,
$"{l.Count + 1}: Left for {ctx.TargetRoute}"])
);
return VStack(8,
SubHeading("Lifecycle Events"),
VStack(4,
log.TakeLast(5).Select(entry =>
TextBlock(entry).FontSize(12).Opacity(0.7).WithKey(entry)
).ToArray()
)
).Padding(16);
}
}

四个回调在不同时点触发:
| 回调 | 触发时机 |
|---|---|
onNavigatingTo |
本页激活之前。调用 ctx.Cancel() 可从目的地一侧拒绝。 |
onNavigatedTo |
本页激活之后 |
onNavigatingFrom |
离开本页之前。调用 ctx.Cancel() 可拦截 —— 经典的「未保存改动」守卫。 |
onNavigatedFrom |
本页不再处于活动状态之后 |
用 onNavigatedTo 拉取数据或启动计时器。用 onNavigatingFrom 保存草稿或确认未保存的改动 —— 在 NavigatingFromContext 上调用 ctx.Cancel() 可彻底阻止导航。关于生命周期模式与异步取消的更多内容,见副作用与生命周期。
注意:
Reset(route)、Replace(route)和PopTo(predicate)都会运行onNavigatingFrom守卫 ——ctx.Cancel()能拦住它们。但SetState(state)不会:它走的是NavigationStack.RestoreState,完全绕过InvokeGuard,并在栈已被改写之后以NavigationMode.Reset触发Navigated。如果你的未保存改动守卫只写在onNavigatingFrom里,那么一个在启动时从磁盘恢复导航状态的应用,会默默覆盖当前页面而从不询问用户。要么显式地为状态恢复设闸(调用SetState前先检查内存中的 dirty 标志),要么从更高层暴露同一套脏检查 —— 例如Window.Closed中的应用关闭处理器。
页面过渡¶
NavigationHost 支持动画页面过渡。设置 Transition 属性来控制页面进出时的动画方式:
class PageTransitionsDemo : Component
{
public override Element Render()
{
var nav = UseNavigation(Route.Home);
return VStack(12,
SubHeading("Page Transitions"),
HStack(8,
Button("Home", () => nav.Navigate(Route.Home)),
Button("Settings", () => nav.Navigate(Route.Settings)),
Button("Profile", () => nav.Navigate(Route.Profile)),
Button("Back", () => nav.GoBack())
.IsEnabled(nav.CanGoBack)
),
NavigationHost(nav, route => route switch
{
Route.Home => VStack(8,
TextBlock("Home").FontSize(24).Bold(),
TextBlock("DrillIn transition on navigate")).Padding(16),
Route.Settings => VStack(8,
TextBlock("Settings").FontSize(24).Bold(),
TextBlock("DrillIn transition to detail")).Padding(16),
_ => TextBlock($"{route}").FontSize(18).Padding(16)
}) with { Transition = NavigationTransition.DrillIn() }
).Padding(24);
}
}

| 过渡 | 效果 |
|---|---|
NavigationTransition.Entrance() |
上滑 + 淡入(默认) |
NavigationTransition.Slide() |
滑动 + 淡入 —— 不指定方向时是垂直方向 |
NavigationTransition.Fade() |
交叉淡入淡出 |
NavigationTransition.DrillIn() |
从中心缩放 + 淡入 |
NavigationTransition.Spring() |
弹簧物理滑动 |
NavigationTransition.None |
瞬时切换 |
默认采用入场动效,因为 WinUI 本身就是这样:一个未携带 NavigationTransitionInfo 而完成导航的 Frame 会播放 EntranceNavigationTransitionInfo,也就是「页面刷新」动画。因此 Reactor 应用开箱即得的动画表现与等价的 WinUI XAML 应用一致。NavigationTransition.Default 是该动效的别名 —— 当你的意思是「这个动画」而非「框架默认的那个」时,直接写 Entrance()。
过渡在合成器线程上运行 —— 播放期间不涉及托管代码。GoBack 会自动反向。合成器过渡的更多内容见动画。
深链接¶
DeepLinkMap 把 URI 模式映射到路由构造器。用它可以从激活 URI 或协议处理器恢复导航状态:
class DeepLinkingDemo : Component
{
public override Element Render()
{
var map = UseMemo(() => new DeepLinkMap<Route>()
.Map("/", _ => Route.Home)
.Map("/settings", _ => Route.Settings)
.Map("/profile", _ => Route.Profile));
var (result, setResult) = UseState("(none)");
return VStack(12,
SubHeading("Deep Linking"),
HStack(8,
Button("Resolve /", () =>
setResult($"/ -> {map.Resolve("/").Matched}")),
Button("Resolve /settings", () =>
setResult($"/settings -> {map.Resolve("/settings").Matched}")),
Button("Resolve /unknown", () =>
setResult($"/unknown -> {map.Resolve("/unknown").Matched}"))
),
TextBlock($"Result: {result}").FontSize(14).Opacity(0.7)
).Padding(24);
}
}

模式段支持丰富的匹配能力:
| 段 | 匹配 |
|---|---|
/literal |
精确匹配 |
/{param} |
捕获一个字符串 |
/{param:int} |
强类型捕获(int、long、bool、Guid) |
/{param?} |
可选参数 |
/{**} |
通配符 —— 匹配剩余路径 |
?key=value |
查询字符串参数 |
必填参数用 RouteArgs.Get<T>(name),可选参数用 RouteArgs.GetOrDefault<T>(name),查询字符串值用 RouteArgs.Query<T>(name)。backStackFactory 参数用于构造一个合成的回退栈,这样即使是通过深链接进入,GoBack 依然可用。
class DeepLinkQueryDemo : Component
{
public override Element Render()
{
var (info, setInfo) = UseState("(none)");
// RouteArgs 在工厂 lambda 内部可用 ——
// 在此捕获强类型参数与查询值
var map = UseMemo(() => new DeepLinkMap<Route>()
.Map("/", _ => Route.Home)
.Map("/settings", _ => Route.Settings)
.Map("/users/{id:int}/posts/{postId:int}",
args =>
{
var userId = args.Get<int>("id");
var postId = args.Get<int>("postId");
var sort = args.Query<string>("sort");
setInfo($"userId={userId}, postId={postId}, sort={sort}");
return Route.Details;
},
() => new[] { Route.Home })
);
return VStack(12,
SubHeading("Deep Link Query Params"),
Button("Resolve /users/42/posts/7?sort=date", () =>
map.Resolve("/users/42/posts/7?sort=date")),
TextBlock($"Captured: {info}").FontSize(14).Opacity(0.7)
).Padding(24);
}
}
该示例展示了强类型的路径参数与查询值。解析器把 /users/42/posts/7?sort=date 拆成可以直接在路由构造器里使用的强类型值。
页面缓存¶
在 NavigationHost 上设置 CacheMode,即可在多次导航之间保留页面状态。被缓存的页面会保持其可视化树存活,而不是重新挂载:
class PageCachingDemo : Component
{
public override Element Render()
{
var nav = UseNavigation(Route.Home);
return VStack(12,
SubHeading("Page Caching"),
TextBlock("Text input is preserved across navigations."),
HStack(8,
Button("Home", () => nav.Navigate(Route.Home)),
Button("Settings", () => nav.Navigate(Route.Settings)),
Button("Back", () => nav.GoBack())
.IsEnabled(nav.CanGoBack)
),
NavigationHost(nav, route => route switch
{
Route.Home => CachedPage("Home"),
Route.Settings => CachedPage("Settings"),
_ => TextBlock($"{route}").Padding(16)
}) with
{
CacheMode = NavigationCacheMode.Enabled,
CacheSize = 5
}
).Padding(24);
}
static Element CachedPage(string name) =>
VStack(8,
TextBlock(name).FontSize(20).Bold(),
TextBox("", _ => { }, placeholderText: "Type here — state persists")
.AutomationName($"{name} page note")
).Padding(16);
}

| 缓存模式 | 行为 |
|---|---|
Disabled |
总是卸载/重新挂载(默认) |
Enabled |
LRU 缓存,最多 CacheSize 项 |
Required |
总是缓存,永不淘汰 |
缓存会保留滚动位置、文本输入与组件状态。对于那些渲染开销大、或丢失状态会让用户感到挫败的页面,请使用它。Required 适合一小组固定的常驻页面(比如应用的三个标签面板);对于无界的路由空间(按行 ID 参数化的详情页),请坚持用 Enabled 并调好 CacheSize —— Required 永不淘汰,会把每一个访问过的路由的元素树保留到进程结束。
TabView¶
对于基于标签页的导航,使用 TabView 加 Tab 项。每个标签页独立持有自己的内容:
class TabNavDemo : Component
{
public override Element Render()
{
return TabView(
Tab("Documents",
VStack(12,
TextBlock("Your documents appear here."),
Button("New Document", () => { })
).Padding(24)
),
Tab("Recent",
VStack(12,
TextBlock("Recently opened files."),
TextBlock("No recent files.").Opacity(0.5)
).Padding(24)
),
Tab("Shared",
VStack(12,
TextBlock("Files shared with you."),
TextBlock("Nothing shared yet.").Opacity(0.5)
).Padding(24)
)
);
}
}

与基于栈的导航不同,标签页会同时让所有内容保持存活。当用户需要在多个并行工作区之间自由切换时,使用标签页。
填满标签页内容区¶
WinUI 的 DefaultTabViewStyle 在 TabView 控件本身上带了 VerticalAlignment="Top"。顶对齐的控件会按其期望高度排布,因此控件模板里那个 * 内容行永远分不到剩余空间,标签页主体会塌缩到当前选中标签内容的自有条件高度。一个带背景的子标签只会让自己文字背后那一小条上色。
Reactor 保留了这个 WinUI 默认行为。用 FillContentArea 主动选择全高的标签页主体:
TabView([
Tab("Editor", Border(TextBlock("Editor")).Background(Theme.CardBackground)),
Tab("Preview", TextBlock("Preview")),
]).FillContentArea();
该 init 属性同样可用于 record 构造语法:
TabView([
Tab("Editor", TextBlock("Editor")),
Tab("Preview", TextBlock("Preview")),
]) with { FillContentArea = true };
在 TabView 元素上显式调用的 .VAlign(...) 总是优先于这个开关,因此你仍然可以把标签栏固定在高容器的顶部。
状态序列化¶
NavigationHandle 可以把完整的导航状态 —— 回退栈、当前路由、前进栈 —— 捕获为一份普通的 NavigationState<TRoute> 快照,并加以恢复。Reactor 有意不选定序列化格式:快照怎么持久化由你决定(走自己的 source-gen 上下文的 JSON、MessagePack、手写二进制皆可)。
class StateSerializationDemo : Component
{
public override Element Render()
{
var nav = UseNavigation(Route.Home);
var (savedJson, setSavedJson) = UseState<string?>(null);
return VStack(12,
SubHeading("State Serialization"),
HStack(8,
Button("Navigate", () =>
nav.Navigate(Route.Settings)),
Button("Save State", () =>
setSavedJson(System.Text.Json.JsonSerializer.Serialize(
nav.GetState(), DocsNavJsonContext.Default.NavigationStateRoute))),
Button("Restore State", () =>
{
if (savedJson is not null)
{
var state = System.Text.Json.JsonSerializer.Deserialize(
savedJson, DocsNavJsonContext.Default.NavigationStateRoute);
if (state is not null) nav.SetState(state);
}
}).IsEnabled(savedJson is not null)
),
TextBlock($"Current: {nav.CurrentRoute}"),
TextBlock($"Saved: {savedJson?[..Math.Min(50, savedJson?.Length ?? 0)] ?? "(none)"}")
.FontSize(12).Opacity(0.6),
NavigationHost(nav, route =>
TextBlock($"Page: {route}").Padding(16))
).Padding(24);
}
}
[System.Text.Json.Serialization.JsonSerializable(typeof(NavigationState<Route>))]
partial class DocsNavJsonContext : System.Text.Json.Serialization.JsonSerializerContext { }

GetState() 返回一个 NavigationState<TRoute> record。SetState(state) 恢复各栈,并以 Reset 模式触发 Navigated。该 record 带有 [JsonPropertyName] 元数据,所以一行 JsonSerializer.Serialize(snapshot, MyJsonContext.Default.NavigationStateRoute) 就能免费得到 camelCase JSON —— 与 JsonSerializerContext 配合时完全 AOT 安全。对于多态路由层级,请给基类型标注 [JsonPolymorphic] 与 [JsonDerivedType],这样往返序列化才能保留判别符。
Frame 事件¶
当你为了与 XAML 页面互操作而内嵌原生 WinUI Frame 时(而不是使用 NavigationHost),导航生命周期通过流畅事件扩展暴露出来:
class FrameEventsDemo : Component
{
public override Element Render()
{
var (log, updateLog) = UseReducer(new List<string>());
return VStack(8,
SubHeading("Frame events"),
Frame(sourcePageType: typeof(DocsFrameDemoPage))
.Navigating(target =>
updateLog(l => [.. l, $"{l.Count + 1}: Navigating to {target.Name}"]))
.Navigated(target =>
updateLog(l => [.. l, $"{l.Count + 1}: Arrived at {target.Name}"]))
.NavigationFailed((target, ex) =>
updateLog(l => [.. l, $"{l.Count + 1}: Failed {target.Name}: {ex.Message}"]))
.Height(300),
VStack(4, log.TakeLast(6).Select(
entry => TextBlock(entry).FontSize(11).Opacity(0.6).WithKey(entry)
).ToArray())
).Padding(24);
}
}
| 流畅方法 | 触发时机 |
|---|---|
.Navigating(handler) |
在离开当前页面之前 |
.Navigated(handler) |
新页面已显示之后 |
.NavigationFailed(handler) |
页面构造抛异常时 —— 收到目标类型与该异常 |
底层的 init 属性(OnNavigating、OnNavigated、OnNavigationFailed)仍可用于 record 构造语法。流畅扩展去掉了前缀 On —— 促成这一约定的 C# 命名约束见 spec 039 §0.1。
目标类型必须拥有 .xaml 文件¶
Frame 是 WinUI 的导航控件,而 WinUI 是通过 XAML 编译器生成的 XAML 类型元数据来解析目标类型的。一个只在 C# 中声明的 Page 永远不会进入那份元数据,所以 WinUI 无法解析它 —— 而对一个无法解析的类型调用 Frame.Navigate 会让进程以访问冲突终止,而不是抛异常,任何 try/catch 与 Application.UnhandledException 处理器都拦不住。
Reactor 会先检查并拒绝,通过 .NavigationFailed(...) 上报:
Frame(typeof(MyCodeOnlyPage)) // no .xaml → refused, not fatal
.NavigationFailed((t, ex) => Log(ex.Message));
这是一个有意的边界,而非缺口。 Reactor 的导航系统是 UseNavigation<TRoute> + NavigationHost —— 它不需要 XAML、不需要 Page 子类、不需要无参构造函数,并且提供了 Frame 无法给出的强类型回退栈、生命周期 Hook、过渡与深链接。Frame 的存在是为了服务那些已有 XAML 页面、想把它们托管进 Reactor 树的应用。关于让 Frame 不适合作为 Reactor 导航模型的另外三条约束(IPage 硬转换、无参构造函数激活、缺少扩展点),见 spec 011 §"Why WinUI Frame is not the answer"。
接上 .NavigationFailed(...) 也等于把失败标记为已处理,于是被拒绝的导航会报告给你的处理器,而不会把整个渲染过程撕掉。不接的话,该失败会以普通托管异常的形式浮现,你可以直接断点拦下。
NavigationView.SelectedTagChanged¶
NavigationView 有一个对应 tag 变更回调的流畅方法,每当用户选中不同的 NavItem 时触发:
class SelectedTagChangedDemo : Component
{
public override Element Render()
{
var nav = UseNavigation(Route.Home);
var (lastTag, setLastTag) = UseState<string?>(null);
return NavigationView(
[
NavItem("Home", icon: "Home", tag: "Home"),
NavItem("Settings", icon: "Setting", tag: "Settings")
],
content: VStack(12,
Heading(lastTag ?? "Home"),
TextBlock("Last selected tag: " + (lastTag ?? "(none)"))
.Opacity(0.6)
).Padding(24)
).SelectedTagChanged(tag =>
{
setLastTag(tag);
if (tag == "Settings") nav.Navigate(Route.Settings);
});
}
}
SelectedTagChanged 收到的是 tag 字符串(若无项被选中则为 null)。给该流畅方法传 null 会清除此前设置的处理程序。
NavigationView.PaneOpenChanged¶
IsPaneOpen 是受控状态,因此它需要一个配套的变更通知:NavigationView 会响应一些你的应用从未目睹的事情来自己开合侧边栏 —— 内容区的轻关闭点击,或者窗口缩放导致的自适应显示模式变化。接上 .PaneOpenChanged(handler),把值直接回灌进驱动 IsPaneOpen 的那个状态:
class PaneOpenChangedDemo : Component
{
public override Element Render()
{
var (isPaneOpen, setIsPaneOpen) = UseState(true);
return (NavigationView(
[
NavItem("Home", icon: "Home", tag: "Home"),
NavItem("Settings", icon: "Setting", tag: "Settings")
],
content: VStack(12,
Heading("Pane state"),
TextBlock(isPaneOpen ? "Pane is open" : "Pane is closed").Opacity(0.6),
Button(isPaneOpen ? "Close pane" : "Open pane",
() => setIsPaneOpen(!isPaneOpen))
.AutomationName(isPaneOpen ? "Close pane" : "Open pane")
).Padding(24)
) with
{
IsPaneOpen = isPaneOpen,
})
// 轻关闭与自适应显示模式变化会在不询问应用的情况下移动侧边栏 ——
// 把新状态推回去,切换按钮才不会说谎。
.PaneOpenChanged(setIsPaneOpen)
.Height(320);
}
}
没有它,组件状态会一直保留旧值,下一次切换写入的是控件已经处于的值,于是看起来毫无反应 —— 侧边栏要点两次才打开。该回调也会作为你自己那次 IsPaneOpen 写入的回声触发,那是一次无害的空操作状态更新。传 null 会清除处理程序。
因为这两者总是成对出现,所以有一个成对重载可以一次调用同时设置状态并接好处理程序 —— 只要侧边栏由状态驱动,就用它:
class ControlledPaneDemo : Component
{
public override Element Render()
{
var (isPaneOpen, setIsPaneOpen) = UseState(true);
return NavigationView(
[
NavItem("Home", icon: "Home", tag: "Home"),
NavItem("Settings", icon: "Setting", tag: "Settings")
],
content: Button(isPaneOpen ? "Close pane" : "Open pane",
() => setIsPaneOpen(!isPaneOpen))
.AutomationName(isPaneOpen ? "Close pane" : "Open pane")
.Padding(24)
)
// 一次调用同时设置侧边栏状态并接好变更处理程序,
// 两者无法再各自漂移。
.IsPaneOpen(isPaneOpen, setIsPaneOpen)
.Height(320);
}
}
SplitView 暴露了同样的 .PaneOpenChanged(handler) 与 .IsPaneOpen(value, handler) 组合。
NavigationView.ItemInvoked 与层级事件¶
SelectedTagChanged 只在选中项真正发生变化时触发。要对每一次激活作出反应 —— 包括用户重新点选已经选中的项 —— 请用 .ItemInvoked(handler)。层级项通过 .ItemExpanding(handler) / .ItemCollapsed(handler) 上报自身的展开/折叠,自适应布局则通过 .DisplayModeChanged(handler) 上报其 Minimal/Compact/Expanded 切换:
class NavItemEventsDemo : Component
{
public override Element Render()
{
var (log, setLog) = UseState("(nothing yet)");
return NavigationView(
[
NavItem("Library", icon: "Library", tag: "Library") with
{
Children = [NavItem("Albums", tag: "Albums")]
},
NavItem("Home", icon: "Home", tag: "Home")
],
content: TextBlock(log).Padding(24)
)
// 每次激活都会触发,包括重新点选当前项 ——
// 那种情况下 SelectedTagChanged 保持沉默。设置项会报告
// NavigationViewElement.SettingsTag。
.ItemInvoked(tag => setLog($"invoked {tag}"))
// 层级项上报自身的展开/折叠。
.ItemExpanding(tag => setLog($"expanding {tag}"))
.ItemCollapsed(tag => setLog($"collapsed {tag}"))
// 自适应布局在 Minimal/Compact/Expanded 之间切换时触发。
.DisplayModeChanged(mode => setLog($"display mode {mode}"))
.Height(320);
}
}
ItemInvoked、ItemExpanding 与 ItemCollapsed 收到的是该项的 tag;DisplayModeChanged 收到的是新的 NavigationViewDisplayMode。四者传 null 时都会被清除。
NavigationView 页脚、设置项与侧边栏尺寸¶
FooterMenuItems 把项固定到侧边栏底部,其协调方式与 MenuItems 完全一致 —— SelectedTag 会同时搜索这两个集合。内置的设置项没有自己的 tag,因此 NavigationViewElement.SettingsTag 就是选中它的哨兵值,而 .SettingsSelected(handler) 会在设置项变为被选中时触发 —— 无论是用户点选它,还是你自己把 SelectedTag 设为该哨兵值:
class NavFooterSettingsDemo : Component
{
public override Element Render()
{
var (selected, setSelected) = UseState("Home");
return (NavigationView(
[NavItem("Home", icon: "Home", tag: "Home")],
content: TextBlock($"Selected: {selected}").Padding(24)
) with
{
// 固定到侧边栏底部,协调方式与 MenuItems 完全一致。
FooterMenuItems = [NavItem("About", icon: "Help", tag: "About")],
SelectedTag = selected,
// 该哨兵值选中内置设置项,它没有自己的 tag。
OnSettingsSelected = () => setSelected(NavigationViewElement.SettingsTag),
PaneHeader = TextBlock("My app").Bold(),
})
.SelectedTagChanged(tag => setSelected(tag ?? "Home"))
.CompactPaneLength(52)
.BackButtonVisible(false)
.Height(320);
}
}
如果你用 .WithNavigation(nav, routeToTag, tagToRoute) 驱动整个视图,设置项的路由是通过第四个参数主动开启的:.WithNavigation(nav, routeToTag, tagToRoute, () => Route.Settings)。它之所以是独立参数、而不是通过 tagToRoute 查 SettingsTag,是因为那个委托必须为交给它的每一个字符串返回一个路由 —— 大多数实现遇到无法识别的 tag 会抛异常 —— 所以它没有拒绝的办法。
控件其余的外壳部分同样声明式:.BackButtonVisible(visible) 与 .PaneToggleButtonVisible(visible) —— 当 TitleBar 已经接管那部分外壳时你会用到它们 —— 以及 .PaneVisible(visible)、.AlwaysShowHeader()、.TitleBarAutoPadding()、.SelectionFollowsFocus()、.OverflowLabelMode(mode)、.ShoulderNavigation(mode)、PaneHeader 与 ContentOverlay 槽位,以及 .CompactPaneLength(px) / .OpenPaneLength(px) / .CompactModeThresholdWidth(px) / .ExpandedModeThresholdWidth(px) 这几项尺寸。
这些流畅方法去掉了底层属性所带的
Is前缀(.BackButtonVisible(false)设置的是IsBackButtonVisible),与同名的TitleBar流畅方法一致。两个元素控制的是同一块外壳,所以无论由谁拥有,调用读起来都一模一样。这四项侧边栏尺寸默认是
double.NaN,意思是 Reactor 永不写入该属性,控件保持自己的默认值。这个哨兵值具有粘性:先设一个显式值、之后又放回NaN,只会跳过写入,而不会恢复 WinUI 的默认值。请把尺寸驱动到你要的值,而不是把它「清空」。
导航诊断¶
NavigationDiagnostics 暴露静态事件用于调试与遥测。订阅它即可追踪导航活动,而无需修改页面代码:
class DiagnosticsDemo : Component
{
public override Element Render()
{
var nav = UseNavigation(Route.Home);
var (log, updateLog) = UseReducer(new List<string>());
UseEffect(() =>
{
EventHandler<NavigationDiagnosticEvent> onRequested =
(_, e) => updateLog(l => [.. l, $"{l.Count + 1}: Requested {e.From} → {e.To}"]);
EventHandler<NavigationDiagnosticEvent> onCompleted =
(_, e) => updateLog(l => [.. l, $"{l.Count + 1}: Completed {e.To}"]);
NavigationDiagnostics.NavigationRequested += onRequested;
NavigationDiagnostics.NavigationCompleted += onCompleted;
return () =>
{
NavigationDiagnostics.NavigationRequested -= onRequested;
NavigationDiagnostics.NavigationCompleted -= onCompleted;
};
});
return VStack(12,
SubHeading("Navigation Diagnostics"),
HStack(8,
Button("Home", () => nav.Navigate(Route.Home)),
Button("Settings", () => nav.Navigate(Route.Settings))
),
VStack(4, log.TakeLast(6).Select(
entry => TextBlock(entry).FontSize(11).Opacity(0.6).WithKey(entry)
).ToArray()),
NavigationHost(nav, route =>
TextBlock($"Page: {route}").Padding(16))
).Padding(24);
}
}
| 事件 | 触发时机 |
|---|---|
NavigationRequested |
一次导航即将开始 |
NavigationCompleted |
页面完成加载 |
NavigationCancelled |
某个生命周期守卫取消了导航 |
| 缓存事件 | 页面被缓存、淘汰或命中 |
| 过渡事件 | 过渡开始或完成 |
事件在 UI 线程上同步触发。可用它们做开发期日志、分析或自定义进度指示器。
模式¶
脏表单上的受保护离开¶
onNavigatingFrom 的经典用法就是「未保存改动」守卫。用 UseState 与表单状态并排跟踪一个 dirty 布尔值,在用户带着未提交的编辑尝试离开时拒绝该次导航。用 ContentDialog 提示用户;若他们选择「放弃」,把 dirty = false 再调用一次 nav.GoBack() —— 第二次调用看到的是干净表单,于是成功。recipes/multi-step-form 那份实践范例把同一形状贯穿到了一整个向导里。
按路由恢复滚动位置¶
页面缓存会保留元素树,但 Disabled 模式不会 —— 重新挂载意味着一个全新的 ScrollView。当 CacheMode = Disabled 才是正确形态时(内存受限的应用、动态路由),在导航宿主父级的一个 UseRef 里维护一份 Dictionary<TRoute, double> 滚动偏移表,在 onNavigatingFrom 中保存偏移,并在页面里某个于 ScrollView 挂载之后运行的 UseEffect 中恢复它。虚拟化列表则配合 VirtualListRef.RestoreScrollOffset 使用。
深链接 → 强类型路由,并带回退栈¶
默认情况下,深链接会让用户落在匹配到的路由上,而回退栈是空的 —— 「返回」无处可去。给 DeepLinkMap.Map(...) 传 backStackFactory,即可合成出用户若通过自然导航本会构建出的那个栈。对于 /users/42/posts/7,该工厂应返回 [Route.Home, Route.UserList, Route.UserDetail(42)],这样用户能沿层级逐级退回。
常见错误¶
从字符串类型的 prop 读取路由¶
// Don't:
class Shell : Component<ShellProps>
{
public override Element Render()
{
return Props.CurrentRoute switch
{
"home" => Home(),
"settings" => Settings(),
...
};
}
}
字符串路由的外壳会丢掉导航系统提供的所有类型安全收益。编译器抓不到拼写错误;分析器无法对不可达路由发出警告;重构改名只能靠 grep。请使用定义路由一节展示的枚举(或 record 联合)形式 —— 路由映射的穷尽性检查就落在 C# 的 switch 表达式里。
把 UseNavigation 当作单例¶
// Don't:
public static NavigationHandle<Route>? Nav;
class Shell : Component
{
public override Element Render()
{
var nav = UseNavigation(Route.Home);
Nav = nav; // capture for later use from anywhere
return ...;
}
}
该句柄绑定在创建它的那个组件的调度器上。自 issue #234 起,各改写方法(Navigate、GoBack、GoForward、Replace、Reset、PopTo、SetState)默认线程安全:从后台计时器或运行在 UI 线程之外的闭包里调用 Nav.Navigate(...),会自动把这次导航封送到那个被捕获的调度器上 —— 与 UseState / UseReducer 的契约一致(见线程与调度)。如果调度器从未被捕获,或者已经关闭,该调用会大声抛出 InvalidOperationException,而不会默默破坏回退/前进栈。把句柄塞进 static 依然是坏味道 —— 它比页面活得更久,并泄漏了捕获的调度器 —— 所以更推荐在后代组件里用 UseNavigation<TRoute>()(不带初始值)通过上下文访问同一个句柄,或者显式通过上下文传递它。
忘记 UseSystemBackButton¶
没有 UseSystemBackButton(nav, window) 的话,标题栏的 Back 箭头以及触屏设备上的硬件 Back 键都不会触发 nav.GoBack —— 它们会落到操作系统默认行为,在根页面上就是关闭窗口。在你调用 UseNavigation(initial) 的同一作用域里接一次 UseSystemBackButton;返回箭头就变成了 nav.GoBack,其可见性跟随 nav.CanGoBack。
提示¶
路由用枚举。 枚举给你编译期安全 —— 你无法导航到不存在的路由。对于携带数据的路由(比如详情页 ID),请用 record 判别联合模式。
在根部调用一次 UseNavigation(initial)。 子组件用 UseNavigation<Route>()(不带初始值)访问同一个句柄。它会通过上下文取到最近祖先的句柄。
列表到详情的流程用 NavigationTransition.DrillIn()。 它传达出层级关系,并与连接动画键天然搭配。
登出流程用 Reset。 它清空整个栈并从头开始,避免用户返回已认证页面。
把 UseSystemBackButton 与导航句柄配对。 调用 UseSystemBackButton(nav, window),即可把系统返回按钮(标题栏或硬件)自动接到你的导航栈上。