はじめに
この記事は、C#/WPF を用いて簡単なアプリケーションを作成することを通じて、そのコンセプトと使い方について理解を深めることを目的とするものである.
参考として、こちらのブログ で公開されている PDF や公式のドキュメントなどを用いた.
この記事を参照する方は、これが素人の独学であるため、誤解や適切でない表現が含まれるだろう点に留意していただきたい.
作るもの
参考として挙げた PDF (WPF実践) に倣い、TextBox
に入力した内容を大文字にして TextBlock
に表示するようなアプリケーションを作成する.
これをいくつかの方法で実装してみて、WPF に慣れていくこととする.
作る
共通部分
WPF のプロジェクトと最低限の UI 要素を作成する.
VisualStudio で新しいプロジェクトの作成を行う.
プロジェクトテンプレートは 「WPF プロジェクト」.
ここではプロジェクト名を WpfExercise
として作成した.
作成されたプロジェクトの MainWindow.xaml
の中身を以下のようにする:
<Window x:Class="WpfExercise.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WpfExercise"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
+ <StackPanel>
+ <TextBox Name="MyTextBox" FontSize="16" FontFamily="Consolas" Text="ここは TextBox"/>
+ <TextBlock Name="MyTextBlock" FontSize="16" FontFamily="Consolas" Text="ここは TextBlock"/>
+ </StackPanel>
</Window>
TextBox
と TextBlock
を用意すれば FontSize
や FontFamily
などを付ける必要はないが、個人的な好みのためにこうしておく.
この状態で実行すると、以下のような画面が開く:
バインディングを使用しない場合
まずは、いわゆるコードビハインドのみで目的を達成してみる.
TextBox
は内容が変更されるたびに TextChanged
イベントを発生させるので、それをコード側で処理できるようにする(関係ない部分は ... として省略):
<!-- TextChanged="MyTextBox_TextChanged" を追加 -->
<TextBox Name="MyTextBox" ... TextChanged="MyTextBox_TextChanged"/>
TextChanged=""
と書き、補完で <新しいイベントハンドラー>
を選択すると、MainWindow.xaml.cs
にメソッドが生成される.
private void MyTextBox_TextChanged(object sender, TextChangedEventArgs e) {
}
あとはここで MyTextBlock
の中身を書き換えるだけである.
private void MyTextBox_TextChanged(object sender, TextChangedEventArgs e) {
+ // ロード時、MyTextBlock 生成前にもこれが呼ばれるようなので、null でないことは確かめる
+ if (MyTextBlock is not null && MyTextBox.Text is not null) {
+ MyTextBlock.Text = MyTextBox.Text.ToUpper();
+ }
}
MVVM に倣う場合
MVVM についての簡単な説明
このサンプルのような小さいアプリケーションでは、ありがたみよりも煩雑さが勝るように思われるが、WPF の基本思想のひとつに MVVM と呼ばれるものがある.
あらゆるものをコードビハインドで書くと、あっという間に MainWindow.xaml.cs
が肥大化してしまう.
あらゆる画面のソースファイルが数千、数万行あるようなプロジェクトは最早管理に失敗していると言えよう.
ファイルの肥大化だけならば C# が持つ partial class
を用いて対抗できそうにも思われるが、関心の分離の観点から、クラス自体が別物であると都合がよい.
そこで、Model, View, ViewModel のみっつに役割を分割し設計するのが MVVM である.
View と ViewModel の関係を簡単に書けば、
- View は文字通り画面に描画されるもの
- 入力などによって View に変更が生じると、そのことを ViewModel に伝える
- ViewModel は新しい内容を受け取り、必要に応じて保持している値を書き換えたり、そのことを View に通知したりする
- 通知を受けた View は、ViewModel が保持する値を参照して自身を更新する
というような関係にある. ViewModel は「必要に応じて保持している値を書き換え」る必要があるが、この部分がある程度以上複雑な場合に Model が登場する.
そして、ViewModel と Model の間には、
- ViewModel は Model を保持/参照している
- ViewModel は View から通知された内容を Model に伝える
- ViewModel は必要に応じて、Model に変更を促す
- Model は自身を更新する
- ViewModel は Model が持つ値を参照し、View から参照可能な形に整形する
- (ViewModel は自身の変更を View に通知する)
という関係が想定されている.
今回は「TextBox
の内容を大文字にする」というだけなので、Model を使わずに ViewModel だけで処理を行ってしまうこととする.
つくる
View と ViewModel の紐づけ(View → ViewModel)
何はともあれ ViewModel となるクラスが必要である.
ここでは MyViewModel として以下のクラスを作成した:
using System.Diagnostics;
namespace WpfExercise {
public class MyViewModel {
private string _input;
/// <summary>
/// TextBox の内容
/// </summary>
public string Input {
get => _input;
set {
_input = value;
Output = _input.ToUpper();// ここで Output を更新する
Debug.WriteLine($"MyViewModel.Output={Output}");
}
}
/// <summary>
/// TextBlock に表示する内容
/// </summary>
public string Output { get; private set; }
}
}
XAML ファイルの方も、MyViewModel を使用する形に編集する:
<Window x:Class="WpfExercise.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WpfExercise"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
<Window.Resources>
<!-- リソースとして ViewModel のインスタンスを準備 -->
<!-- local: は Window タグの中で xmlns:local="clr-namespace:参照する名前空間" として定義されている(自動生成) -->
<!-- このようにして、Foo 名前空間の Bar クラスを参照するというようなことができる -->
<local:MyViewModel x:Key="MyViewModelInstance" />
</Window.Resources>
<StackPanel>
<!-- TextBox/TextBlock の Name 要素は今回使わない -->
<!-- Text として Binding を指定する -->
<!-- {StaticResource=MyKey} のように書くと、リソース(Appication.Resources, Window.Resources, Page.Resources など) に定義した、x:Key="MyKey" のインスタンスを参照する -->
<TextBox FontSize="16" FontFamily="Consolas" Text="{Binding Source={StaticResource MyViewModelInstance}, Path=Input}"/>
<TextBlock FontSize="16" FontFamily="Consolas" Text="{Binding Source={StaticResource MyViewModelInstance}, Path=Output}"/>
<!-- 更新タイミングの都合、上の TextBox の他にフォーカスできる UI を用意する -->
<TextBox FontSize="16" FontFamily="Consolas" Text="text box 2"/>
</StackPanel>
</Window>
ここでは WPF では必須級の Binding が登場している.
これを用いると、Source
に指定したものの、Path
に指定したプロパティを参照してくれる.
上記以外にも機能する記述方法はいくつかある.
Source
を省略し、単に {Binding Foo}
と書いた場合、データコンテキストの Foo プロパティが参照される:
: : :
<!-- Source に代えて DataContext を指定する -->
<TextBox ... DataContext="{StaticResource MyViewModel}" Text="{Binding Input}"/>
<TextBlock ... DataContext="{StaticResource MyViewModel}" Text="{Binding Output}"/>
: : :
そのオブジェクト自身が DataContext
を持っていない場合、その祖先要素の持つ DataContext
が参照されるため、以下のように書くこともできる:
<!-- 親要素のデータコンテキストに ViewModel のインスタンスを割り当てる -->
<StackPanel DataContext="{StaticResource MyViewModel}">
<TextBox FontSize="16" FontFamily="Consolas" Text="{Binding Input}"/>
<TextBlock FontSize="16" FontFamily="Consolas" Text="{Binding Output}"/>
...(省略)...
</StackPanel>
上記例では StackPanel.DataContext
に ViewModel を割り当てているが、さらにその上、Window
要素に DataContext
を持たせてもよい.
ただし、Window.Resources
で ViewModel を生成したのでは MainWindow
生成時にこれを参照できないため、コンストラクタ内などでこれを行う:
<Window x:Class="WpfExercise.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WpfExercise"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
<!-- ここには ViewModel を書かない -->
<StackPanel>
<TextBox FontSize="16" FontFamily="Consolas" Text="{Binding Input}"/>
<TextBlock FontSize="16" FontFamily="Consolas" Text="{Binding Output}"/>
<TextBox Name="MyTextBox" FontSize="16" FontFamily="Consolas" Text="TextBox その2"/>
</StackPanel>
</Window>
using System.Windows;
namespace WpfExercise {
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window {
public MainWindow() {
// ここで DataContext を設定する
DataContext = new MyViewModel();
InitializeComponent();
}
}
}
コンストラクタではなく、Application
クラス(App.xaml.cs ファイル)の OnStartup
メソッド内で Window
要素の生成時に設定してもよいが、切りがないのでここまでとする.
とにかく、ここまでに書いたような方法で TextBox/TextBlock の Text プロパティと ViewModel を紐づけることができる.
この状態で実行し、TextBox に適当な値を入力したあとタブキーを押すと以下のようになる:
見ての通り、TextBlock は書き換えられていないが、このときコンソールのデバッグ出力には、MyViewModel.Output=HELLO, WORLD!
行が出ていることが確認できる.
これは、以下のように説明できる:
- フォーカスが外れたときに View → ViewModel の通知が起きる(ViewModel.Input に値が設定される)
- Input のセッター内で Output を書き換え、デバッグ出力を行う
- しかし、ViewModel.Output の変更を View に通知する方法が実装されていないので画面は更新されない
View と ViewModel の紐づけ(ViewModel → View)
それでは、ViewModel の変更を View に通知べく、MyViewModel
を編集する.
using System.ComponentModel;
using System.Diagnostics;
namespace WpfExercise {
- public class MyViewModel {
+ public class MyViewModel : INotifyPropertyChanged {// インターフェースを追加
private string _input;
/// <summary>
/// TextBox の内容
/// </summary>
public string Input {
get => _input;
set {
_input = value;
Output = _input.ToUpper();
Debug.WriteLine($"MyViewModel.Output={Output}");
}
}
- public string Output { get; private set; }
+ private string _output;
+ /// <summary>
+ /// TextBlock に表示する内容
+ /// </summary>
+ public string Output {
+ get => _output;
+ private set {
+ _output = value;
+ RaiseProeprtyChanged("Output");// View への通知を実施する
+ }
+ }
+ public event PropertyChangedEventHandler PropertyChanged;
+ private void RaiseProeprtyChanged(string propertyName) {
+ PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
+ }
}
}
この状態で実行し先ほどと同じように操作すると、TextBlock の値が更新されることを確認できる:
ところで、
RaiseProeprtyChanged("Output");// View への通知を実施する
と書いているが、ViewModel の持つプロパティが増えてくるとこの引数部分の編集ミスによるバグが発生するだろうことは想像に難くない.
そのため、System.Runtime.CompilerServices.CallerMemberNameAttribute
を利用するとより安全とのことだ.
+ using System.Runtime.CompilerServices;
using System.ComponentModel;
: : : (中略)
public string Output {
get => _output;
private set {
_output = value;
- RaiseProeprtyChanged("Output");// View への通知を実施する
+ RaiseProeprtyChanged();// View への通知を実施する
}
}
public event PropertyChangedEventHandler PropertyChanged;
- private void RaiseProeprtyChanged(string propertyName) {
+ private void RaiseProeprtyChanged([CallerMemberName] string propertyName = null) {
+ // CallerMemberName によって、呼び出し元のプロパティ名をここで使用できるようになった
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
}
View と ViewModel の紐づけ(View → ViewModel の通知タイミング変更)
ここまでの実装では、TextBox に入力した後、もうひとつの TextBox にフォーカスを映したときに初めて通知が行われていた.
これは、Binding.UpdateSourceTrigger
のデフォルト動作が LostFocus
、つまりフォーカスを失ったときであったためである.
TextBox.Text
に変更があるたびに通知したい場合はこれを PropertyChanged
にすればよい:
- <TextBox FontSize="16" FontFamily="Consolas" Text="{Binding Input}"/>
+ <TextBox FontSize="16" FontFamily="Consolas" Text="{Binding Input, UpdateSourceTrigger=PropertyChanged}"/>
+ <!-- 以下に同じ
+ <TextBox FontSize="16" FontFamily="Consolas" Text="{Binding Path=Input, UpdateSourceTrigger=PropertyChanged}"/>
+ -->
これならばふたつめの TextBox をなくしても、入力ごとに TextBlock が更新されていく.
TextBox の内容を直接 Binding する場合
MVVM の考え方が WPF において重要なものであることは間違いないが、今回のような小規模な処理では大仰に感じられるかもしれない.
この記事の最後として、ある UI 要素の持つ値を(簡単な加工の上で)別の UI 要素から参照する方法をここに記す.
値の加工なしで表示する
参照される要素に名前を付け、参照する側は Binding.ElementName
にその要素を指定すればよい:
<StackPanel>
<TextBox Name="MyTextBox" FontSize="16" FontFamily="Consolas"/>
<TextBlock FontSize="16" FontFamily="Consolas" Text="{Binding ElementName=MyTextBox, Path=Text}"/>
</StackPanel>
この状態で実行、入力を行うと以下のようになる:
簡単な加工の上で表示する
ここから、TextBlock に表示される値が大文字となるようにしたい.
Binding.Converter
に IValueConverter
の実装を指定すると値の変換が実施される:
using System.Globalization;
using System.Windows.Data;
namespace WpfExercise {
[ValueConversion(typeof(string), typeof(string))]// string → string の変換であることを明示(書かなくても動きはする)
public class ToUpperCaseConverter : IValueConverter {
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) {
// 変換処理の実装
string input = value as string;
if (input != null) {
// 大文字に変換して返す
return input.ToUpper();
}
return string.Empty;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) {
// 今回は呼ばれないので放置
throw new NotImplementedException();
}
}
}
<Window x:Class="WpfExercise.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:WpfExercise"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
+ <Window.Resources>
+ <!-- 作成したコンバータのインスタンスをリソースに置く -->
+ <local:ToUpperCaseConverter x:Key="MyConverter" />
+ </Window.Resources>
<StackPanel>
<TextBox Name="MyTextBox" FontSize="16" FontFamily="Consolas"/>
- <TextBlock FontSize="16" FontFamily="Consolas" Text="{Binding ElementName=MyTextBox, Path=Text}"/>
+ <!-- コンバータを指定 -->
+ <TextBlock FontSize="16" FontFamily="Consolas" Text="{Binding ElementName=MyTextBox, Path=Text, Converter={StaticResource MyConverter}}"/>
</StackPanel>
</Window>
この状態で実行すると、以下のようになる: