這篇重新整理一下 CodeWF.AvaloniaControls 裡新增的 Guide 引導控制項。
新手引導這種控制項,只講屬性和實作確實不直觀。它本質上是一個強互動控制項:遮罩、高亮、卡片定位、選單展開、TabItem 切換、目標控制項延遲出現,這些效果都需要先看畫面,再看程式碼才容易理解。
先看 Vex 裡實際落地的新手引導流程。

這組引導涵蓋了 Vex 的標題列選單、檔案選單項、大綱入口、左側大綱頁籤、主題選單和說明選單。最關鍵的是,它不是只指向頁面上本來就存在的按鈕,而是會在步驟切換時主動開啟選單、切換側邊欄 TabItem,然後再高亮新出現的目標。
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 裡有 Popup、MenuItem、TabControl、Flyout 這些基礎控制項,但它們並不會直接組合成一個完整的新手引導流程。
一個真正能用在桌面軟體裡的引導控制項,需要處理這些問題:
- 多步驟流程:上一頁、下一頁、完成、關閉。
- 每一步繫結不同目標控制項。
- 沒有目標控制項時居中顯示說明。
- 有目標控制項時繪製遮罩,並在目標周圍挖出高亮區域。
- 引導卡片根據目標位置顯示在上、下、左、右等方向。
- 目標在滾動區域內時自動滾動到可見位置。
- 目標晚一點才出現時,等待並重新定位。
- 目標在
Menu、Popup、Flyout這種彈層裡時,也能正確高亮。 - 佈局變化、視窗大小變化後,重新計算高亮區域。
我參考的是 AtomUI 裡的 Tour 漫遊式引導控制項。AtomUI 的 Tour 把主控制項做成 TemplatedControl,步驟抽象為 ITourStepOption,再用彈層和遮罩層組合出引導效果。這個方向很適合 Avalonia。
CodeWF 的 Guide 沿用了這個思路,但在實作上更偏向我自己的專案需求:
- 使用
GuideOverlay自繪遮罩和高亮區域。 - 使用
Popup顯示引導卡片。 - 透過
GuideStep宣告每一步,也支援StepsSource資料來源。 - 透過
StepOpening和OpeningCommand支援動態業務動作。 - 透過
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 最重要的增強之一。
先看效果。

普通引導的目標控制項本來就在頁面上,直接高亮就行。選單項不一樣:子級 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);
原因是選單彈層建立和佈局不是完全同步完成的。Guide 的 TargetResolveDelay 會給選單彈層一點時間,再去解析目標 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;
這就是選單項引導的完整鏈路:
GuideStep.Target指向具體MenuItem。StepOpening開啟父選單。TargetResolveDelay等待彈層完成佈局。Guide解析目標、繪製遮罩、顯示卡片。- 步驟結束或引導關閉時收起選單。
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() 投遞到背景佇列,能避免高亮區域還停在舊佈局上。
首次啟動只顯示一次
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();
}
實作裡幾個容易忽略的點
1. 目標延遲出現
選單項、Popup 內容、TabItem 內容都可能不是馬上可見。Guide 裡有 TargetResolveDelay,並且會在目標暫時不可見時重試幾次。
2. 彈層目標
選單項通常掛在 PopupRoot 或 OverlayPopupHost 下,不一定和主視窗內容在同一棵視覺樹裡。Guide 會判斷目標是否來自彈層宿主,並用螢幕座標換算高亮區域。
3. 選單 light-dismiss
如果目標在選單彈層裡,使用者點擊引導卡片的「下一步」時,選單可能先收到 light-dismiss 導致普通 Button.Click 遺失。Guide 對上一步、下一步、完成按鈕額外處理了 PointerPressed,確保引導導航優先完成。
4. 佈局重新整理
目標控制項 LayoutUpdated、視窗 ClientSize 變化時,都要重新計算高亮區域。否則視窗調整大小、側欄展開收起以後,高亮框就會錯位。
5. 清理
關閉引導時要停止計時器、關閉所有 Popup、解綁目標和視窗事件,並把焦點盡量還給引導開啟前的控制項。這類控制項橫跨頁面和彈層,如果清理不完整,很容易留下殘餘遮罩。
小結
Guide 現在已經能涵蓋桌面應用裡常見的新手引導場景:
- 基礎多步驟引導。
- 居中歡迎和結束步驟。
- 封面內容、自訂操作按鈕。
- 預設圓點進度和文字進度。
- 遮罩、非強制、高亮間距和圓角。
- 選單展開後引導
MenuItem。 - 二級選單項引導。
- 切換 TabItem 後重新整理並繼續引導。
- 目標延遲出現後的等待和重試。
- 首次啟動自動顯示一次,說明選單再次開啟。
這次在 Vex 裡落地後,我更確定新手引導控制項不能只做「靜態按鈕高亮」。桌面應用的真實入口經常藏在選單、彈層和頁籤後面,Guide 要能跟業務狀態一起走,才算真正可用。
倉庫位址與感謝
感謝 AtomUI 專案提供 Tour 漫遊式引導控制項作為重要參考:
- AtomUI 控制項倉庫:https://github.com/AtomUI/AtomUI
- 本文控制項倉庫 CodeWF.AvaloniaControls:https://github.com/dotnet9/CodeWF.AvaloniaControls
- Vex 專案倉庫:https://github.com/dotnet9/Vex
本文到此結束。