.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 を汚さず横断対応。変更が生じても差し替えが容易。 |
テスト手順(再現性の高い確認方法)
- デバッグビルドで Windows ターゲットを起動する。
MainPageが表示されている状態で Ctrl+S を押す。- 保存処理(ステータス表示/トースト/デバッグ出力)が即時に発火することを確認する。
- 別ページに遷移し、同じキーを押す。今度は反応しないことを確認する(=解除できている)。
- 再び
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 で安全に切り替えられます。
導入から公開までの迅速レシピ
Platforms/Windowsにサービスクラスを追加(最小 or 堅牢版)。- 対象ページの
OnAppearing/OnDisappearingに 2 行追加。 - 保存処理を
ICommand化してテスト。 - 衝突がないか UI 全域で確認(テキスト入力やモーダル表示時など)。
- トースト等の通知を添えて 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 でも実現しましょう。

コメント