CodeWF.AvaloniaControls 新增 Guide 引導控制項:從 AtomUI Tour 到 Vex 落地

CodeWF.AvaloniaControls 新增 Guide 引導控制項:從 AtomUI Tour 到 Vex 落地

這篇文章介紹 CodeWF.AvaloniaControls 新增的 Guide 引導控制項,說明它參考 AtomUI Tour 的開發思路,如何實現遮罩、高亮、彈層定位、動態選單 MenuItem 引導,以及在 Vex 項目中首次啟動、說明選單再次開啟、切換 TabItem 後顯示引導等實際落地。

最後更新 2026/5/23 下午3:45
dotnet9
預計閱讀 13 分鐘
分類
.NET Avalonia 桌面開發
專題
Avalonia
標籤
C# Avalonia CodeWF Guide Vex 桌面應用

這篇重新整理一下 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 裡有 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 最重要的增強之一。

先看效果。

普通引導的目標控制項本來就在頁面上,直接高亮就行。選單項不一樣:子級 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() 投遞到背景佇列,能避免高亮區域還停在舊佈局上。

首次啟動只顯示一次

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. 彈層目標

選單項通常掛在 PopupRootOverlayPopupHost 下,不一定和主視窗內容在同一棵視覺樹裡。Guide 會判斷目標是否來自彈層宿主,並用螢幕座標換算高亮區域。

3. 選單 light-dismiss

如果目標在選單彈層裡,使用者點擊引導卡片的「下一步」時,選單可能先收到 light-dismiss 導致普通 Button.Click 遺失。Guide 對上一步、下一步、完成按鈕額外處理了 PointerPressed,確保引導導航優先完成。

4. 佈局重新整理

目標控制項 LayoutUpdated、視窗 ClientSize 變化時,都要重新計算高亮區域。否則視窗調整大小、側欄展開收起以後,高亮框就會錯位。

5. 清理

關閉引導時要停止計時器、關閉所有 Popup、解綁目標和視窗事件,並把焦點盡量還給引導開啟前的控制項。這類控制項橫跨頁面和彈層,如果清理不完整,很容易留下殘餘遮罩。

小結

Guide 現在已經能涵蓋桌面應用裡常見的新手引導場景:

  • 基礎多步驟引導。
  • 居中歡迎和結束步驟。
  • 封面內容、自訂操作按鈕。
  • 預設圓點進度和文字進度。
  • 遮罩、非強制、高亮間距和圓角。
  • 選單展開後引導 MenuItem
  • 二級選單項引導。
  • 切換 TabItem 後重新整理並繼續引導。
  • 目標延遲出現後的等待和重試。
  • 首次啟動自動顯示一次,說明選單再次開啟。

這次在 Vex 裡落地後,我更確定新手引導控制項不能只做「靜態按鈕高亮」。桌面應用的真實入口經常藏在選單、彈層和頁籤後面,Guide 要能跟業務狀態一起走,才算真正可用。

倉庫位址與感謝

感謝 AtomUI 專案提供 Tour 漫遊式引導控制項作為重要參考:

本文到此結束。

繼續探索

延伸閱讀

更多文章