放浪軍師のアプリ開発局

Xamarin.Formsを使ってAndroid,iOS,UWP,WPFで動くアプリを開発したりしています。Unityも始めます。尚、このブログはわからないところを頑張って解決するブログであるため、正しい保証がありませんのでご注意ください。

Xamarin.Forms.UWP でキーボードショートカットを実装したい

さて始まりました放浪軍師のアプリ開発局。今回は Xamarin.Forms.UWP でキーボード押下したキーを拾うというのをやってみたいと思います。結構大変でしたぜはっはっはっ!

Xamarin.Forms.UWP でキーボードショートカットを実装したかった

今までの乱ちゃんProjectではマウスでボタンをクリックして抽選を実行していました。しかし実際に実況プレイなどで使用する場合には、ゲーム中にコントローラーとマウスをいちいち持ち替えなければならず、これが結構不便だったのでショートカットを実装したいということで調べてみました。

環境

Xamarin.Forms 4.7.0.1142
Prism.Unity.Forms 7.2.0.1422
ReactiveProperty 7.1.0

サンプル

github.com

アクセスキー

docs.microsoft.com
調べてみるとショートカットを簡単に実現できそうなアクセスキーという機能を見つけたので、さっそく実装してみました。やり方は簡単で、クラスの宣言追加と Button コントロールに対応するキーを VisualElement.AccessKey に指定するだけです。

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="RendererTest.Views.MainPage"
             xmlns:windows="clr-namespace:Xamarin.Forms.PlatformConfiguration.WindowsSpecific;assembly=Xamarin.Forms.Core"
             Title="RendererTest">

  <StackLayout HorizontalOptions="CenterAndExpand" VerticalOptions="CenterAndExpand">
    <Label Text="左Altキーを押すとアクセスキーが表示されます"/>
    <Button Text="PageRendererTestPage" CommandParameter="PageRendererTestPage" Command="{Binding Navigation}"
            windows:VisualElement.AccessKey="A"/>
  </StackLayout>

</ContentPage>

エラーが出るが強行可能という罠

いざコードを記述すると以下のようなエラーが表示されます。

重大度レベル コード 説明 プロジェクト ファイル 行 抑制状態
エラー XLS0415 アタッチ可能なプロパティ 'AccessKey' が、型 'VisualElement' に見つかりませんでした。

これに関しては非常に悩んで teratail で質問を投げたのですが…

teratail.com

f-miyu 2020/07/08 01:37
実際に試してみましたが、表記のエラーはでますが、Altキーで、[A]と表示されて、Aキーでクリックイベントも発生しました。もし本当にできないのであれば、何か他のエラーなどがでていないか確認をお願いします。

なんとそのまま動きました!ちくしょう!今度からエラーが出ても一回はビルドしてみようと思います。

毎回Altを押さなければならない

さて、非常に実装が簡単なアクセスキーですが、アクセスキーを実行したい場合は、左Altキーを押すことで表示されるヒントのキーを押す必要があります。つまり実行するには2回キーを押す必要があります。そして実行後ヒントは消えてしまうため、再実行時はまたAltキーを押す必要があります。これではめんどくさすぎてとても抽選には使えませんね。ただ、ショートカットキーがどのキーなのかを表示する為になら有用なので、乱ちゃんProjectではアクセスキーを実装したままにしてあります。

カスタムレンダラーでキーボードのキーを取得する

というわけで正攻法で行きます。Xamarin.Forms ではキーボード押下イベントを拾うような機能は存在していないため、カスタムレンダラーを用いて実装していきます。何気にちゃんとやるのは初めてだったり。 docs.microsoft.com

ContentPage を継承したクラスを作る

まず共通部分に Renderers というフォルダを作り、ContentPage を継承した ContentPageCS クラスを作成します。そして、今回は KeyDown 的なイベントを作成する為、EventArgs を継承した KeyDownEventArgs クラスを作成し、押下キーを格納するプロパティをひとつ用意。この KeyDownEventArgs クラスを用いて ContentPageCS クラスにイベントハンドラを追加します。

using System;
using Xamarin.Forms;

namespace RendererTest.Renderers {

    public class ContentPageCS : ContentPage {

        public delegate void KeyDownEventHandler(object sender, KeyDownEventArgs e);
        public event KeyDownEventHandler KeyDown;

        public void OnKeyDown(KeyDownEventArgs e) => KeyDown?.Invoke(this, e);

    }
    public class KeyDownEventArgs : EventArgs {
        public string Key { get; set; }
    }

}

UWP にて PageRenderer を作成する

次に UWP 側にも Renderers フォルダを作成し、PageRenderer を継承した ContentPageCSRenderer を生成。OnElementChanged を override して Dispatcher.AcceleratorKeyActivated イベントを実装するとキーボードの値を取得できるので、それを先ほど共通部に作成した KeyDownEventArgs クラスでインスタンスを生成したものを、同じく先ほど作成したContentPageCS クラスの OnKeyDown メソッドに投げ込めば、キーボードを押すと値を取得できるイベントが完成します。

