あいさつ
こんにちは、NSSの江口です。
ここ3年くらいは、Prism/WPF
によるクライアントアプリケーションの開発を行っています。
割と長い間WPFを扱ってきて、色々なトラブルも乗り越えてみて気づいたことがあったので、
この場を借りて共有したいと思います。
この記事で言いたいこと(要約)
ViewModelってDataContextのことだし、
DataContextの性質とか、WPFの制約を考慮すると、
共通部品はC#だけで作成するべきだよね。
もし、この文章だけを受けてまあ、そうだよね。そんなことは知ってるよという感想を抱いた方は以降読み進んでも得られるものがあるか微妙なので、WPFにおけるテクニック集まで読み飛ばしていただいても良いかもしれません。
ん?何のことかわからん、とか、知っているけどもうしばらく付き合ってやるかという方は是非読み進めていただきたいと思います。
私がPrismに対して抱いた誤解
Prism
とはWPF
をMVVM
に則って開発できるフレームワークとなっています。
MVVM(Model View ViewModel)
とはView(xaml及びxaml.cs)
にロジックが集中しすぎる課題を解決するために、ViewModel
という概念を登場させ、View
から状態とビジネスロジックを分離することを目的としています。
当時私は共通部品の開発に携わっており、簡単なものでいうと以下のようなものを作成対象としていました。
普通のテキストボックスに近いですが、ラベルや必須マークが一緒についているので、
都度作成する手間削減とデザインの一貫性を担保する上でも意外に重宝しています。
こういった共通部品を作成する上で、Prismのふれこみを聞いて私の直感が働いたのです。
MVVMでのフレームワークなのだから、共通部品もMVVMで構築するべきだよな
うん、きっとそれがあるべき姿なのだろう
これぞ正義の道だと信じ込み、どんどん邁進していったものです。
ただこれはPrism
以前にWPF
の思想を十分に理解していなかったための、
誤った思い込みであったと後に痛感することになります。
なお、今回利用するサンプルコードはGithubにコミットしています。
https://github.com/takehiro-eguchi/prism-wpf-sample
※前提としている開発環境は VisualStudio2022 、ランタイムは .NET8 です。
共通部品に独自ViewModelを利用する際の問題
意気揚々と開発を進め、見た目もいい感じになってきて、いざウィンドウに配置することにしました。その結果は
あれ、全然バインドできねえ。。。なんで。。。?
これが共通部品のViewModel
利用を断念した原因でした。
サンプルコードでは、配置するウィンドウはMainWindow
、共通部品はMVVMTextList
として作成していますが、私は以下のような構成を考えていました。
↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
ViewModel(サンプルでいうとMainWindowViewModel)
のプロパティとUIコンポーネントのプロパティをバインドさせたい場合、以下のように記載すると思います。
<views:MVVMTextList Grid.Row="1">
<views:LabelTextBox Label="1番目ラベル" Text="1番目テキスト"/>
<!--
MainWindowViewModelのViewBehindLabelValueとバインドさせたいが、
LabelTextBoxにとってのDataContextがMVVMTextListViewModelになるため、
バインドできない
-->
<views:LabelTextBox
Label="{Binding ViewBehindLabelValue}"
Text="{Binding ViewBehindTextValue}"/>
</views:MVVMTextList>
これって何を表しているのでしょうか?
ViewBehindLabelValueのみ指定されていますが、View.DataContextが省略されています。
上の例では、MainWindowViewModel.ViewBehindLabelValueとバインドさせたいのですが、実際はLabelTextBox.DataContext.ViewBehindLabelValueとバインドしようとします。
そしてWPF
には以下の特性があります。
DataContextに値が設定されていない場合は、親要素のDataContextが伝播される
だからDataContext(文脈)だった
当初はなんでDataContextなんていうネーミングなのだろうと思っていましたが、上で気づいた特性を理解すると、そのネーミングもすごくしっくり来ました。
つまりWPF
は以下のようにDataContextが伝播されることを期待していたのです。
つまり大きな集合体(ここでいうウィンドウ)をスコープとしたまさに 文脈(Context) だったのです。
DataContext の実体は ViewModel だった
さらにデバッグをして、気が付いたのですが、DataContextに何が入っているのかを覗いてみると
なんとDataContextに設定されているのはViewModelでした。
Prismは利便性を高めるためにViewModelを自動で検索・インスタンス生成を行い、DataContextに設定するということをやってくれていただけで、
WPFにおいては、あくまでDataContextとして扱われているに過ぎなかったのです。
LabelTextBox
の内容を見ると、想定通り親要素のViewModel
が伝播されていることもわかります。
つまり逆から考えますと、
- WPFでは親要素のDataContextを伝播し、そのコンテキスト内でバインドさせなければならない
- PrismにおいてViewModelを作成すると、DataContextに設定されてしまう
- WPFには、DataContextが設定されていない場合は親要素のDataContextが伝播される特性がある
- トップレベルのUIコンポーネント以外は独自のViewModelを作成してはならない
となります。これが
共通部品を作成する場合は、それとペアになるViewModelは作成してはいけない
という理由になります。
WPF標準(xaml + xaml.cs)で作成した場合の問題
共通部品にViewModelを作成するアプローチはあきらめるとして、
今度はレイアウトをxaml
で、ロジックをコードビハインド(xaml.cs
)で作成することにしました。
しばらくはこの方針で上手くいっていたのですが、共通部品のUIコンポーネントを追加しようとした際に課題にぶつかりました。
いわゆるStackPanel
などで良く使われている以下のような作成方法です。
<StackPanel Orientation="Horizontal">
<CheckBox />
<Label />
</StackPanel>
サンプルコードにおいては、以下のようにテキストのリストを内包するコンポーネントです。
<views:ViewBehindTextList>
<views:LabelTextBox />
<views:LabelTextBox />
</views:ViewBehindTextList>
一見上手く行ったのですが、以下のように子要素にx:Name
を設定したところ
<views:ViewBehindTextList Grid.Row="1">
<views:LabelTextBox />
<views:LabelTextBox />
<views:LabelTextBox x:Name="ViewBehindTextBox" />
</views:ViewBehindTextList>
以下のビルドエラーが発生しました。
要素 'LabelTextBox' で Name 属性値 'ViewBehindTextBox' を設定することはできません。
'LabelTextBox' は、要素 'ViewBehindTextList' のスコープ内にあり、
この要素には、別のスコープで定義されたときに既に名前が登録されています。 行 59 位置 37.
これはどうやらWPF
の仕様のようで、対策としてはC#
のみで共通部品を作るというものでした。
XAML:UserControl内のコントロールインスタンスにName属性つけれないエラー - BackacheEngineerの技術的な備忘録
↑ こちらが参考にさせていただいたサイトです。
結論:共通部品についてはC#のみで作成するべきだった
ここまでの試行錯誤の結果、たどり着いた結論がこれです。
WPF
はせっかく、レイアウトをxaml
、ロジックをC#
に分離するという利点があるのですが、こと共通部品についてはそうするべきではないです。
非常に残念ですが、xaml
にできることはコーディングでもできますし、
xaml
でなんとなくやっていたこと(例えばBinding
)も、コーディングに置き換えて実装していくと思わぬ気づきがあります。
私はこれにより、UIコンポーネント
とDataContext
のバインドの仕組みに気づくことができ、
トラブルシュートや機能を応用した新機能等を開発できるようになりましたので、悪いことだけではなかったです。
WPFにおけるテクニック集
ここからは私がPrism/WPF開発を通じて得られたテクニック紹介したいと思います。
ViewとViewModelのプロパティの定義
独自のプロパティを定義してそれをバインドさせたい場合の方法です。
↓↓↓ View側の定義 ↓↓↓
// プロパティの宣言をする
// 保持しているフィールドへの設定では変更通知が発生しないため
// 必ずSetValueを呼び出すこと
// 値自体はSetValueの中でLabelPropertyをキーに管理されるため、
// 個別に管理する必要はない
public string Label
{
get => GetValue(LabelProperty) as string;
set => SetValue(LabelProperty, value);
}
// プロパティ定義の宣言をする
// DependencyPropertyはstaticに管理され、一度だけ登録されれば十分なので、
// staticフィールドとして初期化させればよい
// プロパティが変更された場合のハンドラが必要な場合は登録しておく(OnLabelChanged)
public static readonly DependencyProperty LabelProperty
= DependencyProperty.Register(
nameof(Label), // プロパティ名
typeof(string), // プロパティの型
typeof(LabelTextBox), // オーナークラス
new PropertyMetadata(null, OnLabelChanged)); // メタデータ(初期値、変更ハンドラ)
// 変更ハンドラの宣言をする
// "d"には変更が発生したオーナーオブジェクトが設定される
// 以下の例では、変更されたプロパティ値を子要素のラベルに設定した上で
// その内容に応じてラベル領域を表示したり折りたたんだりしている
private static void OnLabelChanged(
DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is LabelTextBox labelTextBox)
{
// ラベルに反映
string label = labelTextBox.Label;
labelTextBox.TextLabel.Content = label;
// ラベルの内容が空の場合はラベルの領域を閉じておく
labelTextBox.LabelStack.Visibility = !string.IsNullOrEmpty(label) ?
Visibility.Visible : Visibility.Collapsed;
}
}
↓↓↓ ViewModel側の定義 ↓↓↓
// プロパティの宣言
// 個別に保持したフィールドを変更しただけでは、ViewModel側の変更が通知されないため、
// SetPropertyを利用して、csOnlyLabelValueに値を設定した上で変更を通知してもらう
// 何のプロパティが通知されたかという情報は本来は必要だが、
// 省略した場合、呼び出し元のプロパティ名が設定されるようになっている
public string CsOnlyLabelValue
{
get => csOnlyLabelValue;
set => SetProperty(ref csOnlyLabelValue, value);
}
private string csOnlyLabelValue = "2番目ラベル";
コーディングによるBinding
次に普段我々が自然にxaml
に記載している
<XXX Text="{Binding CsOnlyTextCodeBindValue}"/>
これをソースコードで実装すると以下のように書くことができます。
// コードによりBinding
// バインディングソースとしてViewModelのプロパティを設定
Binding binding = new Binding(
nameof(MainWindowViewModel.CsOnlyTextCodeBindValue));
binding.Source = DataContext; // DataContextの実体はMainWindowViewModel
binding.Mode = BindingMode.TwoWay; // 双方向
// UIコンポーネントのバインディングに登録
CsOnlyBindTextBox.SetBinding(
LabelTextBox.TextProperty, binding);
子要素を配置するためのContentProperty
次に標準コンポーネントで良く見る以下の書き方です。
<StackPanel Orientation="Horizontal">
<CheckBox />
<Label />
</StackPanel>
内包させたら、具体的にどこに配置させたいかわからないのになんか上手く動作している。
きっと標準コンポーネントだから上手い事やってくれているのだろう。
などと思っていないでしょうか?まあ、私は思っていたのですが、これには仕組みがあります。
上の例でいうと、内包して記載している部分はContentという部分になっていて、何をContentにするかというのはContentProperty属性によって定義可能です。
// !!! ContentはChildrenプロパティだと定義 !!!
[ContentProperty(nameof(Children))]
public class CSOnlyTextList : UserControl
{
/// <summary> 子要素 </summary>
public ObservableCollection<LabelTextBox> Children
{
get => GetValue(ChildrenProperty) as ObservableCollection<LabelTextBox>;
set => SetValue(ChildrenProperty, value);
}
public static readonly DependencyProperty ChildrenProperty
= DependencyProperty.Register(
nameof(Children),
typeof(ObservableCollection<LabelTextBox>),
typeof(CSOnlyTextList),
new PropertyMetadata(
new ObservableCollection<LabelTextBox>(),
OnChildrenChanged));
private static void OnChildrenChanged(
DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is CSOnlyTextList textList)
{
// 変更イベントを登録
if (textList.Children is ObservableCollection<LabelTextBox> textBoxes)
textBoxes.CollectionChanged += (sender, e) => textList.BuildTextList();
// 再構築
textList.BuildTextList();
}
}
private void BuildTextList()
{
// StackPanelのテキストを一度クリア
var contentChildren = TextStack.Children;
contentChildren.Clear();
// 追加されたテキストを追加
if (Children != null)
foreach (var childText in Children)
contentChildren.Add(childText);
}
}
ContentPropertyの定義を行うことにより
<views:CSOnlyTextList Grid.Row="1">
<!-- ↓↓↓ テキストのコレクションをプロパティ名を省略して内包できる ↓↓↓ -->
<views:LabelTextBox Label="1番目ラベル" Text="1番目テキスト"/>
<views:LabelTextBox Label="{Binding CsOnlyLabelValue}" Text="{Binding CsOnlyTextValue}"/>
<views:LabelTextBox x:Name="CsOnlyTextBox" Label="3番目ラベル" Text="3番目テキスト"/>
<views:LabelTextBox x:Name="CsOnlyBindTextBox" Label="4番目ラベル" />
</views:CSOnlyTextList>
といった定義ができ、以下のような画面を実現することができます。
なお、ContentPropertyを用いない場合でも以下のコーディングで同じことができます。
<views:CSOnlyTextList Grid.Row="1">
<!-- {クラス}.{プロパティ名} で明示的に設定 -->
<views:CSOnlyTextList.Children>
<views:LabelTextBox Label="1番目ラベル" Text="1番目テキスト"/>
:(省略)
</views:CSOnlyTextList.Children>
</views:CSOnlyTextList>
親要素のプロパティとバインドする方法
この記事を作成していてレビュアーの方に質問を受けたため、
受けた質問について回答するついでに、方法の解説と私の見解を共有したいと思います。
共通部品にViewModelを定義した場合でも
親のプロパティをバインドする方法として、FindAncestorやDataContextを書き換えるといったアプローチではダメなのでしょうか?
これについては今回の記事のコンテキストである共通部品が重要になります。
もちろん親要素のDataContextとバインドする方法はあります。
以下のコードでは、親であるMainWindowViewModelのプロパティとバインドできます。
<views:MVVMTextList Grid.Row="1">
<!--
Textの方は、親を辿りMainWindowを検索し、
それのDataContextのプロパティを設定しているためバインドできる
-->
<views:LabelTextBox
Label="{Binding ViewBehindLabelValue}"
Text="{Binding DataContext.ViewBehindTextValue,
RelativeSource={RelativeSource FindAncestor, AncestorType=views:MainWindow}}"/>
</views:MVVMTextList>
これでバインド自体はできるのですが、ここからが共通部品に求められる特性の話になります。
共通部品とはそもそも、開発の効率化やデザインの一貫性担保のために作成されるものなので、
できるだけ直感的に容易に利用できることが重要になります。
なのでこの意見に対しては
DataContextを書き換えるアプローチ
元々共通部品のViewModelに必要なロジックが記載されているわけなので、それを書き換えてしまった場合、共通部品がまともに動作しなくなる可能性がある
FindAncestorを使って親要素のプロパティとバインドさせる方法
確かにできるのですが、共通部品の利用者サイドからすると
なんでわざわざそんな書き方をしなければならないのか、
TextBoxとかLabelみたいに簡単にバインドさせてくれ
という意見が出ますし、これは利用者にDataContextの仕組みの理解を強いることになるので、あまりオススメできません。
世の中のサンプルコードはプロパティのみの記載になっていることが多いので、
問い合わせが多発し、それにより開発効率が作成者側も利用者側も低下してしまい、
本来の共通部品の利点が薄れてしまうことにつながります。なるべく直感的に簡単に利用できるインタフェースにするべきだと考えます。
まとめ
私がPrism/WPF
を利用してきた経験に基づく失敗やTipsについて紹介させていただきました。
いかがだったでしょうか。
どんな技術でも最初はドキュメントなどを見て実装してみると思いますが、
何を読んでも理解できない時は、私はソースコードを読んでみるということを良くやります。
そうすると思わぬ発見や気づきを得られることがありますし、
そのライブラリを作成している方のポリシーや傾向などが見て取れて面白いのでオススメします。
弊社では技術色の強い記事・コラム・体験談など様々な記事を投稿しておりますので、
引き続き読んでいただけると幸いです。