news 2026/5/26 11:12:36

CodeWF.AvaloniaControls 新增 Guide 引导控件:从 AtomUI Tour 到 Vex 落地

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CodeWF.AvaloniaControls 新增 Guide 引导控件:从 AtomUI Tour 到 Vex 落地

这篇记录一下CodeWF.AvaloniaControls新增的Guide引导控件,以及它在 Vex 里的实际落地。

新手引导这种控件,如果只讲属性和实现会比较抽象。它本质上是一个强交互控件:遮罩、高亮、卡片定位、菜单展开、TabItem 切换、目标控件延迟出现、弹层里继续定位目标,这些都需要先看画面,再看代码才容易理解。

先看 Vex 中的菜单类引导流程。完整的新手引导覆盖欢迎页、文件菜单、段落菜单、格式菜单、视图菜单、主题菜单、侧边栏、编辑区、预览区、状态栏和帮助菜单。下面这段 GIF 重点截取菜单和二级菜单步骤,能看到Guide会在步骤切换时主动打开菜单,再高亮菜单里的MenuItem

这段 GIF 已按当前 Vex 重新录制。文件菜单现在会按内容完整展开,导出入口和关闭入口可以一次看到,不再出现旧版下拉高度限制带来的菜单内滚动。

控件库 Demo 里也补了两个更小的例子,分别演示基础多步骤引导、封面内容、自定义操作按钮,以及非模态提示和文本进度指示器。

Guide的源码在这里:

https://github.com/dotnet9/CodeWF.AvaloniaControls/tree/main/src/CodeWF.AvaloniaControls/Controls/Guide

Vex 的落地代码主要在这里:

https://github.com/dotnet9/Vex/tree/main/src/Vex/Modules/Shell/Views/MainWindow.axaml https://github.com/dotnet9/Vex/tree/main/src/Vex/Modules/Shell/Views/MainWindow.axaml.cs https://github.com/dotnet9/Vex/tree/main/src/Vex/Modules/Shell/Views/ShellTitleMenuView.axaml https://github.com/dotnet9/Vex/tree/main/src/Vex/Modules/Shell/Views/ShellTitleMenuView.axaml.cs

为什么要做 Guide

Avalonia 里有PopupMenuItemTabControlFlyout这些基础控件,但它们不会直接组合成一个完整的新手引导流程。

一个真正能用在桌面软件里的引导控件,至少要处理这些问题:

  • 多步骤流程:上一页、下一页、完成、关闭。

  • 每一步绑定不同目标控件。

  • 没有目标控件时居中显示说明。

  • 有目标控件时绘制遮罩,并在目标周围挖出高亮区域。

  • 引导卡片根据目标位置显示在上、下、左、右等方向。

  • 目标在滚动区域内时自动滚动到可见位置。

  • 目标控件晚一点才出现时,等待并重新定位。

  • 目标在MenuPopupFlyout这种弹层里时,也能正确高亮。

  • 布局变化、窗口大小变化后,重新计算高亮区域。

我参考的是AtomUI里的Tour漫游式引导控件。AtomUI 的Tour把主控件做成TemplatedControl,步骤抽象为ITourStepOption,再用弹层和遮罩层组合出引导效果。这个方向很适合 Avalonia。

CodeWF 的Guide沿用了这个思路,但实现上更贴近桌面软件里的真实入口:

  • 使用GuideOverlay自绘遮罩和高亮区域。

  • 使用Popup显示引导卡片。

  • 通过GuideStep声明每一步,也支持StepsSource数据源。

  • 通过StepOpeningOpeningCommand支持动态业务动作。

  • 通过TargetResolveDelay等待菜单、TabItem、Popup 内容完成布局。

  • 对弹层里的MenuItem做额外处理,避免菜单 light-dismiss 影响“下一步”按钮。

控件结构