using RendererTest.Renderers;
using RendererTest.UWP.Renderers;
using Xamarin.Forms.Platform.UWP;

[assembly: ExportRenderer(typeof(ContentPageCS), typeof(ContentPageCSRenderer))]
namespace RendererTest.UWP.Renderers {
    public class ContentPageCSRenderer : PageRenderer {

        ContentPageCS myPage = null;

        protected override void OnElementChanged(ElementChangedEventArgs<Xamarin.Forms.Page> e) {
            base.OnElementChanged(e);

            if (e.OldElement != null) {
                myPage = null;
                Unloaded -= ImageViewRenderer_Unloaded;
                Loaded -= ImageViewRenderer_Loaded;
            }

            if (e.NewElement != null) {
                myPage = (ContentPageCS)e.NewElement;
                Unloaded += ImageViewRenderer_Unloaded;
                Loaded += ImageViewRenderer_Loaded;
            }

        }

        private void ImageViewRenderer_Loaded(object sender, Windows.UI.Xaml.RoutedEventArgs e) {
            Dispatcher.AcceleratorKeyActivated += Dispatcher_AcceleratorKeyActivated;
        }

        private void ImageViewRenderer_Unloaded(object sender, Windows.UI.Xaml.RoutedEventArgs e) {
            Dispatcher.AcceleratorKeyActivated -= Dispatcher_AcceleratorKeyActivated;
        }

        private void Dispatcher_AcceleratorKeyActivated(Windows.UI.Core.CoreDispatcher sender, Windows.UI.Core.AcceleratorKeyEventArgs args) {
            if (args.EventType == Windows.UI.Core.CoreAcceleratorKeyEventType.KeyDown) {
                if (myPage != null) {
                    string resKey(string s) {
                        if (s == "186") return ":";
                        if (s == "187") return ";";
                        if (s == "188") return ",";
                        if (s == "189") return "-";
                        if (s == "190") return ".";
                        if (s == "191") return "/";
                        if (s == "192") return "@";
                        if (s == "219") return "[";
                        if (s == "221") return "]";
                        if (s == "222") return "^";
                        if (s == "226") return "\\";
                        return s;
                    }
                    var e = new KeyDownEventArgs {
                        Key = resKey(args.VirtualKey.ToString())
                    };
                    myPage.OnKeyDown(e);
                }
            }
        }
    }
}

イベントを ViewModel で受け取るには EventToCommandBehavior を使う

さて、イベントができたものの ViewModel で受け取るにはどうすればいいのでしょうか?色々調べた結果、 Prism を使用しているのであれば EventToCommandBehavior を使うのが簡単なようでした。 prismlibrary.com

EventToCommandBehavior でイベントを Command 化

まず、View にて以下のように EventName にイベント名を。EventArgsParameterPath にイベントパラメータのプロパティ名を指定。Command はいつものように Binding させます。

<?xml version="1.0" encoding="utf-8" ?>
<renderers:ContentPageCS  
  xmlns:renderers="clr-namespace:RendererTest.Renderers"
  xmlns="http://xamarin.com/schemas/2014/forms"
  xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
  xmlns:prism="http://prismlibrary.com"
  prism:ViewModelLocator.AutowireViewModel="True"
  x:Class="RendererTest.Views.PageRendererTestPage"
  Title="PageRendererTestPage">

  <renderers:ContentPageCS.Behaviors>
    <prism:EventToCommandBehavior EventName="KeyDown" Command="{Binding KeyDownCommand}" EventArgsParameterPath="Key"/>
  </renderers:ContentPageCS.Behaviors>

  <StackLayout HorizontalOptions="CenterAndExpand" VerticalOptions="CenterAndExpand">
    <Label Text="キーボードを叩いてください。"/>
    <Label Text="{Binding KeyHistory.Value}"/>
  </StackLayout>
  
</renderers:ContentPageCS>

ReactiveCommand<T>でイベントとイベントパラメータを受け取る

そして、ViewModel にて ReactiveCommand<T>を実装して、イベントの発生とパラメータを拾います。通常の Command と CommandParameter を拾うのとまったく同じですね。拾ったら後は Model のメソッドにパラメータを渡して加工し、別で用意した ReactiveProperty で同期させるとかそんな感じになるかと思います。今回のサンプルでは押下したキーの履歴が表示されます。

using Prism.Navigation;
using Reactive.Bindings;
using Reactive.Bindings.Extensions;
using RendererTest.Models;

namespace RendererTest.ViewModels {
    public class PageRendererTestPageViewModel : ViewModelBase {

        public ReactiveCommand<string> KeyDownCommand { get; }
        public ReadOnlyReactiveProperty<string> KeyHistory { get; }

