.NET MAUIでCtrl+Sを常時監視しページ単位で登録・解除する方法|WinUI 3のKeyboardAccelerator実装とMVVM連携まで徹底解説

.NET MAUI で「ページが表示されている間だけ Ctrl+S を常時リッスンし、ページを離れたら確実に解除する」――この要件は WinUI 3 の KeyboardAccelerator を使えばシンプルかつ安全に満たせます。本記事では最短コードから現場運用で効く堅牢パターン、MVVM 連携、クロスプラットフォームの方針、落とし穴までを実行可能なコード付きで体系的に解説します。

目次

.NET MAUI でページ固有の Ctrl+S を常時監視する最小解

まずは「動くこと」を最優先にした最小コードです。Windows 専用のサービスクラスを 1 ファイル追加し、ページのライフサイクル(OnAppearing/OnDisappearing)で登録・解除するだけで実現できます。

ファイル追加:Platforms/Windows/KeyboardShortcutService.Windows.cs

// Platforms/Windows/KeyboardShortcutService.Windows.cs
#if WINDOWS
using System;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Platform;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Input;
using Windows.System;

public static class KeyboardShortcutService
{
    public static void RegisterShortcut(Page page, Action callback)
    {
        if (page is null) throw new ArgumentNullException(nameof(page));
        if (callback is null) throw new ArgumentNullException(nameof(callback));

        // ページがロード済みなら即時、未ロードなら Loaded 後にネイティブ要素へアタッチ
        if (page.Handler?.PlatformView is FrameworkElement fe)
        {
            Attach(fe, callback);
        }
        else
        {
            // VisualElement.Loaded は 1 回だけ発火する(再アタッチ防止のため 1 回で解除)
            void LoadedOnce(object? s, EventArgs e)
            {
                page.Loaded -= LoadedOnce;
                if (page.Handler?.PlatformView is FrameworkElement fe2)
                {
                    Attach(fe2, callback);
                }
            }
            page.Loaded += LoadedOnce;
        }

        static void Attach(FrameworkElement fe, Action callback)
        {
            var accel = new KeyboardAccelerator
            {
                Modifiers = VirtualKeyModifiers.Control,
                Key = VirtualKey.S
            };

            accel.Invoked += (_, e) =>
            {
                callback();
                e.Handled = true; // 他ハンドラへ伝播させない
            };

            fe.KeyboardAccelerators.Add(accel);

            // この最小実装では管理を簡略化し、解除時は Clear() で全削除します。
            // 共有の加速キーが無い画面専用 UI を前提にした簡易アプローチです。
        }
    }

    public static void UnregisterShortcut(Page page)
    {
        if (page?.Handler?.PlatformView is FrameworkElement fe)
        {
            // シンプル重視のため一括クリア
            fe.KeyboardAccelerators.Clear();
        }
    }
}
#endif

ページ側での利用(MainPage 例)

public partial class MainPage : ContentPage
{
    protected override void OnAppearing()
    {
        base.OnAppearing();
#if WINDOWS
        KeyboardShortcutService.RegisterShortcut(this, () =>
        {
            SaveCommand(); // Ctrl+S が押された瞬間に呼びたい処理
        });
#endif
    }

    protected override void OnDisappearing()
    {
        base.OnDisappearing();
#if WINDOWS
        KeyboardShortcutService.UnregisterShortcut(this);
#endif
    }

    void SaveCommand()
    {
        // 実際の保存処理
        // 例: await ViewModel.SaveAsync();
    }
}

プロジェクト設定(抜粋)

WinUI 3 を有効にし、Windows をターゲットにします。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>net8.0-android;net8.0-ios;net8.0-maccatalyst;net8.0-windows10.0.19041.0</TargetFrameworks>
    <UseMaui>true</UseMaui>
    <SingleProject>true</SingleProject>
    <UseWinUI>true</UseWinUI>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

ポイント:本記事のコードは Windows(WinUI 3)専用です。他 OS は末尾の「他プラットフォームの対応」を参照してください。
ビルド推奨:.NET 8 + 最新の .NET MAUI。

なぜ KeyboardAccelerator なのか

  • ページ配下で常時有効:FrameworkElement.KeyboardAccelerators に登録すると、その要素のフォーカス スコープ配下で Ctrl+S を一貫して拾えます。
  • イベントのバブリング制御:Invoked 内で e.Handled = true にすれば、他ハンドラやシステムへの伝播を遮断できます。
  • テキスト入力中でも反応:通常は Entry/Editor にフォーカスがあっても、ページ配下なら発火します(※後述の落とし穴参照)。
  • コードビハインドで完結:XAML を汚さず差し込みやすく、A/B テストや段階導入にも向きます。

