ラベル WPF の投稿を表示しています。 すべての投稿を表示
ラベル WPF の投稿を表示しています。 すべての投稿を表示

2026年8月2日日曜日

WPFのGrid内に動的に要素を配置する

WPFのGrid内に動的に要素を配置したいと思ったことはありませんか?

WPFのGridは、基本的な使い方では静的になります。RowDefinitionsプロパティとColumnDefinitionsプロパティはバインディングできず、XAMLからは静的にしか設定することができません。そのため、実行時まで行や列の数が定まらないようなものはそのままでは作ることができません。

これを動的に作れるように作っていきましょう。

没案:ItemsContorl in ItemsControl

Gridは2次元に要素を並べるものなので、配列の配列を作って2次元のデータを用意し、例えば、行ごとのItemsControlをさらにItemsControlで縦に並べればそもそもGridなんていらないのでは?と思うかもしれません。

実際はそれでも何とかなりますが、行や列数が増えるとめちゃめちゃ重たくなります。そもそもItemsControlがそこそこ重たいコントロールなので、それを何百個も作ったら重くなるのは想像に難くないでしょう。

今回の記事では、そのような構造は取らず、あくまでもGridコントロールを動的に制御するというアプローチで2次元の要素を並べるようにしていきたいと考えています。

ViewModelの準備

まずは、セル一つ分を表現するViewModelを準備していきます。

public class CellViewModel : ViewModel
{
    #region Text

    public string Text
    {
        get;
        set {
            if(field == value)
                return;
            field = value;
            RaisePropertyChanged();
        }
    } = "";

    #endregion

    #region Row

    public int Row
    {
        get;
        set {
            if(field == value)
                return;
            field = value;
            RaisePropertyChanged();
        }
    }

    #endregion

    #region Column

    public int Column
    {
        get;
        set {
            if(field == value)
                return;
            field = value;
            RaisePropertyChanged();
        }
    }

    #endregion
}

シンプルなViewModelです。Row / Columnは、このセルを配置するRow / Columnのインデックスを示したものです。Textは今回の本質とは関係ありませんが、何も表示されないと味気ないので表示内容のテキストを保持するものになります。

余談ですが、このコードはC#14のfieldキーワードを使用することで、バッキングフィールドに直接アクセスするようにしてコードを最小化しています。まさに変更通知プロパティのための機能ですね。

続いて、MainWindowViewModelです。少しややこしく見えるかもしれませんが、行数 / 列数を示すRows / ColumnsプロパティをCellsの内容変更に自動で追従するようにしたためのコードが多いだけでそんなに変なことはやっていませんので、よく読んでいただければ内容がすぐにわかるでしょう。

public class MainWindowViewModel : ViewModel
{
    public MainWindowViewModel()
    {
        _Cells = new ObservableCollection<CellViewModel>();
        Cells = new ReadOnlyObservableCollection<CellViewModel>(_Cells);

        ((INotifyCollectionChanged)Cells).CollectionChanged += Cells_CollectionChanged;
    }

    public void Initialize()
    {
        _Cells.Add(new CellViewModel() { Row = 0, Column = 0, Text = "A1" });
        _Cells.Add(new CellViewModel() { Row = 1, Column = 1, Text = "B2" });
        _Cells.Add(new CellViewModel() { Row = 2, Column = 0, Text = "A3" });
    }

    readonly ObservableCollection<CellViewModel> _Cells;
    public ReadOnlyObservableCollection<CellViewModel> Cells { get; }

    #region Cells_CollectionChanged

    private void Cells_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
    {
        switch(e.Action) {
            case NotifyCollectionChangedAction.Add:
                HandleAdd(e.NewItems!);
                break;

            case NotifyCollectionChangedAction.Remove:
                HandleRemove(e.OldItems!);
                break;

            case NotifyCollectionChangedAction.Replace:
                HandleRemove(e.OldItems!);
                HandleAdd(e.NewItems!);
                break;

            case NotifyCollectionChangedAction.Reset:
                RecalculateRows();
                RecalculateColumns();
                break;
        }
    }

    private void HandleAdd(IList newItems)
    {
        foreach(CellViewModel cell in newItems) {
            if(cell.Row + 1 > Rows)
                Rows = cell.Row + 1;

            if(cell.Column + 1 > Columns)
                Columns = cell.Column + 1;
        }
    }

    private void HandleRemove(IList oldItems)
    {
        bool needRecalcRows = false;
        bool needRecalcColumns = false;

        foreach(CellViewModel cell in oldItems) {
            if(cell.Row + 1 == Rows)
                needRecalcRows = true;

            if(cell.Column + 1 == Columns)
                needRecalcColumns = true;

            if(needRecalcRows && needRecalcColumns)
                break;
        }

        if(needRecalcRows)
            RecalculateRows();

        if(needRecalcColumns)
            RecalculateColumns();
    }

    private void RecalculateRows()
    {
        int maxRow = 0;

        foreach(var cell in Cells) {
            if(cell.Row + 1 > maxRow)
                maxRow = cell.Row + 1;
        }

        Rows = maxRow;
    }

    private void RecalculateColumns()
    {
        int maxColumn = 0;

        foreach(var cell in Cells) {
            if(cell.Column + 1 > maxColumn)
                maxColumn = cell.Column + 1;
        }

        Columns = maxColumn;
    }

    #endregion

    #region Rows

    public int Rows
    {
        get;
        set {
            if(field == value)
                return;
            field = value;
            RaisePropertyChanged();
        }
    }

    #endregion

    #region Columns

    public int Columns
    {
        get;
        set {
            if(field == value)
                return;
            field = value;
            RaisePropertyChanged();
        }
    }

    #endregion
}

Behaviorの準備

さて、ここで用意したViewModelからの情報をGridに伝搬してあげたいのですが、最初に説明した通りRowDefinitionsプロパティとColumnDefinitionsプロパティは直接バインディングすることができずXAML経由で扱えません。そのような場合はそう、Behaviorですね。

public class DynamicGridBehavior : Behavior<Grid>
{
    protected override void OnAttached()
    {
        OnRowChanged(Rows);
        OnColumnChanged(Columns);

        base.OnAttached();
    }

    #region Columns

    public int Columns
    {
        get { return (int)GetValue(ColumnsProperty); }
        set { SetValue(ColumnsProperty, value); }
    }

    public static readonly DependencyProperty ColumnsProperty =
        DependencyProperty.Register(nameof(Columns), typeof(int), typeof(DynamicGridBehavior), new PropertyMetadata(default(int), (sender, e) => {
            if(sender is DynamicGridBehavior me && e.NewValue is int newValue)
                me.OnColumnChanged(newValue);
        }));

    void OnColumnChanged(int newValue)
    {
        if(AssociatedObject == null)
            return;

        if(AssociatedObject.ColumnDefinitions.Count > newValue) {
            AssociatedObject.ColumnDefinitions.RemoveRange(newValue, AssociatedObject.ColumnDefinitions.Count - newValue);
        } else if(AssociatedObject.ColumnDefinitions.Count < newValue) {
            for(int i = AssociatedObject.ColumnDefinitions.Count; i < newValue; i++)
                AssociatedObject.ColumnDefinitions.Add(new ColumnDefinition() { Width = ColumnWidth });
        }
    }

    #endregion

    #region ColumnWidth

    public GridLength ColumnWidth
    {
        get { return (GridLength)GetValue(ColumnWidthProperty); }
        set { SetValue(ColumnWidthProperty, value); }
    }

    public static readonly DependencyProperty ColumnWidthProperty =
        DependencyProperty.Register(nameof(ColumnWidth), typeof(GridLength), typeof(DynamicGridBehavior), new PropertyMetadata(default(GridLength), (sender, e) => {
            if(sender is DynamicGridBehavior me && e.NewValue is GridLength newValue)
                me.OnColumnWidthChanged(newValue);
        }));

    void OnColumnWidthChanged(GridLength newValue)
    {
        if(AssociatedObject == null)
            return;

        foreach(var column in AssociatedObject.ColumnDefinitions)
            column.Width = newValue;
    }

    #endregion

    #region Rows

    public int Rows
    {
        get { return (int)GetValue(RowsProperty); }
        set { SetValue(RowsProperty, value); }
    }

    public static readonly DependencyProperty RowsProperty =
        DependencyProperty.Register(nameof(Rows), typeof(int), typeof(DynamicGridBehavior), new PropertyMetadata(default(int), (sender, e) => {
            if(sender is DynamicGridBehavior me && e.NewValue is int newValue)
                me.OnRowChanged(newValue);
        }));

    void OnRowChanged(int newValue)
    {
        if(AssociatedObject == null)
            return;

        if(AssociatedObject.RowDefinitions.Count > newValue) {
            AssociatedObject.RowDefinitions.RemoveRange(newValue, AssociatedObject.RowDefinitions.Count - newValue);
        } else if(AssociatedObject.RowDefinitions.Count < newValue) {
            for(int i = AssociatedObject.RowDefinitions.Count; i < newValue; i++)
                AssociatedObject.RowDefinitions.Add(new RowDefinition() { Height = RowHeight });
        }
    }

    #endregion

    #region RowHeight

    public GridLength RowHeight
    {
        get { return (GridLength)GetValue(RowHeightProperty); }
        set { SetValue(RowHeightProperty, value); }
    }

    public static readonly DependencyProperty RowHeightProperty =
        DependencyProperty.Register(nameof(RowHeight), typeof(GridLength), typeof(DynamicGridBehavior), new PropertyMetadata(default(GridLength), (sender, e) => {
            if(sender is DynamicGridBehavior me && e.NewValue is GridLength newValue)
                me.OnRowHeightChanged(newValue);
        }));