Guide相关类型比较清晰:

  • Guide:主控件,管理打开关闭、当前步骤、遮罩、弹层和目标解析。

  • GuideStep:声明式步骤,用在 XAML 里。

  • GuideStepOption:代码创建步骤时使用。

  • IGuideStepOption:步骤统一接口。

  • GuideOverlay:负责绘制遮罩和目标高亮洞。

  • DefaultGuideIndicator:默认圆点进度指示器。

  • TextGuideIndicator:文本进度指示器,例如1 / 6

  • GuidePlacementMode:卡片位置枚举。

  • GuideMissingTargetBehavior:目标缺失时居中、跳过或关闭。

主题文件在:

https://github.com/dotnet9/CodeWF.AvaloniaControls/tree/main/src/CodeWF.AvaloniaControls.Themes/Themes/Controls/Guide.axaml

模板里有三个关键弹层:

  • PART_MaskPopup:当前窗口上的遮罩。

  • PART_TargetMaskPopup:目标在其他弹层或TopLevel里时使用。

  • PART_Popup:引导卡片本身。

这个结构让业务侧只关心“要引导哪几个目标”,控件内部负责遮罩、定位、按钮、指示器和清理。

基础用法

最简单的用法是把Guide放到页面根布局里,给每个GuideStep绑定目标控件:

<Grid> <StackPanel Orientation="Horizontal" Spacing="10"> <Button x:Name="UploadButton" Content="上传文件" /> <Button x:Name="SaveButton" Content="保存变更" /> <Button x:Name="MoreButton" Content="更多操作" /> </StackPanel> <codewf:Guide x:Name="BasicGuide" Placement="Bottom" PopupOffset="14"> <codewf:GuideStep Target="{Binding ElementName=UploadButton}" Title="上传文件" Description="把本地文件加入处理队列。" /> <codewf:GuideStep Target="{Binding ElementName=SaveButton}" Placement="Right" Title="保存变更" Description="保存当前工作区的配置和数据。" /> <codewf:GuideStep Target="{Binding ElementName=MoreButton}" Placement="Top" Title="更多操作" Description="更多操作可以继续展开为导出、复制或批处理。" /> </codewf:Guide> </Grid>

打开引导:

BasicGuide.GoTo(0); BasicGuide.Show();

如果要做非模态提示,可以关闭遮罩,并把进度指示器换成文本:

<codewf:Guide x:Name="NonMaskGuide" IsShowMask="False" Placement="Top" StyleType="Primary"> <codewf:Guide.Indicator> <codewf:TextGuideIndicator /> </codewf:Guide.Indicator> </codewf:Guide>

每一步也可以单独调整高亮区域:

<codewf:GuideStep Target="{Binding ElementName=PreviewPanel}" Placement="Left" GapOffsetX="16" GapOffsetY="16" GapRadius="14" Title="自定义高亮区域" Description="扩大圈选间距和圆角,适合突出整块区域。" />

遮罩怎么画

GuideOverlay的核心是用 EvenOdd 几何规则挖洞。

它先画一个覆盖整个窗口的矩形,再把目标控件区域作为第二个矩形加入同一个GeometryGroup,并设置:

geometry.FillRule = FillRule.EvenOdd;

最终效果就是:整屏变暗,目标控件区域保持透明。

目标区域通过屏幕坐标换算出来:

var targetTopLeft = target.PointToScreen(new Point(0, 0)); var origin = relativeTopLevel.PointToClient(targetTopLeft); var rect = new Rect(origin, target.Bounds.Size); var result = rect.Inflate(new Thickness(gapX, gapY));

这里没有直接用TranslatePoint,是因为菜单、Popup、Flyout 里的目标可能已经在另一个弹层宿主里。先拿屏幕坐标,再转回对应TopLevel的客户区坐标,会更稳。

动态菜单引导

动态菜单是这次Guide最重要的增强之一。

