放浪軍師のアプリ開発局

Xamarin.Formsを使ってAndroid,iOS,UWP,WPFで動くアプリを開発したりしています。

Xamarin.Forms で Prism と ReactiveProperty で MVVM な自作コントロールを作りたい

こんにちは!趣味で Xamarin.Forms をやっている放浪軍師と申します。
今回は Xamarin Advent Calendar 企画に参加させていただきました。実は去年もお誘いを受けていたのですが、まだ自信がなかったのでイモ引いたこの企画、今回は頑張りますよ!

Xamarin Advent Calendar 2019 15日目
https://qiita.com/advent-calendar/2019/xamarin

Xamarin.Forms で Prism と ReactiveProperty で MVVM な自作コントロールを作りたい

アプリを作るときに、汎用的なコントロールを作るなんて事は日常茶飯事だと思いますが、それを Xamarin.Forms で実現させることを目標とします。もちろん最近のナウでヤングな技術者にバカうけである MVVM パターンを採用したいので Prism と ReactiveProperty を使います。

MVVMパターン(View - Model - ViewModel)

まず、MVVM ですが、これは見た目(View)とロジック(Model)を分離させる実装開発パターンの事で、その中継的役割を担う ViewModel を合わせて、View - Model - ViewModel 略して MVVM と呼ばれます。これを実現することでロジック(Model)側が見た目(View)を気にする必要がなくなります。

Prism

前述の MVVM を実現する手助けをしてくれるフレームワークです。 Xamarin.Forms に限らず WPF や UWP でも使用できます。これを使用すると View - ViewModel 間を繋いだりするのがかなり楽になります。使いましょう。

ReactiveProperty

MVVM パターンで開発すると、View - ViewModel 間や ViewModel - Model 間の繋がりをあまり意識せずに開発できるようになります。ただ、それを素の状態で実現しようとすると色々とめんどくさかったりします。そんなめんどくささを解決してくれるのが ReactiveProperty です。そのうえ ReactiveExtensions という、値の変化を感じたらそれをメソッドチェーンを使って何やかんやできる LINQ の亜種みたいな便利機能も付いてきます。使いましょう。

ContentView

Xamarin.Forms で自作コントロールを作る際は ContenView を使います。作成方法はいたってシンプルで、通常のページを作成するときに使用する ContentPage とほぼ同じ手順で作成できます。

開発開始

さて、必要な技術が出そろったところでさっそく開発をしてみます。なお、Prism のテンプレートを使用しますので、先に拡張機能から Prism Template Pack をインストールしておいてください。

f:id:roamschemer:20191214161702j:plain

サンプルコード

今回のサンプルコードはGitHubにまとめてあります。よろしければ合わせてごらんください。

github.com

開発環境

Visual Studio 2019 Version 16.4.0
Xamarin.Forms Version 4.4.0.991265
Prism.Dryloc.Forms Version 7.2.0.1422
ReactiveProperty 6.1.4

プロジェクトの作成

通常の Xamarin.Forms ではなく、Prism テンプレートからプロジェクトを作成します。 Prism Blank App (Xamarin.Forms) を選択して作成してください。今回のサンプルでは、プロジェクト名を ContentViewMvvm としました。 f:id:roamschemer:20191214161511j:plain

Nuget から ReactiveProperty のインストールと更新

次に ReactiveProperty のインストールを行います。これは、全プロジェクトを対象に行ってください。また、このタイミングで全パッケージの更新も行っておくと良いでしょう。

f:id:roamschemer:20191214161721j:plain

ContentPage の追加

「えっ!?ContentViewじゃないの!?」と思われるかもしれませんが、間違いではありません。 ContentPage を追加します。…というのも、Prism テンプレートには ContentView が存在しないからです。後で ContentView に継承クラスを変更して使用します。

Views フォルダを右クリックして追加