    void OnRowHeightChanged(GridLength newValue)
    {
        if(AssociatedObject == null)
            return;

        foreach(var column in AssociatedObject.RowDefinitions)
            column.Height = newValue;
    }

    #endregion
}

Columns / ColumnWidth / Rows / RowHeightプロパティを実装しました。こちらもまた依存関係プロパティのせいでコード量が多くなっていますが、やっていることとしては、Columns / Rowsプロパティで列数 / 行数を設定して、それに対応するようにGrid.ColumnDefinitionsとGrid.RowDefinitionsの中身を調整しているにすぎません。新たに行や列を作る場合は、その行幅 / 列幅を共通で設定できるようColumnWidth / RowHeightプロパティも用意しています。

XAMLの実装

さて、最後にXAMLを実装していきます。 

<Window x:Class="GridTest.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
        xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
        xmlns:v="clr-namespace:GridTest.Views"
        xmlns:vb="clr-namespace:GridTest.Views.Behaviors"
        xmlns:vm="clr-namespace:GridTest.ViewModels"
        Title="MainWindow" Width="640" Height="480">

    <Window.DataContext>
        <vm:MainWindowViewModel />
    </Window.DataContext>

    <i:Interaction.Triggers>
        <!--  Dispose method is called, when Window closing.  -->
        <i:EventTrigger EventName="Closed">
            <l:DataContextDisposeAction />
        </i:EventTrigger>

        <i:EventTrigger EventName="ContentRendered">
            <l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="Initialize" />
        </i:EventTrigger>

        <!--  If you make user choose 'OK or Cancel' closing Window, then please use Window Close cancel Behavior.  -->

    </i:Interaction.Triggers>

    <ItemsControl ItemsSource="{Binding Cells}" >
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <Grid>
                    <i:Interaction.Behaviors>
                        <vb:DynamicGridBehavior Rows="{Binding Rows}" Columns="{Binding Columns}" RowHeight="50" ColumnWidth="200" />
                    </i:Interaction.Behaviors>
                </Grid>
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
        <ItemsControl.ItemTemplate>
            <DataTemplate DataType="{x:Type vm:CellViewModel}">
                <Border BorderBrush="Gray" BorderThickness="1" >
                    <TextBlock Text="{Binding Text}" HorizontalAlignment="Center" VerticalAlignment="Center" />
                </Border>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
        <ItemsControl.ItemContainerStyle>
            <Style TargetType="ContentPresenter">
                <Setter Property="Grid.Row" Value="{Binding Row}" />
                <Setter Property="Grid.Column" Value="{Binding Column}" />
            </Style>
        </ItemsControl.ItemContainerStyle>
    </ItemsControl>
</Window>

一つひとつ説明していきます。

まず、動的な要素をパネルにマッピングする場合はItemsControlを使います。具体的なパネルはItemsControl.ItemsPanelで指定しますので、ここでGridを指定してあげています。Gridの内容を動的に設定する必要があるので、先ほど作ったDynamicGridBehaviorを設定しています。 

DataTemplateは特段説明する必要はないと思います。なお、静的に配置する場合は各要素にGrid.Row / Grid.Column添付プロパティを追加することになりますが、ここでは指定しません。なぜならば、BorderはさらにContentPresenterでラッピングされるため、Borderで指定したところで正しく反映されないのです。

ContentPresenterにどうやって添付プロパティを設定するかと言えば、ItemContainerStyleになります。これによってGrid.Row / Grid.Column添付プロパティを設定することができるわけですね。

これによって、このように動的に正しく配置されたGridができました。

まとめ

ポイントをまとめると以下の通りとなります。

  • Grid.RowDefinitions / Grid.ColumnDefinitionsはバインディングできないため、Behaviorを作ってC#のコードから操作をする
  • パネルに動的に要素を配置するためにItemsControlを使用する
  • ItemsControlを使った場合、Grid直下の要素はContentPresenterになるため、ItemContainerStyleを使用してGrid.Row / Grid.Column添付プロパティを設定する 
以上となります。

2025年11月23日日曜日

WPF用縦書きテキストブロック Tategaki ver.3.3.0

Tategakiをアップデートしました。

Github:

Nuget:
https://www.nuget.org/packages/Tategaki/

今回の変更点

今回はほぼほぼマイナーチェンジなのですが、Obsolete属性を付けていたTategakiMultilineを削除したので、2桁目のバージョンを上げて3.3.0としました。

変更内容は以下の通りです。

  • ターゲットフレームワークに.NET10.0を追加
  • フォントファイルの読み込みルーチンを微修正(.NET10.0で出るようになった警告CA2022を回避するため)
  • TategakiMultilineを削除(ver.3.1.0以降Obsolete属性が付いていたもの)

.NET10.0では、Stream.Readメソッドの戻り値を無視すると警告CA2022が出るようです。代替策としてStream.ReadExactlyメソッドを呼び出すことが推奨されていますが、これが.NET7.0以降でのみサポートされているので、それ以外のターゲットではオレオレReadExactlyを作って呼び出すことで対応しました。

TategakiMultilineはいつ消そうかとずっと様子を伺っていたのですが、Obsoleteを付けたver.3.1.0をリリースしてから1年半以上経ちましたし、.NET10.0対応を機に削除することにしました。パフォーマンスも機能性もTategakiTextのTextWrappingプロパティを使うほうが優れていますので、ぜひそちらを使用してください。

2025年6月26日木曜日

WPF用縦書きテキストブロック Tategaki ver.3.2.3

Tategakiをアップデートしました。

Github:

Nuget:
https://www.nuget.org/packages/Tategaki/

今回の変更点

今回もマイナーチェンジです。というより、コードはほぼ変更していません。以下のターゲットフレームワークを追加しました。

  • .NET Framework 4.6.1(現行サポートされている最低バージョン)
  • .NET Framework 4.6.2(GlyphRunのオーバーロードが変更になるバージョン)
  • .NET Core 3.0(.NET CoreでWindows Desktopがサポートされた最低バージョン)
  • .NET8.0(最新のLTSバージョン)

これにより、今までサポートしていたのが.NET Framework 4.7.2以降 / .NET 6.0以降だったものが、.NET Framework 4.6.1以降 / .NET Core 3.0以降に緩和されました。ここまでならコードをほぼ変更せずに対応できましたが、これ以上はTupleがサポートされていないなど、かなり大幅な修正が必要になるため対応できません。

Tategakiはver.2.1.1が.NET Framework 4 Client Profileのサポートをしていて、未だにダウンロードしてくださる方がたくさんいます。現時点でも、ver.3.2.2よりもver.2.1.1のほうが3倍近く多くのダウンロードがあります。

Package Downloads for Tategaki

この緩和によって、過去のバージョンに縛られた方が少しでも新しいバージョンに移行できることを願うばかりです。

2024年10月26日土曜日

Prism + DryIoc 入門③(ダイアログ編)

前回から少し時間が空きましたが、Prism+DryIoc入門の第3弾です。一応これで一区切りのつもりです。 

前回はPrism+DryIocでプロジェクトを立ち上げるお作法を紹介しました。これに加えてWPFの基礎知識がある皆さんはそれなりのソフトを作ることができると思います。ですが、ひとつ足りないことがありました。それはダイアログの表示です。今回はそのダイアログの表示の方法について説明していきます。 

ダイアログの実装

さて、通常、ダイアログを開くとなるとウィンドウを作ることになりますが、PrismではUserControlを作ることになります。ちょうどウィンドウ内でRegionを作るのと同じような感じですね。

順に説明していきます。

1. Viewの作成

「追加」→「新しい項目」からPrism UserControl (WPF)を追加します。

出来上がったXAMLファイルに前回の記事に書いたとおりのお作法でIgnorebleなどを設定します。

<UserControl x:Class="PrismTest.Views.TestDialog"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:prism="http://prismlibrary.com/"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:vm="clr-namespace:PrismTest.ViewModels"
             mc:Ignorable="d"
             d:DataContext="{d:DesignInstance Type=vm:TestDialogViewModel}"
             prism:ViewModelLocator.AutoWireViewModel="True"
             Height="350" Width="525" >

    <Grid />
</UserControl>

2. VideModelの作成

ViewModelにはIDialogAwareを実装する必要があります。これはPrismにこれはダイアログですよと教えるためのものですね。

public class TestDialogViewModel : BindableBase, IDialogAware
{
    public TestDialogViewModel()
    {
    }

    public string Title => "Test Dialog";

    public event Action<IDialogResult>? RequestClose;

    public bool CanCloseDialog() => true;

    public void OnDialogClosed()
    {    
    }

    public void OnDialogOpened(IDialogParameters parameters)
    {
    }
}

タイトルやいくつかのメソッド、ダイアログを閉じる際に呼ぶイベントなどを実装します。特段説明をしなくても、その名前から何のためのものかはすぐにわかると思います。

3. Viewをダイアログとして登録

App.xaml.csを開き、RegisterTypesメソッド中にて作った型をダイアログとして登録します。これを登録することで、PrismがこのViewがダイアログであることを認識してくれます。

public partial class App
{
    protected override Window CreateShell()
    {
        return Container.Resolve<MainWindow>();
    }

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {
        containerRegistry.RegisterDialog<TestDialog>("TestDialog");
    }
}

これも前回の記事までを読んだ人なら特に困ることは無いと思います。

4. ダイアログを表示

さて、ダイアログの表示も至って簡単です。ライブラリ無しではMVVMのポリシーを保ちつつViewModelから別ウィンドウを表示するのがとても面倒だったので、さすがPrismという感じですね。

public class ContentRegionViewModel : BindableBase
{
    public ContentRegionViewModel(IDialogService dialog)
    {
        ShowDialogCommand = new DelegateCommand(() => dialog.ShowDialog("TestDialog"));
    }

    public DelegateCommand ShowDialogCommand { get; }
}