这段短 GIF 只保留菜单类步骤:文件菜单、打开文件夹、导出子菜单、段落菜单、格式菜单、视图菜单和主题二级菜单。它比完整流程更适合看MenuItem弹层目标的定位效果。

普通引导的目标控件本来就在页面上,直接高亮就行。菜单项不一样:子级MenuItem只有父菜单打开以后才会出现在视觉树里。

例如 Vex 里文件菜单、段落菜单、格式菜单、视图菜单、主题菜单和帮助菜单都是这样。引导到某个菜单项之前,需要先打开对应菜单,再等待菜单项完成布局。

控件库 Demo 里的简化写法是:

<Menu> <MenuItem x:Name="GuideThemeMenu" Header="主题色"> <MenuItem x:Name="GuideThemeBlueItem" Header="蓝色" /> <MenuItem x:Name="GuideThemeGreenItem" Header="绿色" /> <MenuItem x:Name="GuideThemePurpleItem" Header="紫色" /> </MenuItem> </Menu> <codewf:Guide x:Name="DynamicGuide" TargetResolveDelay="00:00:00.220" StepOpening="DynamicGuide_OnStepOpening"> <codewf:GuideStep Target="{Binding ElementName=GuideThemeMenu}" Title="主题色菜单" Description="先说明菜单入口本身。" /> <codewf:GuideStep Target="{Binding ElementName=GuideThemeBlueItem}" Placement="RightBottom" Title="蓝色主题" Description="打开菜单后再圈选下拉 MenuItem。" /> </codewf:Guide>

进入步骤时打开菜单:

private void DynamicGuide_OnStepOpening(object? sender, GuideStepEventArgs e) { GuideThemeMenu.IsSubMenuOpen = e.Index is >= 1 and <= 3; }

实际项目里我通常还会再投递一次到 UI 线程后台队列:

Dispatcher.UIThread.Post( () => GuideThemeMenu.IsSubMenuOpen = true, DispatcherPriority.Background);

原因是菜单弹层创建和布局不是完全同步完成的。GuideTargetResolveDelay会给菜单弹层一点时间,再去解析目标MenuItem

Vex 中的菜单项落地

Vex 的标题栏菜单在ShellTitleMenuView.axaml里,关键项都给了名字:

<MenuItem x:Name="FileMenuItem" Header="{i18n:I18n {x:Static l:VexL.MenuFile}}"> <MenuItem x:Name="OpenFolderMenuItem" Header="{i18n:I18n {x:Static l:VexL.OpenFolder}}" /> <MenuItem x:Name="ExportMenuItem" Header="{i18n:I18n {x:Static l:VexL.Export}}"> <MenuItem Header="HTML" /> <MenuItem Header="PDF" /> <MenuItem Header="PNG" /> </MenuItem> </MenuItem>

ShellTitleMenuView.axaml.cs把这些控件暴露给主窗口:

public MenuItem FileMenuTarget => FileMenuItem; public MenuItem OpenFolderMenuTarget => OpenFolderMenuItem; public MenuItem ExportMenuTarget => ExportMenuItem; public MenuItem TableMenuTarget => TableMenuItem; public MenuItem LinkMenuTarget => LinkMenuItem; public MenuItem SourceModeMenuTarget => SourceModeMenuItem; public MenuItem OutlineMenuTarget => OutlineMenuItem; public MenuItem ThemeDarkMenuTarget => ThemeDarkMenuItem; public MenuItem BeginGuideMenuTarget => BeginGuideMenuItem;

主窗口再把这些目标赋给对应的GuideStep

private void ConfigureOnboardingGuideTargets() { GuideFileMenuStep.Target = TitleMenuView.FileMenuTarget; GuideFileOpenStep.Target = TitleMenuView.OpenFolderMenuTarget; GuideFileExportStep.Target = TitleMenuView.ExportMenuTarget; GuideParagraphMenuStep.Target = TitleMenuView.TableMenuTarget; GuideFormatMenuStep.Target = TitleMenuView.LinkMenuTarget; GuideViewMenuStep.Target = TitleMenuView.SourceModeMenuTarget; GuideViewOutlineMenuStep.Target = TitleMenuView.OutlineMenuTarget; GuideThemeMenuStep.Target = TitleMenuView.ThemeDarkMenuTarget; GuideHelpMenuStep.Target = TitleMenuView.BeginGuideMenuTarget; }

