#概要
利便性を考えて、アプリで特定のMenuItemにキーボードショートカット(ex. Ctrl+Oで「開く」)を導入することがよくあります。
しかし、WPF標準のMenuItemではKeyGestureの説明表示は出来ても、その検出は出来ません。
そのためMenuItemとは別に、Window直下に検出するKeyBindingを書かなければいけません。
これは同じような記述を離れた場所に書くことになり、バグの原因になります。
そこで、KeyBindingを受け取ることのできる拡張MenuItemを作ることで、この問題を解決します。
#変更前
##実行画面
こんなアプリを題材にします。
Menu->Hogeを選択するか、Ctrl+Hを押すと、TextBoxに"-Hoge"が追記されます。
##View
ViewではGridで区切って上にMenu、下にTextBoxが置いてあるだけです。
<Window
x:Class="MenuExt.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:MenuExt"
Width="525"
Height="150">
<Window.DataContext>
<local:MainWindowViewModel />
</Window.DataContext>
<Window.InputBindings>
<KeyBinding Command="{Binding HogeCommand}" Gesture="Ctrl+H" />
</Window.InputBindings>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Menu Grid.Row="0">
<MenuItem Header="File(_F)">
<MenuItem
Header="Hoge"
Command="{Binding HogeCommand}"
InputGestureText="Ctrl+H" />
</MenuItem>
</Menu>
<TextBox Grid.Row="1" Text="{Binding MyText.Value}" />
</Grid>
</Window>
コードビハインドには何も書いていないので省略します。
##ViewModel
ViewModelは今回の本題にはあまり関係ありませんが、以下のとおりです。
HogeCommand
が使用されたらMyText
に"-Hoge"と追記します。
ReactivePropertyを使用していますが、他のMVVMライブラリでも話は同じだと思います。
using Reactive.Bindings;
using System;
namespace MenuExt
{
public class MainWindowViewModel
{
public ReactiveCommand HogeCommand { get; } = new ReactiveCommand();
public ReactiveProperty<string> MyText { get; } = new ReactiveProperty<string>();
public MainWindowViewModel()
{
HogeCommand.Subscribe(_ =>
Text.Value += "-Hoge");
}
}
}
#問題点
ViewにWindow直下のKeyBindingとMenuItemがあります。
<KeyBinding Command="{Binding HogeCommand}" Gesture="Ctrl+H" />
<MenuItem
Header="Hoge"
Command="{Binding HogeCommand}"
InputGestureText="Ctrl+H" />
両者はともにHogeCommand
とKeyGesture(*)を含んでいます。
今は短いから問題には感じませんが、これが膨大なMenuを持つアプリ(ex. VisualStudio)になったら、両者が食い違っていても、コードから発見するのは困難でしょう。
(*) MenuItemのInputGestureTextは単に指定したテキストをMenuの右側に表示するだけで、機能は持ちません。
InputGestureTextに書いたのに応答しない!という罠はこれを利用した全員がはまる通過儀礼のようなものです。
名前をGestureCaptionとかにしとけば、もう少し混乱が少なくて済んだのになぁ。
#解決策
この問題の解決で最初に思いつく方法はMenuItem自体にKeyBinding機能を持たせることです。
しかしKeyBindingはその時Focusがあるコントロールでしか検出できません。
つまりMenuItemに実装しても選択されている時以外は動作しない無意味なKeyBindingになってしまいます。
そこでKeyBinding自体は通常通りWindow直下で実装して、それをx:Nameで拡張MenuItemに渡してしまい、そこからCommandとKeyGestureを同期します。
##拡張MenuItemクラス
まずKeyBindingを受け取るMenuItemを継承した拡張MenuItemです。
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace MenuExt
{
/// <summary>
/// キーバインド付きMenuItem
/// </summary>
public class MenuItemKeyBinded : MenuItem
{
/// <summary>
/// KeyBinding依存関係プロパティ
/// </summary>
public KeyBinding KeyBind
{
get { return (KeyBinding)GetValue(KeyBindProperty); }
set { SetValue(KeyBindProperty, value); }
}
public static readonly DependencyProperty KeyBindProperty =
DependencyProperty.Register(nameof(KeyBind), typeof(KeyBinding), typeof(MenuItemKeyBinded),
new PropertyMetadata(new KeyBinding(),
//KeyBindingが指定された時に呼ばれるコールバック
KeyBindPropertyChanged));
private static void KeyBindPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var menuItemKB = d as MenuItemKeyBinded;
var kb = e.NewValue as KeyBinding;
//KeyBindingに結び付けられたコマンドをこのMenuItemのCommandに反映
menuItemKB.Command = kb.Command;
//KeyBindingのローカライズされた文字列("Ctrl"など)をこのMenuItemのInputGestureTextに反映
menuItemKB.InputGestureText = (kb.Gesture as KeyGesture).GetDisplayStringForCulture(CultureInfo.CurrentCulture);
}
}
}
依存関係プロパティとしてKeyBindingを受け取り、中身を継承元MenuItemのCommandとInputGestureTextに渡しています。
InputGestureTextにはKeyGestureを現在のCultureでローカライズした文字列(ex. "Ctrl")を設定します。
なおKeyGestureのModifierKeysやDisplayStringを使用しても"Control"や空白になっており、うまく動きません。
##View
使用する際はWindowのKeyBinding側にCommandとGestureを書いて、x:Name経由で拡張MenuItemにKeyBindingごと渡します。
<Window
x:Class="MenuExt.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:MenuExt"
Width="525"
Height="150">
<Window.DataContext>
<local:MainWindowViewModel />
</Window.DataContext>
<Window.InputBindings>
<KeyBinding
x:Name="HogeKeyBinding"
Command="{Binding HogeCommand}"
Gesture="Ctrl+H" />
</Window.InputBindings>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Menu Grid.Row="0">
<MenuItem Header="File(_F)">
<local:MenuItemKeyBinded
Header="Hoge"
KeyBind="{Binding ElementName=HogeKeyBinding}" />
</MenuItem>
</Menu>
<TextBox Grid.Row="1" Text="{Binding MyText.Value}" />
</Grid>
</Window>
ViewModelと実行結果は同じです。
#環境
VisualStudio2017
.NET Framework 4.6
C#6
#別解
今回はKeyBindingにx:Nameをつけ、KeyBinding経由でCommandとKeyGestureを共有しました。
別の方法として、Command側にKeyGestureを埋め込む方法もあります。
How to Display Working Keyboard Shortcut for Menu Items?-stackoverflow
中段あたりのCommandWithHotkeyの回答