適当なViewModelでIDialogServiceを受け取るようにすると、DryIoc経由でダイアログ関係の機能にアクセスできるようになります。ダイアログを表示するにはShowDialogメソッドを呼ぶだけです。引数はApp.xaml.csで設定した名前ですね。

このコマンドを呼び出すと、無事ダイアログが表示されました。

ダイアログを作りこむ

1. ウィンドウスタイル

さて、ダイアログが表示されましたが、よく見ると最大化/最小化ボタンなどが表示されていますね。Windows11のデザインではわかりにくいですが、ウィンドウもリサイズ可能なものになっています。さらにタスクバーを見るとダイアログが単体のアイコンを持っています。イケてないですね。


端的に、普通のウィンドウが表示されたと言えます。ダイアログはもっとダイアログっぽいスタイルでウィンドウを表示したいですね。

これに関しては、UserControlにprism:Dialog.WindowStyle添付プロパティを設定してやれば良いです。

<UserControl x:Class="PrismTest.Views.TestDialog"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:prism="http://prismlibrary.com/"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:vm="clr-namespace:PrismTest.ViewModels"
             mc:Ignorable="d"
             d:DataContext="{d:DesignInstance Type=vm:TestDialogViewModel}"
             prism:ViewModelLocator.AutoWireViewModel="True"
             Height="350" Width="525" >

    <prism:Dialog.WindowStyle>
        <Style TargetType="Window">
            <Setter Property="prism:Dialog.WindowStartupLocation" Value="CenterOwner" />
            <Setter Property="ResizeMode" Value="NoResize"/>
            <Setter Property="ShowInTaskbar" Value="False"/>
            <Setter Property="SizeToContent" Value="WidthAndHeight"/>
        </Style>
    </prism:Dialog.WindowStyle>

    <Grid />
</UserControl>

Windows11のデザインではわかりにくいですが、ウィンドウ枠が細枠(サイズ変更不可)になっており、最小化/最大化のボタンもなくなっています。もちろんタスクバーにも表示はされていません。

2. データの受け渡しをする

さて、ダイアログを表示するときは何かしらのデータを引き渡したいし、ダイアログを閉じた時には入力されたデータを受け取りたいことがあります。安心してください、Prismではそのような機能もサポートしています。

public class ContentRegionViewModel : BindableBase
{
    public ContentRegionViewModel(IDialogService dialog)
    {
        ShowDialogCommand = new DelegateCommand(() => {
            var param = new DialogParameters() { { "Text", Text }, };

            dialog.ShowDialog("TestDialog", param, result => {
                if(result.Result == ButtonResult.OK)
                    Text = result.Parameters.GetValue<string>("Text");
            });
        });
    }

    public DelegateCommand ShowDialogCommand { get; }

    public string Text
    {
        get => _Text;
        set => SetProperty(ref _Text, value);
    }
    string _Text = "";
}

呼び出し側のViewModelはこんな感じです。ShowDialogの第2引数にDialogParametersを渡します。名前こそDialogParametersですが、単純なディクショナリ型ですので、任意のデータを入れ込むことができます。

第3引数はダイアログから制御が返ってきたときに呼び出されるデリゲートです。下のダイアログ側のViewModelを見てからのほうがわかりやすいと思いますので、ひとまずそちらを見てから解説します。

public class TestDialogViewModel : BindableBase, IDialogAware
{
    public TestDialogViewModel()
    {
        OkButtonCommand = new DelegateCommand(() => RaiseRequestClose(ButtonResult.OK, new DialogParameters() { { "Text", Text } }));
        CancelButtonCommand = new DelegateCommand(() => RaiseRequestClose(ButtonResult.Cancel));
    }

    public string Title => "Test Dialog";

    public void OnDialogOpened(IDialogParameters parameters)
    {
        Text = parameters.GetValue<string>("Text");
    }

    public void OnDialogClosed()
    {
    }

    public bool CanCloseDialog() => true;

    protected void RaiseRequestClose(ButtonResult btnres, IDialogParameters param)
    {
        RequestClose?.Invoke(new DialogResult(btnres, param));
    }
    protected void RaiseRequestClose(ButtonResult btnres)
    {
        RequestClose?.Invoke(new DialogResult(btnres));
    }
    public event Action<IDialogResult>? RequestClose;


    public DelegateCommand OkButtonCommand { get; }
    public DelegateCommand CancelButtonCommand { get; }

    public string Text
    {
        get => _Text;
        set => SetProperty(ref _Text, value);
    }
    string _Text = "";
}

OnDialogOpenedメソッドはIDialogAwareインターフェースのメンバーの一つですが、ダイアログが開いたときにPrismがこのメソッドを呼んでくれます。この引数が、呼び出し元がパラメーターとして指定したものになります。今回は呼び出し物が指定したTextを読み取って、Textプロパティに代入しています。 

OKボタン/Cancelボタンが押されたときにそれぞれRequestCloseイベントを発動させています。その引数にはDialogResultクラスで、どのボタンが押されたかとDialogParametersをコンストラクタに渡してあげることで、その値を呼び出し元に返すことができます。

そして、呼び出し元のコードでは、OKボタンが押されたことを受けて、ShowDialogメソッドの第3引数のデリゲートが呼ばれますので、そこでButtonResultやDialogParametersを読み取って、適宜何かしらの処理を追加してやれば良いです。

ちなみに、ShowDialogメソッドは同期的に呼ばれます。すなわち、第3引数のデリゲートが制御を返した後にShowDialogメソッドは制御を返します。じゃあなんでShowDialogメソッドの戻り値をIDialogResultにしてくれなかったんだ…。

番外編:標準ダイアログを表示する

さて、ダイアログの表示のしかたは分かったけど、MessageBoxやファイルを開くダイアログなどのWindows標準ダイアログを表示させるにはどうしたら良いでしょう?

結論から言うと、Prismではサポートしていません。今まで説明してきた通り、ダイアログを実装するための支援が充実しているから、MessageBoxやファイルを開くダイアログは自作してねということのようです。

確かに、最近そういったダイアログも独自実装されたアプリをしばしば見かけますが、個人的にはソウジャナイ感をどうしても感じてしまいます。そういう標準ダイアログは、アプリを作る側の省力化だけでなく、使う側としてもどのアプリを使っても同じデザインだからOSとして統一感があり使いやすいわけです。それをわざわざ「自分で実装しろ」というのはいかがなものか…。

というわけで、標準ダイアログを表示するためにはLivetを使いましょう。大丈夫、PrismとLivetも共存できます。

まずは NugetからLivet.Messagingを入れます。

public class ContentRegionViewModel : BindableBase
{
    public ContentRegionViewModel()
    {
        Messenger = new InteractionMessenger();

        ShowMessageBoxCommand = new DelegateCommand(() => Messenger.Raise(new InformationMessage("MessageBox Text", "Caption", System.Windows.MessageBoxImage.Information, "InformationMessageBox")));
    }

    public InteractionMessenger Messenger { get; }

    public DelegateCommand ShowMessageBoxCommand { get; }
}

その後、ViewModelにInteractionMessengerクラスのプロパティを一つ用意してあげます。Livet標準のViewModel基底型ならもとからこのプロパティを持っているのですが、当然ながらPrismには無いので後付けしてやる必要があります。

<UserControl x:Class="PrismTest.Views.ContentRegion"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:prism="http://prismlibrary.com/"
             xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
             xmlns:vm="clr-namespace:PrismTest.ViewModels"
             mc:Ignorable="d"
             d:DataContext="{d:DesignInstance Type=vm:ContentRegionViewModel}"
             prism:ViewModelLocator.AutoWireViewModel="True"
             d:Height="350" d:Width="525" >

    <i:Interaction.Triggers>
        <l:InteractionMessageTrigger Messenger="{Binding Messenger}" MessageKey="InformationMessageBox">
            <l:InformationDialogInteractionMessageAction InvokeActionOnlyWhenWindowIsActive="False" />
        </l:InteractionMessageTrigger>
    </i:Interaction.Triggers>
    
    <Grid>
        <Button Content="Show MessageBox" Command="{Binding ShowMessageBoxCommand}" Width="150" Height="40" />
    </Grid>
</UserControl>

