CodeWF.AvaloniaControls に Guide ガイドコントロールを追加:AtomUI Tour から Vex への実装

CodeWF.AvaloniaControls に Guide ガイドコントロールを追加:AtomUI Tour から Vex への実装

この記事では、CodeWF.AvaloniaControls に追加された Guide ガイドコントロールについて説明します。AtomUI Tour の開発アプローチを参考に、マスク、ハイライト、ポップアップの位置決め、動的メニュー MenuItem のガイド、および Vex プロジェクトでの初回起動、ヘルプメニューからの再開、TabItem 切り替え後のガイド表示などの実際の実装について解説します。

最終更新 2026/05/23 15:45
dotnet9
読了目安 9 分
カテゴリ
.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 のようなポップアップレイヤー内にある場合でも、正しくハイライトする。
  • レイアウトの変更やウィンドウサイズの変更後に、ハイライト領域を再計算する。

参考にしたのは AtomUITour(ツアー型ガイドコントロール)です。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

テンプレートには3つの重要なポップアップがあります。

  • 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 ジオメトリルールを使用して穴をくり抜くことです。

最初にウィンドウ全体を覆う矩形を描画し、次にターゲットコントロールの領域を2つ目の矩形として同じ 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 のファイルメニュー、段落メニュー、書式メニュー、表示メニュー、テーマメニュー、ヘルプメニューなどがこれに該当します。特定のメニュー項目をガイドする前に、まず対応するメニューを開き、メニュー項目のレイアウトが完了するのを待つ必要があります。

デモでの考え方は以下の通りです。

<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 で、「ファイル」と「アウトライン」の2つのタブがあります。初心者向けガイドには、非常に典型的な動的フローがあります。まず「表示」メニューで「アウトライン」エントリをハイライトし、次に自動的に左側の「アウトラインタブ」に切り替え、アウトラインナビゲーション領域について説明します。

効果は以下の通りです。

メインウィンドウでは、サイドバーのターゲットは同じ 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(ツアー型ガイドコントロール)を重要な参考資料として提供してくださったことに感謝します。

以上で本記事を終わります。

さらに探索

関連読書

その他の記事
同じカテゴリ / 同じテーマ 2026/05/18

枝见 Zhijian:Avalonia を使用した Markdown マインドマップエディター

この記事では、Avalonia ベースのローカルマインドマップエディター「枝见 Zhijian」を紹介します。空の新規作成、フォルダ読み込み、正確な初心者ガイド、macOS ショートカットキー対応、アウトライン/Markdown/マインドマップの同期、ノードメモ、ミニマップ、ズーム、キャンバスドラッグ、Markdown/OPML/XMind ファイルの交換をサポートしています。

続きを読む
同じカテゴリ / 同じテーマ 2026/05/16

CodeWF.Markdown:Avalonia 12 ベースの Markdown レンダリングコントロール

この記事では、CodeWF.Markdown のリポジトリアドレス、NuGet インストール方法、フルパッケージライン、Lite パッケージライン、リアルタイム編集プレビュー、タイポグラフィテーマ、コードハイライト、画像プレビュー、数式、複数ビューワーオーバーレイ、インクリメンタルレンダリング機能について紹介します。

続きを読む
同じカテゴリ / 同じテーマ 2026/05/16

CodeWF Toolbox:Avalonia + Prism で作られた開発者ツールボックス

この記事では、CodeWF Toolbox の既存機能について重点的に紹介します。変換、開発、セキュリティ、Web、メディア、ネットワーク、テキスト、データ、ログリーダー、国際化リソース管理、および Avalonia + Prism のモジュール化された構成方法を含みます。

続きを読む
同じカテゴリ / 同じタグ 2026/01/11

AvaloniaのクリップボードとDataGridの問題

最近のAvaloniaデスクトップソフトウェア開発で解決した2つの問題を記録:クリップボードコピーのクラッシュ、タブ切り替え時のDataGridの遅延。根本原因を分析し、解決策を提供する

続きを読む