運用品質を高める「安全な登録解除」実装

上記の最小実装は簡潔な反面、同一ページで他の加速キーを使う場合に Clear() が“全部まとめて”消してしまう可能性があります。大規模画面や複数チームでの開発では「自分が追加したものだけ外す」が安全です。以下はそれを実現する堅牢版です。

// Platforms/Windows/KeyboardShortcutService.Windows.cs(堅牢版)
#if WINDOWS
using System;
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Platform;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Input;
using Windows.System;

public static class KeyboardShortcutService
{
    // ページごとに自分が追加した KeyboardAccelerator を保持
    private static readonly ConditionalWeakTable<Page, Holder> _map = new();

    private sealed class Holder
    {
        public FrameworkElement? Element;
        public KeyboardAccelerator? Accelerator;
        public EventHandler? LoadedHandler;
    }

    public static void RegisterShortcut(Page page, Action callback)
    {
        if (page is null) throw new ArgumentNullException(nameof(page));
        if (callback is null) throw new ArgumentNullException(nameof(callback));

        // 二重登録ガード:既存があれば一旦解除
        UnregisterShortcut(page);

        var holder = new Holder();
        _map.Add(page, holder);

        if (page.Handler?.PlatformView is FrameworkElement fe)
        {
            Attach(fe, callback, holder);
        }
        else
        {
            void LoadedOnce(object? s, EventArgs e)
            {
                page.Loaded -= LoadedOnce;
                if (page.Handler?.PlatformView is FrameworkElement fe2)
                {
                    Attach(fe2, callback, holder);
                }
            }
            holder.LoadedHandler = LoadedOnce;
            page.Loaded += LoadedOnce;
        }
    }

    private static void Attach(FrameworkElement fe, Action callback, Holder holder)
    {
        var accel = new KeyboardAccelerator
        {
            Modifiers = VirtualKeyModifiers.Control,
            Key = VirtualKey.S
        };
        TypedEventHandler<KeyboardAccelerator, KeyboardAcceleratorInvokedEventArgs> invoked = (sender, e) =>
        {
            callback();
            e.Handled = true;
        };
        accel.Invoked += invoked;

        fe.KeyboardAccelerators.Add(accel);

        holder.Element = fe;
        holder.Accelerator = accel;
    }

    public static void UnregisterShortcut(Page page)
    {
        if (!_map.TryGetValue(page, out var holder))
            return;

        // Loaded ハンドラを掃除
        if (holder.LoadedHandler is not null)
        {
            page.Loaded -= holder.LoadedHandler;
            holder.LoadedHandler = null;
        }

        // 自分が足したものだけ外す
        if (holder.Element is FrameworkElement fe && holder.Accelerator is not null)
        {
            fe.KeyboardAccelerators.Remove(holder.Accelerator);
            holder.Accelerator = null;
            holder.Element = null;
        }

        // マップから除去
        _map.Remove(page);
    }
}
#endif

この版では、ConditionalWeakTable を使ってページごとの登録情報を自動的に寿命管理しています。UnregisterShortcut を呼び忘れても GC 寄りの観点でのリークは起きにくく、再入・多重登録・画面遷移といった現場で起きがちなシナリオにも強くなります。

MVVM 連携(ICommand を叩く)

コールバックの中で ViewModel の ICommand を起動すれば、UI ロジックを汚さずにテストや再利用がしやすくなります。

// ViewModel 側
public class MainViewModel
{
    public ICommand SaveCommand { get; }
    public MainViewModel()
    {
        SaveCommand = new Command(async () => await SaveAsync());
    }
    private Task SaveAsync()
    {
        // 保存ロジック
        return Task.CompletedTask;
    }
}

// ページ側
public partial class MainPage : ContentPage
{
    public MainViewModel ViewModel => (MainViewModel)BindingContext;

    protected override void OnAppearing()
    {
        base.OnAppearing();
#if WINDOWS
        KeyboardShortcutService.RegisterShortcut(this, () => ViewModel.SaveCommand.Execute(null));
#endif
    }

    protected override void OnDisappearing()
    {
        base.OnDisappearing();
#if WINDOWS
        KeyboardShortcutService.UnregisterShortcut(this);
#endif
    }
}

複数ショートカットに拡張する(Ctrl+Shift+S など)

設計を少し一般化すれば、ページごとに複数のショートカットを宣言できます。

// 例:RegisterShortcut のオーバーロードで配列を受ける
public record ShortcutSpec(VirtualKeyModifiers Mod, VirtualKey Key, Action Callback);