        public PageRendererTestPageViewModel(INavigationService navigationService, CoreModel coreModel) : base(navigationService) {
            KeyHistory = coreModel.ObserveProperty(x => x.KeyHistory).ToReadOnlyReactiveProperty();
            KeyDownCommand = new ReactiveCommand<string>().WithSubscribe(x => coreModel.SetKeyHistory(x));
        }
    }
}
using Prism.Mvvm;

namespace RendererTest.Models {
    public class CoreModel : BindableBase {
        public string KeyHistory { get => keyHistory; set => SetProperty(ref keyHistory, value); }
        private string keyHistory;

        public void SetKeyHistory(string key) {
            KeyHistory += key;
        }
    }
}

まとめ

という感じでキーボードからの情報をイベント化し、VMで拾うことができました。やったぜ!今回学んだことを駆使すればかなりいろんな事ができるんじゃないかと思います。

あ、、、ショートカット作ってねーじゃねーか!!!

すいません結局ショートカット作ってないですね。…と言ってもここまで行けば簡単で、通常 Button を叩くことで実行する Model 層のメソッドを、ショートカットキーを受けたメソッドから呼び出してやればいいだけです。説明もいらない気がしますがまぁこんな感じ。

View

<?xml version="1.0" encoding="utf-8" ?>
<renderers:ContentPageCS  
  xmlns:renderers="clr-namespace:RendererTest.Renderers"
  xmlns="http://xamarin.com/schemas/2014/forms"
  xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
  xmlns:prism="http://prismlibrary.com"
  prism:ViewModelLocator.AutowireViewModel="True"
  x:Class="RendererTest.Views.PageRendererTestPage"
  Title="PageRendererTestPage">

  <renderers:ContentPageCS.Behaviors>
    <prism:EventToCommandBehavior EventName="KeyDown" Command="{Binding KeyDownCommand}" EventArgsParameterPath="Key"/>
  </renderers:ContentPageCS.Behaviors>

  <StackLayout HorizontalOptions="CenterAndExpand" VerticalOptions="CenterAndExpand">
    <Label Text="キーボードを叩いてください。"/>
    <Label Text="{Binding KeyHistory.Value}"/>
    <Label Text="{Binding MethodHistory.Value}"/>
    <StackLayout Orientation="Horizontal">
      <Button Text="method A" Command="{Binding CommandA}" BackgroundColor="#35856B"/>
      <Button Text="method B" Command="{Binding CommandB}" BackgroundColor="#35856B"/>
      <Button Text="method C" Command="{Binding CommandC}" BackgroundColor="#35856B"/>
    </StackLayout>
  </StackLayout>
  
</renderers:ContentPageCS>

ViewModel

using Prism.Navigation;
using Reactive.Bindings;
using Reactive.Bindings.Extensions;
using RendererTest.Models;
using System;

namespace RendererTest.ViewModels {
    public class PageRendererTestPageViewModel : ViewModelBase {

        public ReactiveCommand<string> KeyDownCommand { get; }
        public ReadOnlyReactiveProperty<string> KeyHistory { get; }
        public ReactiveCommand CommandA { get; } = new ReactiveCommand();
        public ReactiveCommand CommandB { get; } = new ReactiveCommand();
        public ReactiveCommand CommandC { get; } = new ReactiveCommand();
        public ReadOnlyReactiveProperty<string> MethodHistory { get;}

        public PageRendererTestPageViewModel(INavigationService navigationService, CoreModel coreModel) : base(navigationService) {
            KeyHistory = coreModel.ObserveProperty(x => x.KeyHistory).ToReadOnlyReactiveProperty();
            KeyDownCommand = new ReactiveCommand<string>().WithSubscribe(x => coreModel.SetKeyHistory(x));

            MethodHistory = coreModel.ObserveProperty(x => x.MethodHistory).ToReadOnlyReactiveProperty();
            CommandA.Subscribe(_ => coreModel.MethodA());
            CommandB.Subscribe(_ => coreModel.MethodB());
            CommandC.Subscribe(_ => coreModel.MethodC());
        }
    }
}

Model

using Prism.Mvvm;

namespace RendererTest.Models {
    public class CoreModel : BindableBase {
        public string KeyHistory { get => keyHistory; set => SetProperty(ref keyHistory, value); }
        private string keyHistory;

        public string MethodHistory { get => methodHistory; set => SetProperty(ref methodHistory, value); }
        private string methodHistory;


        public void SetKeyHistory(string key) {
            KeyHistory += key;
            if (key == "A") MethodA();
            if (key == "B") MethodB();
            if (key == "C") MethodC();
        }

        public void MethodA() => MethodHistory += "Method A を実行しました。\n";
        public void MethodB() => MethodHistory += "Method B を実行しました。\n";
        public void MethodC() => MethodHistory += "Method C を実行しました。\n";
    }
}

キャプチャ

ボタンを押下した時とABCを押したときにメソッドが実行されているのが分かるかと思います。 f:id:roamschemer:20200729094631g:plain