进入步骤时,主窗口判断当前步骤属于哪个菜单:

private void OnboardingGuide_OnStepOpening(object? sender, GuideStepEventArgs e) { PrepareOnboardingGuideStep(e.Step); TitleMenuView.SetGuideMenuOpen(GetGuideMenuKey(e.Step)); }

SetGuideMenuOpen的职责是先关闭所有菜单,再打开当前步骤需要的菜单:

public void SetGuideMenuOpen(string? menuKey) { CloseGuideMenus(); if (string.IsNullOrWhiteSpace(menuKey)) { return; } ApplyGuideMenuOpen(menuKey); Dispatcher.UIThread.Post(() => ApplyGuideMenuOpen(menuKey), DispatcherPriority.Background); }

主题菜单还有二级菜单,处理时要连续打开:

case ThemeColorGuideMenu: ThemeMenuItem.IsSubMenuOpen = true; ThemeColorMenuItem.IsSubMenuOpen = true; break;

这就是菜单项引导的完整链路:

  1. GuideStep.Target指向具体MenuItem

  2. StepOpening打开父菜单。

  3. TargetResolveDelay等待弹层完成布局。

  4. Guide解析目标、绘制遮罩、显示卡片。

  5. 步骤结束或引导关闭时收起菜单。

TabItem 切换后再显示引导

Vex 左侧侧边栏是一个TabControl,里面有“文件”和“大纲”两个页签。新手引导里复用同一个侧边栏目标:先切到文件页签说明文档列表,再切到大纲页签说明导航区域。

主窗口里,侧边栏目标是同一个Border

<Border x:Name="SidebarGuideTarget" IsVisible="{Binding Layout.IsSidebarVisible}"> <TabControl prism:RegionManager.RegionName="{x:Static regions:RegionNames.ShellSidebarRegion}" SelectedIndex="{Binding Navigation.SelectedSideTabIndex, Mode=TwoWay}" /> </Border>

两个步骤都指向SidebarGuideTarget

<codewf:GuideStep x:Name="GuideSidebarFilesStep" Target="{Binding ElementName=SidebarGuideTarget}" Title="{i18n:I18n {x:Static l:VexL.GuideSidebarFilesTitle}}" /> <codewf:GuideStep x:Name="GuideSidebarOutlineStep" Target="{Binding ElementName=SidebarGuideTarget}" Title="{i18n:I18n {x:Static l:VexL.GuideSidebarOutlineTitle}}" />

区别在于进入步骤前先切换 TabItem:

private void PrepareOnboardingGuideStep(IGuideStepOption step) { if (DataContext is not MainWindowViewModel viewModel) { return; } if (ReferenceEquals(step, GuideSidebarFilesStep)) { viewModel.Layout.ShowFiles(); QueueOnboardingGuideRefresh(); return; } if (ReferenceEquals(step, GuideSidebarOutlineStep)) { viewModel.Layout.ShowOutline(); QueueOnboardingGuideRefresh(); } }

ShowFiles()ShowOutline()会确保侧边栏可见,并切换SelectedSideTabIndex

public void ShowOutline() { IsSidebarVisible = true; SelectSidebarTab(1); } public void ShowFiles() { IsSidebarVisible = true; SelectSidebarTab(0); }

最后重新刷新引导位置:

private void QueueOnboardingGuideRefresh() { Dispatcher.UIThread.Post(OnboardingGuide.Refresh, DispatcherPriority.Background); }