既に存在している Views フォルダを右クリックして追加して、Prism から ContentView を作成してください。今回は MyControl.xamlという名称で作成しました。なお、ここで間違って ViewModels フォルダ右クリックから作成してしまうと、おかしなことになるので注意してください。

f:id:roamschemer:20191214161736j:plain

f:id:roamschemer:20191214161811j:plain

ContentView に変更する

早速 ContentPage から ContentView に変更します。変更するのは Viws 内に存在するMyControl.xaml とその配下にある MyControl.xaml.cs の2箇所です。

MyControl.xaml

最初と最後の ContentPage を ContentView に変更します。後でわかりやすいように、適当なラベルとボタンも張り付けておきましょう。

<?xml version="1.0" encoding="utf-8" ?>
<ContentView 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="ContentViewMvvm.Views.MyControl">

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*" />
            <RowDefinition Height="2*" />
        </Grid.RowDefinitions>
        <Label Text="自作コントローーーーールのラベル!!!" Grid.Row="0" HorizontalOptions="CenterAndExpand" VerticalOptions="CenterAndExpand"/>
        <Button Text="自作コントローーーーールのボタン!!!" Grid.Row="1"/>
    </Grid>

</ContentView>

MyControl.xaml.cs

継承元の ContentPage を ContentView に変更します。

using Xamarin.Forms;

namespace ContentViewMvvm.Views
{
    public partial class MyControl : ContentView
    {
        public MyControl()
        {
            InitializeComponent();
        }
    }
}

App.xaml.cs の RegisterTypes メソッドを修正する

ContentPage から ContentView に変わったことにより、ページ遷移時に使用する記述が使用不能になりますので、そのコードを削除する必要があります。尚、このコードは Prism を用いて Page を追加した際に自動的に追加されるものです。

App.xaml.cs

この時点で RegisterTypes メソッドでエラーになっている箇所を削除します。

protected override void RegisterTypes(IContainerRegistry containerRegistry)
{
    containerRegistry.RegisterForNavigation<NavigationPage>();
    containerRegistry.RegisterForNavigation<MainPage, MainPageViewModel>();
    //containerRegistry.RegisterForNavigation<MyControl, MyControlViewModel>(); ←不要
}

これで無事 ContentView が完成しました。これが自作コントロールとなりますので、いろんな Page に貼り付けまくることができるようになります。やったぜ!

Page に自作コントロールを設置する

早速設置してみます。設置する対象はどの Page でも構わないのですが、ここでは最初から存在する MainPage に貼り付けることにします。

MainPage.xaml に自作コントロールを貼り付ける

せっかくの自作コントロールなので、複数設置してみましょう。

MainPage

xmlns:cont="clr-namespace:ContentViewMvvm.Views"を追加して自作コントロールのある名前空間を指定。その配下にあるcont:MyControlというコントロールを Grid 状に配置しました。わかりやすいようにそれぞれの背景色は別のものにしてみます。

<?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="ContentViewMvvm.Views.MainPage"
             xmlns:cont="clr-namespace:ContentViewMvvm.Views">

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*" />
            <RowDefinition Height="2*" />
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="2*"/>
        </Grid.ColumnDefinitions>
        <cont:MyControl Grid.Row="0" Grid.Column="0" BackgroundColor="LightBlue"/>
        <cont:MyControl Grid.Row="0" Grid.Column="1" BackgroundColor="LightCyan"/>
        <cont:MyControl Grid.Row="1" Grid.Column="0" BackgroundColor="LightSkyBlue"/>
        <cont:MyControl Grid.Row="1" Grid.Column="1" BackgroundColor="LightYellow"/>
    </Grid>

</ContentPage>

ビルドしてみる

間違っていなければ、ビルドが通って動作が確認できます。

f:id:roamschemer:20191215011215j:plain

自作コントロール内で MVVM してみる

自作コントロール内のロジックを ReactivePropertyを用いて MVVM で作成してみます。機能としては、

  • ボタンを押す(View)
  • ボタンを押したことを受けとり、ロジックを走らせる(ViewModel)
  • ロジックを実施し、プロパティが変化する(Model)
  • ロジック内のプロパティが変化したことを感知し、表示変更を通達する(ViewModel)
  • 表示が変わる(View)