// 呼び出し例(ページ側)
#if WINDOWS
KeyboardShortcutService.RegisterShortcuts(this, new[]
{
    new ShortcutSpec(VirtualKeyModifiers.Control, VirtualKey.S, () => ViewModel.SaveCommand.Execute(null)),
    new ShortcutSpec(VirtualKeyModifiers.Control | VirtualKeyModifiers.Shift, VirtualKey.S, () => ViewModel.SaveAsCommand.Execute(null)),
});
#endif

この拡張は、ツール系アプリでの「Ctrl+S」「Ctrl+Shift+S」「Ctrl+P」「Ctrl+F」などの一括定義に適しています。Invoked で Handled を true にしておけば、重複処理の多重発火も防げます。

実装の意図と動作モデル

観点採用理由 / 実装ポイント
適用範囲ページのネイティブ要素(FrameworkElement)に登録するため、配下の全コントロールで共通して有効。ページを離れれば解除。
ライフサイクルOnAppearing で登録、OnDisappearing で解除。未ロード対策として Loaded 後にネイティブ要素へアタッチ。
多重登録防止堅牢版では最初に Unregister を呼ぶ/保持オブジェクトで自身の登録のみ除去。
イベント伝播e.Handled = true で他ハンドラへのバブリングを遮断。ショートカット競合を局所化。
保守性サービスクラスに閉じ込めることで、XAML を汚さず横断対応。変更が生じても差し替えが容易。

テスト手順(再現性の高い確認方法)

  1. デバッグビルドで Windows ターゲットを起動する。
  2. MainPage が表示されている状態で Ctrl+S を押す。
  3. 保存処理(ステータス表示/トースト/デバッグ出力)が即時に発火することを確認する。
  4. 別ページに遷移し、同じキーを押す。今度は反応しないことを確認する(=解除できている)。
  5. 再び MainPage に戻る。再登録が行われ、再び反応することを確認する。

落とし穴と回避策

症状原因対処
ショートカットが効かないことがあるページが未ロードのままネイティブ要素へアタッチしようとしているpage.Loaded 後にアタッチする(本記事コードは対応済み)
複数回押すと複数回登録されるOnAppearing が再入し、同じ登録を積み重ねている登録前に UnregisterShortcut を呼ぶ/堅牢版の二重登録ガードを採用する
別チームの加速キーまで消えてしまうKeyboardAccelerators.Clear() を使って一括解除している堅牢版のように「自分が追加した項目だけ Remove」する
テキスト入力に干渉してしまうコントロール固有のショートカットと競合必要に応じて「保存は Ctrl+Shift+S」などに変更。Handled で伝播を遮断
ウィンドウ切替で効かないフォーカスがページ配下にないウィンドウをアクティブにする(アプリ外では加速キーは届かない)

UI/UX 観点のベストプラクティス

  • 可視化:ショートカットがある機能はメニューや設定画面に「Ctrl+S」などの表記を併記し、学習コストを下げる。
  • 冪等性:保存処理は複数回連打されても状態が壊れないように。保存中はボタンやショートカットを一時的に無効化するのも有効。
  • フィードバック:「保存しました」トーストやステータスバー更新で操作の完了を即時に示す。
  • アクセシビリティ:ショートカットのみで機能が独占されないよう、メニューやボタン操作でも同じコマンドを到達可能にする。

応用:一時的に無効化/有効化を切り替える

長時間の重い保存や編集中に誤爆を防ぎたい場合は、フラグで無効化しておき、必要なタイミングで一時的に外すと安全です。

bool _shortcutEnabled = true;

#if WINDOWS
KeyboardShortcutService.RegisterShortcut(this, () =>
{
    if (!_shortcutEnabled) return;
    _shortcutEnabled = false;
    try
    {
        SaveCommand();
    }
    finally
    {
        _shortcutEnabled = true;
    }
});
#endif

クロスプラットフォームの考え方

Ctrl+S のようなキーボード ショートカットは、プラットフォームごとにネイティブ API が異なります。Windows 以外の方向性は次の通りです(代表例)。

  • Mac Catalyst:UIKeyCommand を用い、Command キー(⌘)との組み合わせで登録。UIApplication.SharedApplication に追加、または UIViewController の AddKeyCommand を利用。
    (例:⌘S で保存、⌘⇧S で別名保存)
  • iPad(外付けキーボード):同じく UIKeyCommand。表示は UIMenuBuilder 等で案内可能。
  • Android:物理キーボードがある端末に限り、OnKeyDown/OnKeyUp と KeyEvent の組み合わせで対応。Ctrl 修飾の扱いは端末/IME 依存が強め。

「各 OS で最適なキーと動作を用意しつつ、アプリ内のコマンドは共通化(ViewModel の ICommand に集約)」というのが、現実解としてバランスが良い方針です。