这一步很重要。TabItem 切换后,新内容需要等布局系统刷新才能得到正确尺寸。先切换业务状态,再把Guide.Refresh()投递到后台队列,能避免高亮区域还停在旧布局上。

编辑区、预览区和状态栏则直接在 XAML 里绑定现有目标:

<workspace:MarkdownEditorView x:Name="EditorGuideTarget" Grid.Column="2" /> <Border x:Name="PreviewGuideTarget" Grid.Column="4" IsVisible="{Binding Layout.IsPreviewVisible}" /> <shell:ShellStatusBarView x:Name="StatusBarGuideTarget" Grid.Row="2" />

这些步骤不需要打开菜单,只需要按普通控件重新定位即可。

首次启动只显示一次

Vex 里新手引导不是每次启动都弹出。配置里增加了:

<add key="HasSeenOnboardingGuide" value="false" />

窗口打开后检查这个状态:

private void QueueFirstRunOnboardingGuide() { if (_settingsStore is null || _settingsStore.Current.HasSeenOnboardingGuide == true) { return; } _settingsStore.Update(settings => settings with { HasSeenOnboardingGuide = true }); Dispatcher.UIThread.Post(BeginOnboardingGuide, DispatcherPriority.Background); }

第一次启动自动展示一次,并立即写回状态。之后用户可以从帮助菜单再次打开:

<MenuItem x:Name="BeginGuideMenuItem" Header="{i18n:I18n {x:Static l:VexL.OnboardingGuide}}" Click="BeginGuideMenuItem_OnClick" />

重新打开时从第一步开始:

private void BeginOnboardingGuide() { ConfigureOnboardingGuideTargets(); TitleMenuView.CloseGuideMenus(); OnboardingGuide.GoTo(0); OnboardingGuide.Show(); }

目前的已知限制

动态菜单引导现在还有一个待优化点:如果引导过程中软件失去焦点,菜单弹层会先按 Avalonia 的 light-dismiss 规则收起,后续引导也可能跟着消失。

这个问题本质上和“菜单弹层目标”“引导卡片按钮”“窗口激活状态”三者有关。当前实现已经对上一步、下一步、完成按钮做了PointerPressed优先导航处理,但窗口真正失焦时,菜单弹层仍然可能先关闭。后续可以考虑在Guide内部增加更明确的焦点恢复、弹层目标保持策略,或者在业务侧把动态菜单步骤和普通页面步骤拆得更清晰。

我现在更倾向于另一个方案:动态菜单弹出并完成布局后,先把菜单区域截图,贴回到引导遮罩层上,再按截图位置做挖洞和卡片定位。这样即使程序临时失去焦点,真实菜单弹层被 Avalonia 收起,用户看到的引导画面也不会突然消失或错位。

这个方向也有取舍:截图内容是静态的,窗口缩放、DPI 变化、主题切换、菜单内容变化时都要重新捕获;另外截图层不能响应真实菜单项交互,只适合引导说明,不适合把菜单操作和引导操作混在一起。后续如果实现,需要把“捕获弹层快照”“坐标换算”“遮罩挖洞”“失焦恢复”几个边界处理清楚。

如果你对动态菜单项引导有更好的实现思路,欢迎提交 PR:https://github.com/dotnet9/CodeWF.AvaloniaControls/pulls。如果在使用Guide时遇到问题,也可以直接提 Issue:https://github.com/dotnet9/CodeWF.AvaloniaControls/issues。

这次文章先不改控件代码,只把这个限制和可能的优化方向写出来,后续再单独优化。

实现里几个容易忽略的点

1. 目标延迟出现

菜单项、Popup 内容、TabItem 内容都可能不是马上可见。Guide里有TargetResolveDelay,并且会在目标暂时不可见时重试几次。

2. 弹层目标

菜单项通常挂在PopupRootOverlayPopupHost下,不一定和主窗口内容在同一棵视觉树里。Guide会判断目标是否来自弹层宿主,并用屏幕坐标换算高亮区域。