となります。これらは ReactiveProperty を使って接続させる事によってそれぞれ独立して記述できます。

ロジック(Model)を作成する

作り方は様々ですが、説明がしやすいように Model から作成する事にします。Models というフォルダを新たに用意してその中に以下のようなクラスを作成しました。Prism.Mvvm 名前空間の BindableBase を継承させるのを忘れないようにしてください。

using Prism.Mvvm;
using System;
using System.Collections.Generic;

namespace ContentViewMvvm.Models
{
    public class VtuberRandom : BindableBase
    {
        public string Name
        {
            get => name;
            set => SetProperty(ref name, value);
        }
        private string name;

        private readonly List<string> nameList;

        public VtuberRandom()
        {
            nameList = new List<string>()
            {
                "九条林檎","九条棗","九条杏子","九条茘枝",
                "雨ヶ崎笑虹","都三代らみょん","三田そにあ","縁うか","ひなわんこ",
                "巻乃もなか","幸糖ミュウミュウ","青咲ローズ","泡沫調",
                "白乃クロミ","碧惺スキア","紫吹ふうか","菜花なな",
                "結目ユイ","水瀬しあ"
            };
        }

        /// <summary>
        /// ランダムでnameListの名前のうちの一つをNameプロパティにSetする
        /// </summary>
        public void RundomNameSet()
        {
            int seed = Environment.TickCount;
            Random rnd = new System.Random(seed);
            var no = rnd.Next(0, nameList.Count);
            Name = nameList[no];
        }
    }
}

『RundomNameSet メソッドを実行すると、ランダムで nameList の名前のうちの一つを Name プロパティに Set する』という単純なクラスです。setter には SetProperty という ViewModel に変更を通知する機能を付ける必要があります。その為、自動実装プロパティが使えないのが若干面倒ではありますが、コードスニペットを作っておくとかで対応すれば良いと思います。なお、並んでいる名前に関しては趣味です。(A2Pを推せ…)

中継を作成する(ViewModel)

先ほどの Model で変更した情報を View に渡す。もしくはその逆を行うための中継的役割を果たす ViewModel を作成します。自作コントロールの ViewModel は既に ViewModels ファイル内に MyControlViewModel.cs というファイルで存在していますので、そちらに追記していきます。

using ContentViewMvvm.Models;
using Prism.Mvvm;
using Reactive.Bindings;
using Reactive.Bindings.Extensions;
using System;
using System.Reactive.Disposables;
using System.Reactive.Linq;

namespace ContentViewMvvm.ViewModels
{
    public class MyControlViewModel : BindableBase, IDisposable
    {
        private CompositeDisposable Disposable { get; } = new CompositeDisposable();
        public VtuberRandom Model { get; }
        public ReactiveProperty<string> Name { get; set; }
        public ReactiveCommand RundomCommand { get; private set; } = new ReactiveCommand();

        public MyControlViewModel(VtuberRandom vtuberRandom)
        {
            this.Model = vtuberRandom;

            //Modelとの接続
            Name = Model.ObserveProperty(x => x.Name)                   //ModelのNameプロパティと接続します。
                        .Where(x => x != null)                          //Name != null じゃないとなにもしません。
                        .Select(x => x.Contains("九条") ? $"{x}様" : x) //Nameに"九条"が含まれる場合は様を付加。それ以外はそのまま
                        .ToReactiveProperty<string>()                   //ViewModel←Modelの単方向接続です
                        .AddTo(this.Disposable);                        //解放用に纏めておきます

            //Commandの実行
            RundomCommand.Subscribe(_ => Model.RundomNameSet());

        }
        public void Dispose() => this.Disposable.Dispose();
    }
}