ViewではInteractionMessageTriggerを使い、ViewModelからのメッセージを受け取ってやれば良いです。今回はOKボタンのみのMessageBoxを表示させていますが、その他の種類のMessageBoxだったりファイルを開くダイアログだったりは、対応する***Message型/***InteractionMessageAction型に差し替えれば表示することができます。ここらへんはLivetの使い方ですので、だいぶ昔に書いたこちらの記事を見ていただければ良いかと思います。

Prismの入門記事でPrismではなく別のライブラリを使えというのも変な話ですが、ライブラリによって当然得手不得手というものはありますから、それによって組み合わせが発生するのはやむを得ないことと思います。

***

以上で、3回にわたって連載(?)してきたPrism+DryIocの入門編ですが、ひとまず今回で終わりにしたいと思います。また何か思いついたテーマがあれば随時記事にします。

2024年10月20日日曜日

.NET時代のWPFでMessageBoxにビジュアルスタイルを適用する

WPFには一応標準のMessageBoxが付いています。Windows 95以降(Win32APIにて)標準で装備されているMessageBoxをWPFから呼び出す機能ですね。Livetなどではちゃんとそれを使うための機能が備わっているため、未だにお世話になります。

ですが、実はこのMessageBox、そのままではあまりイケた見た目になりません。

MessageBoxの見た目 

早速MessageBoxを表示させてみましょう。
<Button HorizontalAlignment="Center" VerticalAlignment="Center" Height="30" Width="150" Content="Show MessageBox" >
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="Click">
            <l:InformationDialogInteractionMessageAction InvokeActionOnlyWhenWindowIsActive="False" >
                <l:DirectInteractionMessage>
                    <l:InformationMessage Caption="Test" Text="Test MessageBox" Image="Information" />
                </l:DirectInteractionMessage>
            </l:InformationDialogInteractionMessageAction>
        </i:EventTrigger>
    </i:Interaction.Triggers>
</Button>

見てくださいこのボタン。四角くて段付きのボタン!25年前のデザインです。

まじめな話をすると、Windows Me及びWindows 2000まではこのデザインでした。いずれも2000年発売のOSです。その後、Windows XPの発売に合わせて「ビジュアルスタイル」と呼ばれる見た目が導入されました。それを有効にすると、ボタンが少しフラットな見た目になります。

良いですね!ボタンがフラットなデザインになりましたし、アイコンも少しモダンな感じななりました。 

余談ですが、WPFが正式にリリースされたのは(=WPFが初めて組み込まれた.NET Framework 3.0がリリースされたのは)2006年のことですから、これらビジュアルスタイルはそれよりもだいぶ古い技術の話になります。WPFでは見た目を非常に高い柔軟性でいじることができますが、それよりも前はコントロールの見た目もそんなに柔軟に変えられるものではありませんでした。MicrosoftがWin32APIの中で提供するコントロールを使って自分のアプリを作成しなければならない時代では、やはりこういった全体的な見た目にかかわる機能というのはやはり当時は注目度が高かったわけです。

.NET時代のビジュアルスタイル有効化

さて、ネットで少し調べると、まあ古い技術であるため、Win32APIの話(C++のアプリ作成の話)だったり、Windows Formsの話だったり、.NET Framework時代の話だったり、昔の記事がたくさん見つかります。.NET時代の設定はどうしたら良いのでしょうか。

結論から言えば簡単です。

まず、ソリューション エクスプローラーからプロジェクトを右クリックし、「追加」→「新しい項目」を選択します。

つづいて「アプリケーション マニフェスト ファイル」を選択し、そのまま「追加」ボタンを押します。


そうすると、プロジェクト直下にapp.manifestが追加されるので、それを開きます。下のほうを見ると「Windows のコモン コントロールとダイアログのテーマを有効にします (Windows XP 以降)」と書いてあるブロックがあるので、その部分のコメントを外します。

最後にこれをビルドすれば、ビジュアルスタイルが適用されたMessageBoxが表示されます。

--------------

以上です。

2024年10月13日日曜日

WPFでReactivePropertyを使う際のお作法

今回はWPFでReactivePropertyを使う際のお作法を備忘録的に紹介していこうと思います。

ReactivePropertyとはReactive Extensions (Rx)ベースで作られたプロパティを提供するライブラリです。INotifyPropertyChangedを実装しているためViewModelにそのまま使用することができ、また、LINQを使って値の伝搬を表現することができるため、Statefulなアプリを簡単に実装することができる、非常に強力なライブラリです。

1. 導入

Nugetからダウンロードできます。

ReactiveProperty.WPFは入れなくても一応使えないことは無いのですが、あとでハマることになりますので、WPFアプリなら思考停止でとりあえず入れてしまいましょう。

2. UIスレッドへの転送設定

WPFには単一のスレッドからしかUI要素にアクセスできないという仕様があります。ですので、ViewModelにてイベントをそのスレッドに転送してやらねばならないのですが、ReactivePropertyには便利なことに特定のスレッドでイベントを発生させる機能があります。

さて、転送するにあたって、当然ながらどのスレッドがUIスレッドかということをライブラリに教え込まなければなりません。それは、App.xaml/App.xaml.csに以下のコードを追加することで行います。

<Application x:Class="WpfTest.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:WpfTest"
             StartupUri="MainWindow.xaml"
             Startup="Application_Startup">
    
    <Application.Resources>
    </Application.Resources>
</Application>
public partial class App : Application
{        
    private void Application_Startup(object sender, StartupEventArgs e)
    {
        ReactivePropertyScheduler.SetDefault(new ReactivePropertyWpfScheduler(Dispatcher));
    }
}

アプリのスタートアップ時に、Application.DispatcherをReactivePropertySchedulerとして登録するという作業になります。ReactivePropertyWpfSchedulerはReactiveProperty.WPFに入っているクラスで、WPF向けのスケジューラーです。

3. ReactivePropertyとReactivePropertySlim

さて、これで準備が整いましたので、実際に使用していきます。

ReactiveProperty(ライブラリ)には、ReactiveProperty(クラス)とReactivePropertySlim(クラス)があります。ReactivePropertyは色々な機能を持っているが故にパフォーマンスが悪めなのですが、ReactivePropertySlimは機能を絞ってパフォーマンスをかなり改善しています。そのReactivePropertySlimで削られた機能として、主に以下の2つがあります。

  • UIスレッドへの自動転送機能
  • 入力値のバリデーション機能

いずれも、ViewModelとしては必要な機能です。ですので、基本的にViewModelではReactiveProperty、ModelではReactivePropertySlimを使用すると良いでしょう。

public class MainWindowViewModel : BindableBase
{
    readonly IMainWindowModel model;
    public MainWindowViewModel(IMainWindowModel model)
    {
        this.model = model;

        Text = model.Text.ToReactivePropertyAsSynchronized(p => p.Value);
    }

    public ReactiveProperty<string> Text { get; }
}
public interface IMainWindowModel
{
    IReactiveProperty<string> Text { get; }
}
public class MainWindowModel : IMainWindowModel
{
    public MainWindowModel()
    {
        Text = new ReactivePropertySlim<string>();
        Text.Subscribe(p => System.Diagnostics.Debug.WriteLine(p));
    }

    public ReactivePropertySlim<string> Text { get; }
    IReactiveProperty<string> IMainWindowModel.Text => Text;
}

このコードでは、Prism前提でModelとViewModelを疎結合にするため、間にIMainWindowModelを挟んでいます。そこまでの抽象化が必要ない場合は適宜コードを読み替えてください。

ReactivePropertyとReactivePropertySlimを双方向に同期させるためにはToReactivePropertyAsSynchronized()を使います。片方向(Model→ViewModel)の場合はToReadOnlyReactiveProperty()で良いでしょう。

--------------

以上です。その他の細かい使い方は公式ドキュメントを参照ください。

2024年9月29日日曜日

ListView Extensions ver.1.3.1

ListView Extensions ver.1.3.1をリリースしました。

Github:

Nuget:
https://www.nuget.org/packages/ListViewExtensions/

今回の変更点

  • ソート対象のプロパティ名を入れ子(プロパティのプロパティの…のプロパティ)にできるようにした。
  • SortableGridViewColumnにSortingMemberPathを追加した。この項目に何も設定していない場合(nullの場合)は今まで通りDisplayMemberBindingsのパスをソートのキーとして扱うが、この項目を設定した場合はこれをキーとしてソートされる。
  • ListViewViewModel (ReadOnlyUIObservableCollectionを継承しているクラス)でIndexerとCountのPropertyChangedイベントが発生しなかった不具合を修正
  • ListViewの子要素にComboBoxなどがあると、その選択が変化したときに例外が発生する不具合を修正

下2つはバグ修正ですので、上2つについて説明していきます。

ソート対象のプロパティ名を入れ子(プロパティのプロパティの…のプロパティ)にできるようにした

今までは、ソートのキーにするプロパティ名は、SortableObservableCollectionの要素のプロパティしかできませんでした。ですので、少々わざとらしいですが、例えば以下のようなクラスがあったとしたら、person.Name.Spellなどのいわゆる「プロパティのプロパティ」はソートキーに指定することができませんでした(こちらのコードはgithubに公開しているサンプルコードの抜粋です)。

public class PersonViewModel : ViewModel
{
    // 中略
    
    public NameViewModel? Name
    {
        get => _Name;
        set => RaisePropertyChangedIfSet(ref _Name, value);
    }
    NameViewModel? _Name;

    public string Age => $"{model.Age}歳";

    public string Birthday => model.Birthday.ToShortDateString();

    public string Height => $"{model.Height_cm}cm";

    // 中略
}

public class NameViewModel : ViewModel
{
    // 中略
    
    public string? Spell
    {
        get => model.Spell;
        set => model.Spell = value;
    }

    public string? Pronunciation
    {
        get => model.Pronunciation;
        set => model.Pronunciation = value;
    }
}

それをできるようにしたという変更です。

これの真価を発揮するのは、ReactivePropertyを使ってViewModelを作ったときです。ReactivePropertyでは、person.Name.Valueなどの形でプロパティにアクセスする必要があるため、必ず2段以上入れ子になります。

実は今までもListVIewViewModelのコンストラクタでプロパティの読み替え用のディクショナリを、SortableObservableCollectionの引数でIComparerのディクショナリを渡すことで無理やり使うことはできなくはなかったのですが、回りくどい方法でソースコードの負荷が高まってしまうためあまりイケている方法ではありませんでした。ですが、今回のアップデートで、DisplayMemberBindingsに表現したとおりのパスを辿るようになったので、ViewModelやModelでは何も書かずに入れ子プロパティを参照できるようになりました。

SortableGridViewColumnにSortingMemberPathを追加した

ListViewのヘッダーはSortableGridViewColumnで作ることができます。ここで、それぞれのヘッダーに対応するソートのキーとするプロパティは、そのままDisplayMemberBindingsのパスを用いていましたが、それを任意のプロパティに設定できるようにしました。

通常は別の名前のプロパティにする必要は無いとは思いますが、例えば、ViewModelとViewでプロパティ名が異なる場合は、実際はソート操作自体はModel(SortableObservableCollection)にて行っているためプロパティ名の読み替えが必要になっていました。前述した通り、ViewModelにはプロパティ名読み替え用にディクショナリを受け取るコンストラクタがありますが、SortingMemberPathで直接設定できるようになりました。

<GridView>
    <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name.Spell}" Header="Name" />
    <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name.Pronunciation}" Header="Pronunciation" />
    <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
    <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
    <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" SortingMemberPath="Height_cm" Header="Height" />
    <GridView.ColumnHeaderContainerStyle>
        <Style TargetType="lv:SortableGridViewColumnHeader">
            <Setter Property="SortingArrowLocation" Value="Top" />
        </Style>
    </GridView.ColumnHeaderContainerStyle>
</GridView>

このコードでは、5つ目の「Height」のプロパティ名を「Height_cm」と指定しています。

ちなみにもう一点この機能が必須のところがあって、SortableGridViewColumnにてCellTemplateを指定する場合です。セルの中身をDataTemplateで表現したい場合に使うものですが、DisplayMemberBindingsを設定しているとそちらのほうが優先されてしまいCellTemplateが働きません。その際もこのSortingMemberPathを指定することで、DataTemplateを使いながらソート用のプロパティも指定できるようになりました。

ちなみに、DisplayMemberBindingsはBindingBase型のプロパティですが、SortingMemberPathはString型です。ですので、Visual StudioでIntelliSenseは働きませんのでご注意ください。

--------------

変更点の説明は以上です 。

実際自分でこのライブラリを使い込んでいくといろいろと見つかりますね。まあ、WPFの全貌は10年以上触っていてもよくわからないので、こうやってブラッシュアップしていくしかないですね…。

2024年9月23日月曜日

ListView Extensions ver.1.3.0

ListView Extensions ver.1.3.0をリリースしました。

Github:

Nuget:
https://www.nuget.org/packages/ListViewExtensions/

今回の変更点

  • 選択項目の同期をListViewSelectedItemsActionからSelectedItemsSync.Source添付プロパティ経由で行うようにした。
  • Obsolete指定していたSortedHeaderを削除

後者は古い機能が削除されただけなので、前者について説明します。

選択項目の同期をListViewSelectedItemsActionからSelectedItemsSync.Source添付プロパティ経由で行うようにした。

ListViewにはSelectedItemsプロパティがあり、複数項目を選択したときはここから選択項目をすべて取得することができますし、このリストをプログラムから操作することで選択項目を変更することができます。しかし、これはViewModelをバインディングできないのです。

というのも、見ての通りこれはget専用プロパティであり、ViewModelのにIListのプロパティを作ってバインディングしようとしてもsetすることができないのです。Mode=OneWayToSourceにしてみてもやはり上手くいきません。BinadbleAttributeが付いているプロパティなのに一体どういうことなんでしょうね。

ということで、ver.1.2.0までのListViewExtensionsではListViewSelectedItemsActionというものを用意していました。

<ListView ItemsSource="{Binding People}" >
    <ListView.View>
        <GridView>
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name}" Header="Name" />
            <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Pronunciation}" Header="Pronunciation" />
            <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" Header="Height" />
            <GridView.ColumnHeaderContainerStyle>
                <Style TargetType="lv:SortableGridViewColumnHeader">
                    <Setter Property="SortingArrowLocation" Value="Top" />
                </Style>
            </GridView.ColumnHeaderContainerStyle>
        </GridView>
    </ListView.View>
    <ListView.ItemContainerStyle>
        <Style TargetType="ListViewItem">
            <Setter Property="ContextMenu">
                <Setter.Value>
                    <ContextMenu>
                        <MenuItem Header="Increment the age" Command="{Binding IncrementAgeCommand}" />
                        <MenuItem Header="Decrement the age" Command="{Binding DecrementAgeCommand}" />
                    </ContextMenu>
                </Setter.Value>
            </Setter>
            <!--<Setter Property="lv:DoubleClickBehavior.Command" Value="{Binding DoubleClickCommand}" />-->
            <Setter Property="lv:DoubleClickBehavior.MethodTarget" Value="{Binding}" />
            <Setter Property="lv:DoubleClickBehavior.MethodName" Value="DoubleClicked" />
        </Style>
    </ListView.ItemContainerStyle>
    <i:Interaction.Triggers>
        <l:InteractionMessageTrigger Messenger="{Binding Messenger}" MessageKey="SelectedItemsMirroring" >
            <lv:ListViewSelectedItemsAction Source="{Binding People.SelectedItemsSetter}" />
        </l:InteractionMessageTrigger>
    </i:Interaction.Triggers>
</ListView>

ただし、これを使うのには癖がありすぎました。先日これを使おうとしたところ自分でもめちゃめちゃハマりましたし、ハマった方も多かったのではないでしょうか。

まず、これは適当なタイミングでViewModelからこのアクションを発動させないと同期しません。

public void Initialize()
{
    model = MainWindowModel.GetInstance();

    People = new ListViewViewModel<PersonViewModel, PersonModel>(model.People, person => new PersonViewModel(person), new Dictionary<string, string>() { { nameof(PersonModel.Height_cm), nameof(PersonViewModel.Height) } }, DispatcherHelper.UIDispatcher);
    Messenger.Raise(new InteractionMessage("SelectedItemsMirroring"));
}

忘れずにそのコードを入れたとして、このアクションを発動させるタイミングもとても重要です。このアクションはListViewのSelectedItemsをListViewSelectedItemsAction.Sourceにコピーする操作をするので、ListViewがインスタンス化されているタイミングでなければなりません。Loadedイベントなどで発動するようにしてもその前なので上手くいかないようです。Window.ContentRenderedイベントに合わせて使えば上手くいきますが、例えばUserControl内での使用などではこのイベントが使えないので一苦労します。

こんな癖つよシステムは使っていられないとのことで、試行錯誤のすえ、今回のバージョンでは以下のような形になりました。

<ListView ItemsSource="{Binding People}" lv:SelectedItemsSync.Source="{Binding People.SelectedItemsSetter}" >
    <ListView.View>
        <GridView>
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name}" Header="Name" />
            <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Pronunciation}" Header="Pronunciation" />
            <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" Header="Height" />
            <GridView.ColumnHeaderContainerStyle>
                <Style TargetType="lv:SortableGridViewColumnHeader">
                    <Setter Property="SortingArrowLocation" Value="Top" />
                </Style>
            </GridView.ColumnHeaderContainerStyle>
        </GridView>
    </ListView.View>
    <ListView.ItemContainerStyle>
        <Style TargetType="ListViewItem">
            <Setter Property="ContextMenu">
                <Setter.Value>
                    <ContextMenu>
                        <MenuItem Header="Increment the age" Command="{Binding IncrementAgeCommand}" />
                        <MenuItem Header="Decrement the age" Command="{Binding DecrementAgeCommand}" />
                    </ContextMenu>
                </Setter.Value>
            </Setter>
            <!--<Setter Property="lv:DoubleClickBehavior.Command" Value="{Binding DoubleClickCommand}" />-->
            <Setter Property="lv:DoubleClickBehavior.MethodTarget" Value="{Binding}" />
            <Setter Property="lv:DoubleClickBehavior.MethodName" Value="DoubleClicked" />
        </Style>
    </ListView.ItemContainerStyle>
</ListView>

はい、超シンプルです。ListViewに添付プロパティでSelectedItemsSetterを登録するだけです。わかりやすいし、今までのようにViewModelで特別な処理を入れる必要もありませんし、タイミングを選ぶなどといったこともありません。

中身的にはWPFシステムのバインディングではなく、独自のバインディングシステムを使っています。すなわち、ListView.SelectedItemsとPeople.SelectedItemsSetterは別インスタンスで、裏で中身を同期する仕組みを作って動かしています。そのため、今までのSelectedItemsSetterとはプロパティの形態が変わり、以前バージョンとの互換性はなくなっています。

それ以外の使い方は今までのバージョンと合わせています。

2024年9月3日火曜日

ListView Extensions ver.1.2.0

ListView Extensions ver.1.2.0をリリースしました。

Github:

Nuget:
https://www.nuget.org/packages/ListViewExtensions/

今回の変更点

  • IReadOnlySortableObservableCollectionインターフェースとReadOnlySortableObservableCollectionクラスを追加
    • ISortableObservableCollectionインターフェースはIReadOnlySortableObservableCollectionインターフェースを継承するようにした
    • ListViewViewModelのコンストラクタに与えるソースコレクションをIReadOnlySortableObservableCollectionにした
  • ListViewViewModelに単一の型引数を取るオーバーロードを追加

主な変更点を説明していきます。

IReadOnlySortableObservableCollectionインターフェースとReadOnlySortableObservableCollectionクラスを追加

もともとSortableObservableCollectionはMVVMのModelで使うことを想定していますが、今まではReadOnlyがありませんでした。MVVMパターンでは、Modelでは読み取り専用のコレクションを公開し、書き換えは別のメソッドなどを介してやることが多いので、今までのReadOnlyが無い環境では少し不便でした。

そこで、読み取り専用のソート可能ObservableCollectionとしてReadOnlySortableObservableCollectionを追加しました。ReadOnlyですがソートはできるので、SortやMoveなどのメソッドも動きます。そうした場合、ソースコレクションに対してSort / Moveを行うという形になり、結果的にソースコレクションに影響を与えることができます。ReadOnlyなのにそれはどうなのかと少し思いましたが、まあ、Sortableと言ってるから割り切ってくれということで。

これに伴って、ISortableObservableCollectionインターフェイスはIReadOnlySortableObservableCollectionを継承する形にしました。この辺で破壊的変更をしているのでバージョンの2桁目を上げています。 

また、ListViewViewModelもIReadOnlySortableObservableCollectionを受け取るようにしています。SortableObservableCollectionもIReadOnlySortableObservableCollectionを実装していますので、今までと使い勝手は変わりないでしょう。ただし、ListVIewViewModelのRemoveSelectedItemCommandはReadOnlyだと失敗します(InvalidOperationExceptionを吐きます)。

ListViewViewModelに単一の型引数を取るオーバーロードを追加

ListViewViewModelは、要素の型をModelとViewModelで変換できるよう2つの型引数を取るものしか今までありませんでしたが、変換が不要な際は記述が冗長だったので、1つの型引数を取るオーバーロードを追加しています。

ただし、ListViewViewModelは同じ参照の要素を2つ以上設定すると例外を吐きます。これは、同じ参照の要素が2つ以上あるとSelectedItemsでどちらが選択された項目か区別がつかないからです。そういうことになりうる場合は、少し面倒ですが、ラッピングするViewModel型を作ってnewするようにしてください。あくまでもお作法です。

***

以上です。最近投稿頻度が上がっている気がする…。

2024年8月31日土曜日

ListView Extensions ver.1.1.0

唐突ですがListView Extensions ver.1.1.0をリリースしました。実に前回の更新から6年ぶりです。

Github:

Nuget:
https://www.nuget.org/packages/ListViewExtensions/

今回の変更点

  • Githubでソースコードを公開
  • ライセンスをMITライセンスに変更
  • ターゲットを.NET Framework 4.5.2 / .NET Core 3.1 / .NET 6に変更
  • ListViewのヘッダーサポートを強化
    • SortableGridViewColumnHeader、SortableGridViewColumnを追加
    • SortedHeaderをObsolete指定にした
  • ISortableObservableCollectionのSortメソッドの引数を変更、古いメソッドはObsolete指定にした
  • コードのリファクタリング、Nullableの有効化、単体テストの追加など

主な変更点を説明していきます。

Githubで公開 / MITライセンス化

最近久しぶりにこのListView Extensionsをいじろうとしたとき、そういえばはコードを公開していなかったなと思ってGithubで公開することにしました。併せてライセンスもMITに変更しています(今までは明記なし)。すっかり私もオープンソースの人(?)になってきました。

ターゲットを.NET Framework 4.5.2 / .NET Core 3.1 / .NET 6に変更

直前のver.1.0.1 では.NET Framework 4.5のみでした。これが.NET系統にも対応するようにしました。実はこれが今回のアップデートの大きなモチベーションだったりします。

ListViewのヘッダーサポートを強化

ListViewのヘッダーを簡単に作成

今までのListView Extensionsではヘッダーの記述がとても冗長でした。

<ListView ItemsSource="{Binding People}" >
    <ListView.Resources>
        <lv:SortingConditionConverter x:Key="ConditionToDirectionConverter" />
    </ListView.Resources>
    <ListView.View>
        <GridView>
            <GridViewColumn Width="120" DisplayMemberBinding="{Binding Name}">
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Name" >
                    <lv:SortedHeader Content="Name" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Name'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="150" DisplayMemberBinding="{Binding Pronunciation}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Pronunciation" >
                    <lv:SortedHeader Content="Pronunciation" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Pronunciation'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="70" DisplayMemberBinding="{Binding Age}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Age" >
                    <lv:SortedHeader Content="Age" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Age'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="120" DisplayMemberBinding="{Binding Birthday}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Birthday" >
                    <lv:SortedHeader Content="Birthday" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Birthday'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="100" DisplayMemberBinding="{Binding Height}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Height_cm" >
                    <lv:SortedHeader Content="Height" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Height_cm'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
        </GridView>
    </ListView.View>
    <!-- 中略 -->
</ListView>

これはサンプルコードの抜粋ですが、ListViewで1列作るのに5行もコードが必要でした。そして似たような記述も繰り返し行われとても冗長です。XAMLの構造、というよりもどういう仕組みになっているかはこの状態でわかりやすいんですがね。

これを、今回のバージョンでは以下のように書き換えることができるようになりました。

<ListView ItemsSource="{Binding People}" >
    <ListView.View>
        <GridView>
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name}" Header="Name" />
            <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Pronunciation}" Header="Pronunciation" />
            <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" Header="Height" />
        </GridView>
    </ListView.View>
    <!-- 中略 -->
</ListView>

だいぶすっきりしました。

このSortableGridViewColumnはGridViewColumnを継承して作られたものですが、少しトリッキーです。上のコードは下のコードとほぼ同等です。

<ListView ItemsSource="{Binding People}" >
    <ListView.Resources>
        <lv:SortingConditionConverter x:Key="ConditionToDirectionConverter" />
    </ListView.Resources>
    <ListView.View>
        <GridView>
            <GridViewColumn Width="120" DisplayMemberBinding="{Binding Name}">
                <lv:SortableGridViewColumnHeader Content="Name" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Name'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Name"/>
            </GridViewColumn>
            <GridViewColumn Width="150" DisplayMemberBinding="{Binding Pronunciation}" >
                <lv:SortableGridViewColumnHeader Content="Pronunciation" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Pronunciation'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Pronunciation"/>
            </GridViewColumn>
            <GridViewColumn Width="70" DisplayMemberBinding="{Binding Age}" >
                <lv:SortableGridViewColumnHeader Content="Age" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Age'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Age"/>
            </GridViewColumn>
            <GridViewColumn Width="120" DisplayMemberBinding="{Binding Birthday}" >
                <lv:SortableGridViewColumnHeader Content="Birthday" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Birthday'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Birthday"/>
            </GridViewColumn>
            <GridViewColumn Width="100" DisplayMemberBinding="{Binding Height}" >
                <lv:SortableGridViewColumnHeader Content="Height" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Height'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Height"/>
            </GridViewColumn>
        </GridView>
    </ListView.View>
    <!-- 中略 -->
</ListView>

SortableGridViewColumnHeaderはGridViewColumnHeaderを継承して作られたもので、GridViewColumnHeaderに並び替えのアイコン「▲」「▼」を表示する機能を追加したものです。SortingDirectionプロパティに並び替えの向きを指定することでアイコンが表示されます。

SortableGridViewColumnは、HeaderプロパティがSortableGridViewColumnHeaderではない場合に自動的にSortableGridViewColumnHeaderを作ってそのインスタンスにHeaderプロパティに入っていたものを渡しますます。そして、そのSortableGridViewColumnHeaderのSortingDirectionプロパティ、Command / CommandParameterプロパティにSortableSourceプロパティにバインディングされたオブジェクトのプロパティを自動的にバインディングします。ソートのキーにはDisplayMemberBindingの値を使うので、万が一DisplayMemberBindingの値と異なるプロパティをソートのキーにする必要がある場合はGridViewColumnHeaderを直接触る必要があります。

これらの機能が追加されたことによって、SortedHeaderはObsolete扱いとなりました。

ListViewのヘッダーをカスタマイズ① - 並び替え矢印の位置を変更

SortableGridViewColumnはSortingArrowLocationプロパティを持っています。これはDock列挙型になっているので、上下左右好きな場所に配置することができます。直接プロパティを触っても良いのですが、GridView.ColumnHeaderContainerStyleプロパティによる一括スタイル指定をするのが良いでしょう。

<ListView ItemsSource="{Binding People}" >
    <ListView.View>
        <GridView>
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name}" Header="Name" />
            <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Pronunciation}" Header="Pronunciation" />
            <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" Header="Height" />
            <GridView.ColumnHeaderContainerStyle>
                <Style TargetType="lv:SortableGridViewColumnHeader">
                    <Setter Property="SortingArrowLocation" Value="Top" />
                </Style>
            </GridView.ColumnHeaderContainerStyle>
        </GridView>
    </ListView.View>
    <!-- 中略 -->
</ListView>

↓Top

Top

↓Right

Right

画像ではTopとRightの例を出していますが、LeftとBottomを指定することもできます。あまり馴染みのないデザインかとは思いますが。

あとはSortingArrowMarginプロパティを使えばArrowの周囲のMarginを設定することができるので、「Name」などのヘッダーテキストの間隔を調整したいときに使えます。

ListViewのヘッダーをカスタマイズ② - 並び替え矢印をカスタマイズ

ListViewのヘッダーの矢印は専用のクラス「AscendingArrow」「DescendingArrow」で表現されています。ですので、このクラスのTemplateを丸々置き換えてしまうことで矢印の見た目をカスタマイズすることができます。

<ListView ItemsSource="{Binding People}" >
    <ListView.Resources>
        <Style TargetType="lv:AscendingArrow">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate>
                        <Polyline Points="0,5 5,0,10,5" StrokeThickness="1" Stroke="Black" />
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
        <Style TargetType="lv:DescendingArrow">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate>
                        <Polyline Points="0,0 5,5,10,0" StrokeThickness="1" Stroke="Black" />
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
    </ListView.Resources>
    <ListView.View>
        <GridView>
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name}" Header="Name" />
            <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Pronunciation}" Header="Pronunciation" />
            <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" Header="Height" />
            <GridView.ColumnHeaderContainerStyle>
                <Style TargetType="lv:SortableGridViewColumnHeader">
                    <Setter Property="SortingArrowLocation" Value="Top" />
                </Style>
            </GridView.ColumnHeaderContainerStyle>
        </GridView>
    </ListView.View>
    <!-- 中略 -->
</ListView>

このように全く違う見た目になりました。これで、仮に私のデザインした▲が気に入らない人がいたとしても、クレームを受けることなく「自分で好きに変えてください」と言えるようになりました。こういった表現力の豊かさがさすがWPFという感じですね。

ISortableObservableCollectionのSortメソッドの引数を変更

ISortableObservableCollectionのSortメソッドの形が少し変わっています。破壊的変更です。

public interface ISortableObservableCollection<T> : IList<T>, INotifyPropertyChanged, INotifyCollectionChanged
{
    // 中略

    [Obsolete]
    void Sort(string propertyName, SortingDirection direction);

    /// <summary>
    /// 自身をソートするメソッド
    /// </summary>
    /// <param name="direction">ソート方向。Noneの場合は何もしない。</param>
    /// <param name="propertyName">ソートに使用するプロパティ名。Nullの場合は要素自身をキーとしてソート。</param>
    void Sort(SortingDirection direction, string? propertyName = null);
}

まあ、直接SortableObservableCollectionからソートを手動でかけていない限り影響は無いとは思いますが、注意してください。Obsoleteの警告が出てもパラメーターの順序を入れ替えるだけでOKです。

この変更は、propertyNameをnullでも受け取れるようにしたところにあります。nullにすると要素自身をキーにして並び替えることができます。SortableObservableCollection<int>などの場合を想定しています。

***

以上で今回の変更分の説明は終わりです。あまり使用面で影響のない修正は割愛させてもらいます。

2024年8月23日金曜日

Prism + DryIoc 入門②(お作法編)

どんなフレームワークにも決まったお作法というか、こう使ってほしいという想定があります。逆に言えば、それをマスターすることがそのフレームワークを自由自在に使いこなせるようになる近道というわけです。今回はそのようなPrismのお作法を紹介していきます。

1. プロジェクトテンプレート

Prismを使ったWPFアプリを作る場合は、プロジェクトテンプレートを使いましょう。Visual Studio 2022の場合は、メニューの「拡張機能」→「拡張機能の管理」からPrismと検索するとPrism Template Packが出てきてインストールすることができます。

インストールをすると、プロジェクトの新規作成からPrismを選択できるようになります。今回は「Prism Blank App (WPF)」を選択します。

そうするといつも通りプロジェクトの名前と保存名が聞かれるので適当に入力します。

ここで「作成」を押すと、PrismのDIコンテナを選択するダイアログが出てきますので、「DryIoc」を選択します。

これにて無事Prism+DryIocのプロジェクトが出来上がりました。

最後にお好みで、.NETのバージョンを最新に上げたり、Nullableを有効化したり、Nugetからパッケージを最新にしたりしておきましょう。ビルドすると空のWPFアプリが立ち上がるはずです。

ちなみに、Prismはバージョン8まではMITライセンスですが、バージョン9からはPrism Community License / Prism Commercial Licenseという独自の商用ライセンスに切り替わっているようです。バージョンアップには要注意です。

2. 領域(Region)の作成と配置

ウィンドウにUI要素(コントロールなど)を配置するとき、MainWindowに直接配置することもできますが、もう少し小さいパーツ単位でUI要素を作って配置したいときがあると思います。そうしないとMainWindowに全部入りになってしまいますもんね。そんな時にはRegionと呼ばれるものを作ると良いです。

ちなみに、Regionの実体はただのUserControlです。 新しい項目の追加から、Prismテンプレートの"Prism UserControl (WPF)"を選択します。

今回は、MainWindowの左半分と右半分のRegionということで、LeftRegionとRightRegionの2つを追加しました。UserControlを新規作成すると、対応するViewModelも自動的に作ってくれます。

MainWindow.xamlでは次のようにContentControlにてRegionを配置します。

<Window x:Class="PrismTest.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:prism="http://prismlibrary.com/"
        prism:ViewModelLocator.AutoWireViewModel="True"
        Title="{Binding Title}" Height="350" Width="525" >
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="1*" />
            <ColumnDefinition Width="1*" />
        </Grid.ColumnDefinitions>

        <ContentControl Grid.Column="0" prism:RegionManager.RegionName="LeftRegion" />
        <ContentControl Grid.Column="1" prism:RegionManager.RegionName="RightRegion" />
    </Grid>
</Window>

これだけでは実は不十分で、Region名と実際のUserControl型を紐づけなければなりません。

public class MainWindowViewModel : BindableBase
{
    private string _title = "Prism Application";
    public string Title
    {
        get { return _title; }
        set { SetProperty(ref _title, value); }
    }

    public MainWindowViewModel(IRegionManager regionManager)
    {
        regionManager.RegisterViewWithRegion("LeftRegion", typeof(LeftRegion));
        regionManager.RegisterViewWithRegion("RightRegion", typeof(RightRegion));
    }
}

MainWindowViewModelのコンストラクタにIRegionManager型の引数を設定します。こうするとDIコンテナが良い感じにIRegionManagerの実体を割り振ってくれるので、ここでRegisterViewWithRegionメソッドを呼び出してRegion名と実際のUserControl型を結び付けておきます。

これでこのようにRegionが所定の場所に割り付けられるようになりました。

ちなみに上の画像に"Left Region"と"Right Region"と表示しているのは、空っぽだと見た目でよくわからないので、そういうコードを追加したからです。

<UserControl x:Class="PrismTest.Views.LeftRegion"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:prism="http://prismlibrary.com/"             
             prism:ViewModelLocator.AutoWireViewModel="True">
    <Border BorderThickness="1" BorderBrush="Black" >
        <TextBlock Text="Left Region" HorizontalAlignment="Center" VerticalAlignment="Center" />
    </Border>
</UserControl>
<UserControl x:Class="PrismTest.Views.RightRegion"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:prism="http://prismlibrary.com/"             
             prism:ViewModelLocator.AutoWireViewModel="True">
    <Border BorderThickness="1" BorderBrush="Black" >
        <TextBlock Text="Right Region" HorizontalAlignment="Center" VerticalAlignment="Center" />
    </Border>
</UserControl>

3. デザイン時のデータ

実装を進めているとあることに気が付きます。ViewのXAMLを編集する際、ViewModelのプロパティのサジェストがVisual Studioで働かないのです。これはViewModelLocator.AutoWireViewModelにてViewModelが実行時に動的に紐づけられるからで、XAMLにはどこにもViewModelの型を明記していないからです。それはさすがにサジェストが働かなくて当然ですね。

このようなときに便利なのがデザイン時のデータという機能です。

一番大元の要素に以下のような呪文を追加します。

<Window x:Class="PrismTest.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:prism="http://prismlibrary.com/"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:vm="clr-namespace:PrismTest.ViewModels"
        mc:Ignorable="d"
        d:DataContext="{d:DesignInstance Type=vm:MainWindowViewModel}"
        prism:ViewModelLocator.AutoWireViewModel="True"
        Title="{Binding Title}" Height="350" Width="525" >
    <!-- 中略 -->
</Window>

5から9行目が呪文部分です。これを入力することによって、Visual Studioでのデザイン時のみDataContextをMainWindowViewModelとみなすことができ、プロパティなどのサジェストが働くようになります。

4. インターフェースとクラスのマッピング

最後にインターフェースとクラスのマッピングです。前回の記事でも説明した通り、DIを使用した実装では、各クラス間の依存性を抑えるため、コンストラクタでインターフェースを受け取るようにし、DIコンテナがそれに対応するクラスをインスタンス化して渡してくれます。

それを実現するためには、インターフェースとクラスの対応付けをあらかじめ設定しておく必要があります。その設定は、App.xaml.cs内で記述することになります。

public partial class App
{
    protected override Window CreateShell()
    {
        return Container.Resolve<MainWindow>();
    }

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {
        containerRegistry.Register<IMainWindowModel, MainWindowModel>();
    }
}

RegisterTypesメソッドをオーバーライドして、そのなかでRegisterメソッドを呼び出すことでインターフェースとクラスのマッピングをすることができます。

public class MainWindowViewModel : BindableBase
{
    private string _title = "Prism Application";
    public string Title
    {
        get { return _title; }
        set { SetProperty(ref _title, value); }
    }

    public MainWindowViewModel(IRegionManager regionManager, IMainWindowModel model)
    {
        regionManager.RegisterViewWithRegion("LeftRegion", typeof(LeftRegion));
        regionManager.RegisterViewWithRegion("RightRegion", typeof(RightRegion));

        Title = model.Title;
    }
}

マッピングしてさえいれば、コンストラクタに引数を追加することで、DIコンテナにて対応するインスタンスを作ってくれます。

なお、グローバルなデータ、設定値などはRegisterSingletonメソッドでDIコンテナに登録することによって、同じインターフェースには唯一のインスタンス(実体が同じもの)が返されるようになります。

***

以上で必要最低限のお作法は紹介できたはずです。 ここから自分の作りたいアプリをどんどん実装していきましょう。

2024年8月14日水曜日

Prism + DryIoc 入門①(DI編)

このブログを普段から読んでくださっている方(どれくらいいるかわかりませんが…)なら、私はMVVMフレームワークとしてLivetを使っていることはよくご存じと思います。ですが、ついにPrismにも手を出してみようと思い、最近Prismを使い始めましたので、その内容をブログに残しておこうと思います。

今回はなぜPrismを使う必要があるのか、DI (Dependency Injection)の対応という観点から説明をしていきたいと思います。

DI (Dependency Injection)とは

DI (Dependency Injection)とは日本語で「依存性の注入」とか言われたりするそうです。ここで言う依存性とは、クラス間の依存性のことです。クラスA内でクラスBを使用していれば、クラスAはクラスBに依存しているということになります。DIでは、それをクラスA内部にクラスBを使った実装を直接書くのではなく、もう少し抽象化して実装して、クラスBを使うということを後から外部から注入できるようにしようとものです。

なぜそんなことをするかというと、ひとえに単体テストをしやすくするためです。クラスA内でクラスBとクラスCとクラスDを使用し…となっていると、クラスAのテストをしたいのにクラスB, C, Dすべて用意してテストしなければならなくなってきます。クラスBがエラーを起こした時の処理をテストしたいけど、クラスBにエラーを起こさせるのが難しいとかいうことがあるかもしれません。もしくはクラスCはサーバーと通信していて、いろいろなパターンを自動でテストしているとサーバーに負担がかかるとか通信で時間がかかるとかがあるかもしれません。ただ単にクラスA内のコードが正しく動くかを試験したいだけなのに、それではテストがとてもしにくいですよね。

そこで、クラスAの実装時にはクラスB, C, Dに対応したインターフェースのみをコンストラクタで受け取って使い、クラスB, C, Dそのものには触れないようにします。そして、実行時にコンストラクタに本物のクラスB, C, Dのインスタンスを与えてあげるという実装のしかたをすれば、テストのときはダミーのクラスB, C, Dを渡してあげることで本物のクラスB, C, Dには依存しないテストをすることができるようになります。これが「Dependency Injection」という考え方です。

LivetでMVVMを実装した場合(DI無し)

さて、LivetはDIをサポートしていませんが、LivetでMVVMなソフトを作った場合どのようになって何が問題になるでしょうか。以下の画像のようにテキストボックスに入力した文字を大文字に変換して表示するソフトを例に考えてみましょう。

これをLivetで実装した場合、おおむね以下のような感じになるかと思います。

<Window x:Class="LivetTest.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
        xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
        xmlns:v="clr-namespace:LivetTest.Views"
        xmlns:vm="clr-namespace:LivetTest.ViewModels"
        Title="Livet Test" Width="525" Height="350">

    <Window.DataContext>
        <vm:MainWindowViewModel />
    </Window.DataContext>

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="1*" />
        </Grid.RowDefinitions>
        
        <TextBox Grid.Row="0" Text="{Binding InputText, UpdateSourceTrigger=PropertyChanged}" />
        <TextBlock Grid.Row="1" Text="{Binding OutputText}" />
    </Grid>
</Window>
public class MainWindowViewModel : ViewModel
{
    readonly MainWindowModel model;

    public MainWindowViewModel()
    {
        model = new MainWindowModel();

        model.PropertyChanged += Model_PropertyChanged;
        PropertyChanged += This_PropertyChanged;
    }

    public string InputText
    {
        get => _InputText;
        set
        { 
            if(_InputText == value)
                return;
            _InputText = value;
            RaisePropertyChanged();
        }
    }
    private string _InputText;

    public string OutputText
    {
        get => _OutputText;
        private set
        { 
            if(_OutputText == value)
                return;
            _OutputText = value;
            RaisePropertyChanged();
        }
    }
    private string _OutputText;

    private void Model_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(model.OutputText):
                OutputText = model.OutputText;
                break;
        }
    }

    private void This_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(InputText):
                model.InputText = InputText;
                break;
        }
    }
}
public class MainWindowModel : NotificationObject
{
    public MainWindowModel()
    {
        PropertyChanged += This_PropertyChanged;
    }

    public string InputText
    {
        get => _InputText;
        set
        {
            if(_InputText == value)
                return;
            _InputText = value;
            RaisePropertyChanged();
        }
    }
    private string _InputText;

    public string OutputText
    {
        get => _OutputText;
        private set
        {
            if(_OutputText == value)
                return;
            _OutputText = value;
            RaisePropertyChanged();
        }
    }
    private string _OutputText;

    private void This_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(InputText):
                OutputText = InputText.ToUpper();
                break;
        }
    }
}

少し長いですが、重要なのはMainWindow.xaml内で

<Window.DataContext>
    <vm:MainWindowViewModel />
</Window.DataContext>

と名指しでMainWindowViewModelをインスタンス化しているところ、およびMainWindowViewModel.cs内で

readonly MainWindowModel model;

public MainWindowViewModel()
{
    model = new MainWindowModel();

    // 中略
}

と名指しでMainWindowModelをインスタンス化しているところです。MainWindow.xamlをテストするにはMainWindowViewModelの本物が必要で、MainWindowViewModelのテストをするにはMainWindowModelの本物が必要になります。これでは単体テストと呼べず、ただの結合テストになってしまいますね。

PrismでMVVMを実装した場合(DryIoc)

Prismは標準でDIをサポートしていますが、Prismで直接DIコンテナが実装されているわけではなく、別のライブラリを参照する形となっています。WPFではUnityとDryIocが使えるようですが、ここではDryIocを使います。DryIocを選んだ理由としては、こちらのほうが速度が速く、かつ、UnityはC#がよく使われているゲームライブラリと名前が同じで検索汚染がひどく、目的の情報にたどり着きにくいからです。

さて、先ほどのLivetと同様にPrism+DryIocで実装すると以下のようになります。

<Window x:Class="PrismTest.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:prism="http://prismlibrary.com/"
        prism:ViewModelLocator.AutoWireViewModel="True"
        Title="Prism Test" Height="350" Width="525" >

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="1*" />
        </Grid.RowDefinitions>

        <TextBox Grid.Row="0" Text="{Binding InputText, UpdateSourceTrigger=PropertyChanged}" />
        <TextBlock Grid.Row="1" Text="{Binding OutputText}" />
    </Grid>
</Window>
public class MainWindowViewModel : BindableBase
{
    readonly IMainWindowModel model;

    public MainWindowViewModel(IMainWindowModel model)
    {
        this.model = model;

        model.PropertyChanged += Model_PropertyChanged;
        PropertyChanged += This_PropertyChanged;
    }

    public string InputText
    {
        get => _InputText;
        set
        {
            if(_InputText == value)
                return;
            _InputText = value;
            RaisePropertyChanged();
        }
    }
    private string _InputText;

    public string OutputText
    {
        get => _OutputText;
        private set
        {
            if(_OutputText == value)
                return;
            _OutputText = value;
            RaisePropertyChanged();
        }
    }
    private string _OutputText;

    private void Model_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(model.OutputText):
                OutputText = model.OutputText;
                break;
        }
    }

    private void This_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(InputText):
                model.InputText = InputText;
                break;
        }
    }
}
public interface IMainWindowModel : INotifyPropertyChanged
{
    string InputText { get; set; }

    string OutputText { get; }
}
    public class MainWindowModel : BindableBase, IMainWindowModel
    {
        public MainWindowModel()
        {
            PropertyChanged += This_PropertyChanged;
        }

        public string InputText
        {
            get => _InputText;
            set
            {
                if(_InputText == value)
                    return;
                _InputText = value;
                RaisePropertyChanged();
            }
        }
        private string _InputText;

        public string OutputText
        {
            get => _OutputText;
            private set
            {
                if(_OutputText == value)
                    return;
                _OutputText = value;
                RaisePropertyChanged();
            }
        }
        private string _OutputText;

        private void This_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
        {
            switch(e.PropertyName) {
                case nameof(InputText):
                    OutputText = InputText.ToUpper();
                    break;
            }
        }
    }
public partial class App
{
    protected override Window CreateShell()
    {
        return Container.Resolve<MainWindow>();
    }

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {
        containerRegistry.Register<IMainWindowModel, MainWindowModel>();
    }
}

こんな感じになります。注目していただきたいところは、

prism:ViewModelLocator.AutoWireViewModel="True"

と書いてあるところと、

readonly IMainWindowModel model;

public MainWindowViewModel(IMainWindowModel model)
{
    this.model = model;

    // 中略
}

と書いてあるところです。

Viewでは、Windowに添付プロパティとしてViewModelLocator.AutoWireViewModelを追加することができます。これをTrueにすると、Windowのクラス名の末尾に「ViewModel」を付けたクラスをそのViewに対応したViewModelとして認識し、自動的にDataContextプロパティにそれをインスタンス化してくっつけてくれるようになります。敢えて名指しでViewModelを指定しなくて良くなるわけです。

ViewModelでは、直接内部でModelをインスタンス化する代わりに、コンストラクタの引数でIMainWindowModelというインターフェースを受け取っています。IMainWindowModelインターフェースはその名の通りMainWindowModelのpublicなプロパティのみを抜き出したインターフェースですが、こうすることによって、本番ではMainWindowModelのインスタンスをコンストラクタでに渡してあげれば良いですし、単体テストをするときはIMainWindowModelを実装した適当なダミーのクラスを渡してあげればテストができるようになります。こうすることで、ViewModelを動かす際に対応するModelが必須ではなくなり(=依存性が弱まり)、テストの自由度が上がるわけです。

ちなみに、実際にMainWindowModelをインスタンス化するところを我々が自分で実装することは無く、DIコンテナが良い感じにインスタンス化してくれます。その際に、MainWindowModelのコンストラクタに必要なインスタンスを作って渡してくれるのですが、あらかじめ「IMainWindowModelの本番実装はMainWindowModelだから、IMainWindowModelが出てきたらMainWindowModelをインスタンス化して渡してあげてね」と教えてあげなければなりません。そのことは、App.xaml.cs内に記述するようになっています。

まとめ

ここまで、Prismを使うにあたって必要なDIの基本的な考え方を説明してきました。今までは実装全体でしかテストできなかったものが、パーツごとに分けてテストができるようになるというのはとても良いですね。

ただ、その「依存性を減らす」という目的と裏表ですが、DIはコードの見通しが悪くなるのが欠点です。上の例だとIMainWindowModelの本番実装が何かというのはVisual StudioのF12キーだけで追いかけるのは難しく、App.xaml.csの中身を能動的に見に行かなければならなくなります。自分で実装したのならそういうマナーはわかっていますが、世の中に転がっている任意の流儀で実装されたDIのコードを追いかけるのは至難の業です…。

ちなみに、今回の記事で「LivetはDIに対応していない」と何度も書きましたが、LivetがPrismに対して劣っているという意図はありません。次回の記事くらいで出てくるかもしれませんが、LivetはできるけどPrismができないことというのもあります。ですので、実際のアプリ開発においては、PrismとLivetの二者択一ではなく、PrismとLivetの併用で作っていくことになると思います。その辺の話もまたそのうち記事にできればなと思っています。