3. 菜单 light-dismiss

如果目标在菜单弹层里,用户点击引导卡片的“下一步”时,菜单可能先收到 light-dismiss 导致普通Button.Click丢失。Guide对上一步、下一步、完成按钮额外处理了PointerPressed,确保引导导航优先完成。

4. 布局刷新

目标控件LayoutUpdated、窗口ClientSize变化时,都要重新计算高亮区域。否则窗口调整大小、侧栏展开收起以后,高亮框就会错位。

5. 清理

关闭引导时要停止定时器、关闭所有 Popup、解绑目标和窗口事件,并把焦点尽量还给引导打开前的控件。这类控件横跨页面和弹层,如果清理不完整,很容易留下残余遮罩。

小结

Guide现在已经能覆盖桌面应用里常见的新手引导场景:

  • 基础多步骤引导。

  • 居中欢迎和结束步骤。

  • 封面内容、自定义操作按钮。

  • 默认圆点进度和文本进度。

  • 遮罩、非模态、高亮间距和圆角。

  • 菜单展开后引导MenuItem

  • 二级菜单项引导。

  • 切换 TabItem 后刷新并继续引导。

  • 目标延迟出现后的等待和重试。

  • 首次启动自动显示一次,帮助菜单再次打开。

这次在 Vex 里落地后,我更确定新手引导控件不能只做“静态按钮高亮”。桌面应用的真实入口经常藏在菜单、弹层和页签后面,Guide要能跟业务状态一起走,才算真正可用。

仓库地址与感谢

感谢 AtomUI 项目提供Tour漫游式引导控件作为重要参考:

  • 控件库 GitHub 地址:dotnet9/CodeWF.AvaloniaControls

  • AtomUI 地址:AtomUI/AtomUI

  • Vex 地址:dotnet9/Vex

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/26 11:12:02

Unity WebGL输入法终极解决方案:DOM桥接实现中文IME支持

1. 为什么Unity WebGL的输入法问题会让人抓狂——不是Bug&#xff0c;是架构级限制“Unity WebGL输入法终极解决方案”这个标题里&#xff0c;“终极”两个字不是营销话术&#xff0c;而是我踩过三轮大坑、重写四版输入逻辑、在Chrome/Firefox/Safari/Edge全平台实测27个版本后…

作者头像 李华
网站建设 2026/5/26 11:03:11

超高清显示技术如何重塑你的视觉体验?从护眼到沉浸感全面解析

处于数字信‍息‍爆炸的时代当中, 我⁠们于每日面​对屏幕的时间长达‌数小时这件事儿‌上, 一块屏幕的优劣情况, 直接对我们的工作效率产​生影响, 也影响着娱乐享受, 甚‍至关乎视觉健康, 近⁠些年来, 显‍示技术正在经历一场静默却深​刻的革命‍,‌ 其​核心目标已经从‌单…

作者头像 李华
网站建设 2026/5/26 11:03:09

优雅使用Enum提升SpringBoot配置管理效率

&#x1f449; 这是一个或许对你有用的社群&#x1f431; 一对一交流/面试小册/简历优化/求职解惑&#xff0c;欢迎加入「芋道快速开发平台」知识星球。下面是星球提供的部分资料&#xff1a; 《项目实战&#xff08;视频&#xff09;》&#xff1a;从书中学&#xff0c;往事上…

作者头像 李华
网站建设 2026/5/26 11:01:44

告别Arduino IDE:在VSCode中搭建ESP8266高效开发环境

1. 为什么选择VSCode开发ESP8266&#xff1f; 如果你还在用Arduino IDE开发ESP8266项目&#xff0c;可能会遇到这些烦恼&#xff1a;代码补全基本靠猜、跳转定义完全不存在、调试信息像在玩解谜游戏。我刚开始用Arduino IDE时&#xff0c;最崩溃的是每次要找函数定义都得手动翻…

作者头像 李华