機能は主に2つあります。1つは ReactiveProperty を用いて String 型のプロパティを作成し、Model のプロパティと接続している部分。もう一つは ReactiveCommand を用いてプロパティを作成し、それが実行されたら Model のメソッドを実行する事です。注目すべきはコンストラクタ内のコメントの部分で、ReactivePropertyは、まぁごらんのとおり色々できます。WhereやSelectなんかはLINQそのものですね。今回は ViewModel ← Model の単方向接続ですが、Model → ViewModel接続や双方向接続も可能です。

ReactiveCommand については、タップイベントだと思ってもらえば問題ないです。View で何かをタップしたら Model.RundomNameSet() が実施されます。注意すべきは戻り値が無い事で、ViewModel から Model のメソッドを呼ぶ際には戻り値があってはいけません。これは機能的に値を受け取れないという訳ではなく、MVVM の概念的に ViewModel へ Model から返答があるとおかしいからです。今回の場合、メソッドが実行されれば Model の Name プロパティが変化し、その変化は随時 ViewModel の Name プロパティが受け取るので返答値は必要ありませんよね?このように ViweModel は 見た目(View) に対する事象のみを扱うので、プロパティの変化さえ受け取ることができれば充分であり、戻り値が必要になることはあり得ないのです。

まぁこの辺に関しては、私よりすばらしい記事がありますので、そちらをご覧ください。

blog.okazuki.jp

ugaya40.hateblo.jp

画面(View)を作成する

最後に View を作成します。Views にある MyControl.xaml に記述していきます。

<?xml version="1.0" encoding="utf-8" ?>
<ContentView 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="ContentViewMvvm.Views.MyControl">

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*" />
            <RowDefinition Height="2*" />
        </Grid.RowDefinitions>
        <Label Text="{Binding Name.Value}" Grid.Row="0" HorizontalOptions="CenterAndExpand" VerticalOptions="CenterAndExpand"/>
        <Button Text="VTuber抽選" Command="{Binding RundomCommand}" Grid.Row="1"/>
    </Grid>

</ContentView>

Label の Text プロパティや Button の Command プロパティに Binding というキーワードが出てきました。これが View と ViewModel を繋ぐものです。注意点としてはReactiveProperty で Binding した場合は .Value を付けるというルールが存在します。記述を忘れないようにしましょう。なぜか表示が出ないぞちくしょうめ!という場合は、まず最初にこの .Value 忘れを真っ先に疑ってください。エラーも出ないので中々にハマることがあります。

以上で修正は終わりです。

View - ViewModel の接続について

ここまで終わって、「あれ?View - ViewModel はどういうルールで繋がっているんだ?お互いを接続するような記述はしてないよね?」と思った貴方!鋭いですね!実は Prism から 生成された View にはprism:ViewModelLocator.AutowireViewModel="True" という記述が付加されています。これを使用した場合、View と ViewModel の接続は名前空間とクラス名で判断して自動的に接続されます。便利ですね!ただし、当然ながら任意で名前空間やクラス名を変更してしまうと接続が解除されます。一応接続ルールを変更する方法はあるらしいのですが、まぁ何もしないのが無難だと思います。気になる方は調べてみてください。ちなみにファイルの移動だけなら問題ありません。

動かしてみる

ちょっと余談を挟みましたが、これで MVVM を実現した自作コントロールが完成しました。いやっほーぅ!!!ちなみにこんな感じの挙動をします。

f:id:roamschemer:20191215013507g:plain

親 Page から自作コントロールの情報が見たい

物凄く長くなっていますが、実はここからが本題です。自作したコントロールを親 Page から色々やるにはどうすれば良いのでしょうか?ContentViewに存在するプロパティであれば説明するまでもなくそのまま ReactiveProperty を用いてバインディングが可能ですが、ここでは、『各自作コントロールに配置された Label に表示された Name を親でも表示する方法』を考えてみたいと思います。なお、ここから先はあんまり自信がありませんので、問題が無いかどうか検討の上参考にしてください。

BindableProperty