性能・スレッド・例外の扱い

  • 非同期:保存処理が非同期なら async/await で実行し、例外は UI スレッドに戻してハンドリング。
  • UI スレッド保証:Invoked は UI スレッドで呼ばれるため、通常は追加のディスパッチ不要。
  • 例外処理:コールバック内で例外が発生したら握りつぶさず、ユーザー通知やリトライの導線を準備する。

ログと運用監視

ショートカットの利用状況を計測すると、UI 改善に繋がります。

KeyboardShortcutService.RegisterShortcut(this, () =>
{
    try
    {
        // Telemetry.TrackEvent("Shortcut", new { Key = "Ctrl+S", Page = "MainPage" });
        SaveCommand();
    }
    catch (Exception ex)
    {
        // Telemetry.TrackException(ex);
        throw;
    }
});

セキュリティ・コンプライアンス上の注意

  • 権限不要:本手法は OS 権限を追加で要求しません。
  • データ整合性:ショートカット誤作動で破壊的操作が走らないよう、保存前のバリデーションや差分保存を徹底。

QA とエッジケース

Q. 子コントロール側で KeyboardAccelerator を持っている場合は?

近い要素に登録された加速キーが優先されることがあります。重要なショートカットはページ直配下に集約し、競合の芽を減らすのが実践的です。

Q. グローバル(アプリ全体)で常時監視したい場合は?

ウィンドウ直下(Window.Content のネイティブ要素)に登録する手もあります。ただし本記事の要件は「ページ固有」なので、範囲を広げすぎないことが重要です。

Q. 同じページで Ctrl+S を別のモードに切り替えたい

堅牢版のように「自分が追加した加速キーだけ抜き差し」できれば、モード変更のたびに Remove / Add で安全に切り替えられます。

導入から公開までの迅速レシピ

  1. Platforms/Windows にサービスクラスを追加(最小 or 堅牢版)。
  2. 対象ページの OnAppearing/OnDisappearing に 2 行追加。
  3. 保存処理を ICommand 化してテスト。
  4. 衝突がないか UI 全域で確認(テキスト入力やモーダル表示時など)。
  5. トースト等の通知を添えて UX を磨く。

まとめ

WinUI 3 の KeyboardAccelerator をページのネイティブ要素にアタッチすれば、.NET MAUI でも「ページが表示されている間だけ Ctrl+S を常時リッスン」できます。OnAppearing で登録、OnDisappearing で解除。未ロード時は Loaded 後にアタッチ。これだけで、ゴースト入力やメモリリークを避けつつ、UX を底上げできます。チーム開発では「自分の登録だけ外す」堅牢版を採用し、MVVM の ICommand へ委譲して保守性を高めるのがおすすめです。


付録:比較表(最小実装 vs. 堅牢実装)

項目最小実装堅牢実装
解除方法Clear() で一括自分が追加したものだけ Remove()
二重登録対策ページ側の呼び出し順番に依存サービス側でガード(Unregister 先行)
混在環境の安全性△(他の加速キーも消す恐れ)◎(相互干渉を最小化)
コード量短いやや多い

付録:他プラットフォームの最短コード(参考イメージ)

Mac Catalyst(⌘S)

#if MACCATALYST
using UIKit;

public static class MacShortcutService
{
public static void RegisterSave(UIResponder responder, Action callback)
{
var cmd = UIKeyCommand.Create(new NSString("s"), UIKeyModifierFlags.Command, new ObjCRuntime.Selector("OnSave:"));
responder.AddKeyCommand(cmd);

```
    // responder に "OnSave:" を実装し、callback() を呼ぶ
}
```

}
#endif 

Android(物理キーボード端末のみ)

#if ANDROID
// Activity の OnKeyDown をオーバーライドして Ctrl + S を判定
// 端末や IME による差異が大きいため本番利用前に実機検証を
#endif

最終チェックリスト

  • Windows で UseWinUI を有効化している。
  • OnAppearing / OnDisappearing の登録・解除が入っている。
  • 必要に応じて「自分の分だけ解除」する堅牢版を採用している。
  • 保存処理は冪等で、UI へのフィードバックがある。
  • 他ページやダイアログ表示時に誤爆しない。

導入の効果

項目効果
操作速度マウス移動なしで即保存、体感応答性が大幅に向上
習熟コスト一般的なデスクトップ慣習に沿うため学習不要
実装コスト1 ファイル+数行で追加、XAML 変更なし
保守性サービス化により横断制御・改修が容易

この方法により、ページが表示されている間は Ctrl+S を常時リッスンし、ページ離脱時には確実に解除できます。意図しないイベント発火を防ぎながら、デスクトップ品質の快適なショートカット UX を .NET MAUI でも実現しましょう。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次