まず、親とやりとりする為のプロパティを ContentView に新設します。その為には、ContentView を継承した MyCustomContentView を作成して使用する事になります。ここで追加するプロパティは当然バインディング可能にする必要があり、それを実現するのが BindableProperty となります。尚、今回は Controls というフォルダを作成して、そこに格納しました。

using Xamarin.Forms;

namespace ContentViewMvvm.Controls
{
    public class MyCustomContentView : ContentView
    {
        public static readonly BindableProperty NowNameProperty =
            BindableProperty.Create(nameof(NowName), //名前
                                    typeof(string), //型
                                    typeof(MyCustomContentView), //自クラス
                                    string.Empty,    //初期値
                                    propertyChanged: (bindable, oldValue, newValue) => //変更があったことを感知するイベントハンドラ
                                    {
                                        ((MyCustomContentView)bindable).NowName = newValue;
                                    },
                                    defaultBindingMode: BindingMode.TwoWay); //初期バインディング方向
        public object NowName
        {
            get => GetValue(NowNameProperty);
            set => SetValue(NowNameProperty, value);
        }
    }
}

ここでは NowName というプロパティを新たに新設しました。BindableProperty.Create メソッドでは、引数でプロパティの名前や型、バインディング方向の方向などを渡します。複数のプロパティを設置したい場合は同じ記述を増やしていく事になります。

Viewの継承元をMyCustomContentViewに変更

MyControl.xml の継承元を MyCustomContentView に変更します。これにより NowName プロパティが使用できます。

<?xml version="1.0" encoding="utf-8" ?>
<controls:MyCustomContentView
    xmlns:controls="clr-namespace:ContentViewMvvm.Controls"
    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="ContentViewMvvm.Views.MyControl"
    NowName="{Binding Name.Value}">
    
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*" />
            <RowDefinition Height="2*" />
        </Grid.RowDefinitions>
        <Label Text="{Binding Name.Value}" Grid.Row="0" HorizontalOptions="CenterAndExpand" VerticalOptions="CenterAndExpand"/>
        <Button Text="VTuber抽選" Command="{Binding RundomCommand}" Grid.Row="1"/>
    </Grid>

</controls:MyCustomContentView>

ここで、新設した NowName と ViewModelの Name プロパティをバインディングさせます。これにより、ラベルに表示されている物と同等のものが NowName プロパティとも繋がることになります。また、当然ですがコードビハインド側の継承元も変更します。

using ContentViewMvvm.Controls;

namespace ContentViewMvvm.Views
{
    public partial class MyControl : MyCustomContentView
    {
        public MyControl()
        {
            InitializeComponent();
        }
    }
}

親 Page で子のプロパティを表示する

親である MainPage.xaml を修正し、子のプロパティを表示するようにします。

<?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="ContentViewMvvm.Views.MainPage"
             xmlns:cont="clr-namespace:ContentViewMvvm.Views">

    <ContentPage.Resources>
        <ResourceDictionary>
            <Style TargetType="Label">
                <Setter Property="HorizontalOptions" Value="CenterAndExpand"/>
                <Setter Property="VerticalOptions" Value="CenterAndExpand"/>
            </Style>
        </ResourceDictionary>
    </ContentPage.Resources>
    
    <StackLayout>
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="*" />
                <RowDefinition Height="2*" />
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"/>
                <ColumnDefinition Width="2*"/>
            </Grid.ColumnDefinitions>
            <cont:MyControl x:Name="LeftTopControl" Grid.Row="0" Grid.Column="0" BackgroundColor="LightBlue" />
            <cont:MyControl x:Name="RightTopControl" Grid.Row="0" Grid.Column="1" BackgroundColor="LightCyan" />
            <cont:MyControl x:Name="LeftUnderControl" Grid.Row="1" Grid.Column="0" BackgroundColor="LightSkyBlue" />
            <cont:MyControl x:Name="RightUnderControl" Grid.Row="1" Grid.Column="1" BackgroundColor="LightYellow" />
        </Grid>
        <Label Text="------ ここから↓が親Page ------"/>
        <Grid>
            <Grid.RowDefinitions>
                <RowDefinition Height="*" />
                <RowDefinition Height="*" />
                <RowDefinition Height="*" />
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"/>
                <ColumnDefinition Width="*"/>
            </Grid.ColumnDefinitions>
            <Label BindingContext="{x:Reference Name=LeftTopControl}" Text="{Binding Path=NowName}" 
                   BackgroundColor="{Binding Path=BackgroundColor}" Grid.Row="0" Grid.Column="0" />
            <Label BindingContext="{x:Reference Name=RightTopControl}" Text="{Binding Path=NowName}" 
                   BackgroundColor="{Binding Path=BackgroundColor}" Grid.Row="0" Grid.Column="1" />
            <Label BindingContext="{x:Reference Name=LeftUnderControl}" Text="{Binding Path=NowName}" 
                   BackgroundColor="{Binding Path=BackgroundColor}" Grid.Row="1" Grid.Column="0" />
            <Label BindingContext="{x:Reference Name=RightUnderControl}" Text="{Binding Path=NowName}" 
                   BackgroundColor="{Binding Path=BackgroundColor}" Grid.Row="1" Grid.Column="1" />
        </Grid>
    </StackLayout>
</ContentPage>

まず記述するのは、cont:MyControlx:Name="LeftTopControl"の部分です。これによりそれぞれのコントロールに名称を付けます。そして、Label コントロールに記述したBindingContext="{x:Reference Name=LeftTopControl}"の部分で先ほどの名称を使って参照するコントロールを指定。そのコントロール内のプロパティにはText="{Binding Path=NowName}"という風な指定の仕方で、バインディングが完成します。なお、当然ですが既存のプロパティにも同様の方法でバインディングできます。さて、これで親Pageから子の値を表示することに成功しました。やったね!こんな感じで動きます。

f:id:roamschemer:20191215012327g:plain

親 Page から自作コントロールを操作したい

ですよね!子の状態を親で拾えるのなら、その逆もやりたいですよね!わかってます!わかってますとも!実際親に配置されたボタンを押すと子に配置されたそれぞれのボタンが押されるという挙動を作ってみようと色々やってみましたんですがね!無理でした!ごめんなさい!一応やってみたことを書いてみますので誰かご解決をば…(人頼み)

親からのボタンタップを受けるプロパティを作成

自作コントロール内に、親からタップした際に情報を受け取るための CommandTrigger というプロパティを作成しました。これに親からSeed値を与えてその変化を子の ReactiveProperty が拾って発動…というのを考えたのですが、コメントに書いてあるとおり親からこのプロパティを変更しても、setterに届いてくれずにとん挫…。いろいろやってみたのですが理由がわからずタイムオーバー…。追加したプロパティだけでなく、BackgroundColor の変更も受け付けなかったので、そもそも ContentView のプロパティはバインディングからの変更が不可なのか???どうにも挙動が安定せず原因が掴めません。大したことじゃないと思うのですがね…原因がわかったら更新します。

public static readonly BindableProperty CommandTriggerProperty =
    BindableProperty.Create(nameof(CommandTrigger),
                            typeof(int),
                            typeof(MyCustomContentView),
                            0,
                            propertyChanged: (bindable, oldValue, newValue) =>
                            {
                                ((MyCustomContentView)bindable).CommandTrigger = newValue;
                            },
                            defaultBindingMode: BindingMode.TwoWay);

public object CommandTrigger
{
    get => GetValue(CommandTriggerProperty);
    set => SetValue(CommandTriggerProperty, value); //←ここに親から来てくれないので子が操作不能
}

まとめ

とまぁ(未達から目をそらして…)こんな感じで比較的簡単に自作コントロールが作れることがわかりました。複数の画面で使いまわすようなパーツは、積極的に ContentView にしてしまうと良いのではないでしょうか?それではみなさま、良いXamarinを…