備忘録的なSomething

素敵なAnything

【C#,WPF,MVVM】DataGridとかで SelectedItems をコマンドで使う簡単な方法

DataGrid, ListView, ListBox などの ItemsControl で複数選択したとき、選択結果は SelectedItems に入っていますが、残念なことにこのプロパティは Binding 非対応です。
バインディングしたい場合は IsSelected プロパティを用意するなど面倒な手順が必要になりますが、実はコマンドで取得するだけなら簡単にできます。

やり方:

  • DataGrid 等には x:Name をつけておきます。
        <DataGrid x:Name="dataGridName"
                  ItemsSource="{Binding Items}" />
  • 使用するコマンドの CommandParameter に、DataGrid 等の SelectedItems を渡します。
        <Button Content="選択"
                Command="{Binding SelectCommand}"
                CommandParameter="{Binding ElementName=dataGridName, Path=SelectedItems}" />
  • VMのコマンド実装では、parameter を受け取って、SelectedItems の型である IList にキャストしてから、中身を項目の型にキャストして使います。
    (以下はコマンド実装に ReactiveProperty の ReactiveCommand を使用し、各項目の型は string である場合の例です)
    なお、IList はusing System.Collections;で使用できます。(なぜか Ctrl+. を押しても出てこない・・・)
            SelectCommand.Subscribe(parameter =>
            {
                var selectedItems = ((IList)parameter).Cast<string>().ToArray();
                // 以下、selectedItems を使う処理
            });

サンプルコード

このサンプルでは、string のリストから複数項目を選択してボタンを押すと、選択された項目群を表示します。
MVVMフレームワークとして ReactiveProperty を使用しています。

xaml(Margin等の指定は省略)

<Window x:Class="WpfTestApp.Windows.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" SizeToContent="WidthAndHeight" ResizeMode="NoResize">
    <StackPanel>
        <DataGrid x:Name="dataGridName"
                  ItemsSource="{Binding Items}"
                  AutoGenerateColumns="False">
            <DataGrid.Columns>
                <DataGridTextColumn Header="世界" Binding="{Binding}" />
            </DataGrid.Columns>
        </DataGrid>
        <Button Content="選択"
                Command="{Binding SelectCommand}"
                CommandParameter="{Binding ElementName=dataGridName, Path=SelectedItems}" />
    </StackPanel>
</Window>

VM

    public class MainWindowViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        public List<string> Items { get; } = new List<string>() { "Asgard", "Alfheim", "Jotunheim", "Midgard", "Muspelheim", "Nidavellir", "Niflheim", "Svartalfheim", "Vanaheim", };
        public ReactiveCommand SelectCommand { get; } = new ReactiveCommand();

        // コンストラクタ
        public MainWindowViewModel()
        {
            SelectCommand.Subscribe(parameter =>
            {
                var selectedItems = ((IList)parameter).Cast<string>().ToArray();
                if (selectedItems.Any())
                {
                    MessageBox.Show($"あなたは {string.Join(" と ", selectedItems)} を選択しました。");
                }
            });
        }
    }

実行結果



【C#,WPF,MVVM】DataGridとかの項目をフィルタリングして表示する

DataGrid や ListView などの ItemsControl に対して、検索結果等で絞り込んで表示したいときは、CollectionViewSource のフィルタリング機能が使用できます。

やり方:

  • VMに CollectionViewSource のプロパティを定義します。
public CollectionViewSource ItemsView { get; } = new CollectionViewSource();
  • xaml では、DataGrid 等の ItemsSrouce に CollectionViewSource の View プロパティをバインドします。
<DataGrid ItemsSource="{Binding ItemsView.View}" />
  • VMのコンストラクタ等で、CollectionViewSource の Source と View.Filter を設定します。
    View.Filter には、object(Source に入れたアイテムの型にキャスト可)を受け取って、表示するかどうかをboolで返す処理を指定します。
    (以下では関数を作成して指定しています)
        // コンストラクタ
        public MainWindowViewModel()
        {
            ItemsView.Source = new[] { "アイテム1", "アイテム2", "アイテム3", };
            ItemsView.View.Filter = ItemsViewFilter;
        }

        // フィルタリング関数
        private bool ItemsViewFilter(object obj)
        {
            var item = obj as string;
            if (表示する条件)
            {
                return true;
            }
            else
            {
                return false;
            }
        }
  • 検索ボタンが押されたタイミングなど、フィルタリングを実行するときはビューの Refresh() 関数を呼びます。
ItemsView.View.Refresh();

以下の記事で紹介した方法と組み合わせると、Enterキーを押したときにフィルタリングをかけることもできます。
【C#,WPF,MVVM】TextBox上でEnterキーが押されたときにコマンドを実行する - 備忘録的なSomething

サンプルコード

このサンプルでは、string のリストから、入力した部分文字列を含む項目を絞り込んで表示します。
MVVMフレームワークとして ReactiveProperty を使用しています。

xaml(Margin等の指定は省略)

<Window x:Class="WpfTestApp.Windows.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" SizeToContent="WidthAndHeight" ResizeMode="NoResize">
    <StackPanel>
        <TextBox Text="{Binding SearchText.Value}" />
        <Button Content="検索" Command="{Binding SearchCommand}" />
        <DataGrid ItemsSource="{Binding ItemsView.View}"
                  AutoGenerateColumns="False"
                  IsSynchronizedWithCurrentItem="False">
            <DataGrid.Columns>
                <DataGridTextColumn Header="世界" Binding="{Binding}" />
            </DataGrid.Columns>
        </DataGrid>
    </StackPanel>
</Window>

IsSynchronizedWithCurrentItemは、デフォルトの True だとなぜか最初の項目が初期選択されてしまうので、嫌な場合は False を指定します。

VM

    public class MainWindowViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        public ReactivePropertySlim<string> SearchText { get; } = new ReactivePropertySlim<string>();
        public ReactiveCommand SearchCommand { get; } = new ReactiveCommand();
        public CollectionViewSource ItemsView { get; } = new CollectionViewSource();

        // コンストラクタ
        public MainWindowViewModel()
        {
            ItemsView.Source = new[] { "Asgard", "Alfheim", "Jotunheim", "Midgard", "Muspelheim", "Nidavellir", "Niflheim", "Svartalfheim", "Vanaheim", };
            ItemsView.View.Filter = ItemsViewFilter;

            SearchCommand.Subscribe(() => ItemsView.View.Refresh());
        }

        // フィルタリング関数
        private bool ItemsViewFilter(object obj)
        {
            if (string.IsNullOrEmpty(SearchText.Value))
            {
                return true;
            }

            var item = obj as string;
            return item.Contains(SearchText.Value);
        }
    }

実行結果

f:id:kmua:20211127164941p:plain
↓(「gard」が含まれる世界を検索)
f:id:kmua:20211127172734p:plain

更新履歴

  • 2021/11/30 IsSynchronizedWithCurrentItem について追記

【C#,WPF,MVVM】TextBox上でEnterキーが押されたときにコマンドを実行する

例えば検索ボックスで、TextBox上でEnterキーが押されたら検索を実行したい、みたいな場合、KeyBinding を使うとキーが押されたときにコマンドを実行できます。
このとき、もちろん検索文字列も欲しいので、入力された文字列を CommandParameter で渡してあげる必要があります。
以下の例では RelativeSource で直上の TextBox (つまり自分の親)の Text プロパティを CommandParameter に渡しています。

        <TextBox Text="{Binding SearchText.Value}">
            <TextBox.InputBindings>
                <KeyBinding Key="Enter"
                            Command="{Binding SearchCommand}"
                            CommandParameter="{Binding Text, RelativeSource={RelativeSource AncestorType={x:Type TextBox}}}" />
            </TextBox.InputBindings>
        </TextBox>

サンプルコード

MVVMフレームワークとして ReactiveProperty を使用しています。

MainWindow.xaml

<Window x:Class="WpfTestApp.Windows.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" SizeToContent="WidthAndHeight" ResizeMode="NoResize">
    <StackPanel>
        <TextBox Text="{Binding SearchText.Value}">
            <TextBox.InputBindings>
                <KeyBinding Key="Enter"
                            Command="{Binding SearchCommand}"
                            CommandParameter="{Binding Text, RelativeSource={RelativeSource AncestorType={x:Type TextBox}}}" />
            </TextBox.InputBindings>
        </TextBox>
        <TextBlock Text="{Binding DisplayText.Value}" />
    </StackPanel>
</Window>

MainWindowViewModel.cs

    public class MainWindowViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        public ReactivePropertySlim<string> SearchText { get; } = new ReactivePropertySlim<string>();
        public ReactivePropertySlim<string> DisplayText { get; } = new ReactivePropertySlim<string>();
        public ReactiveCommand SearchCommand { get; } = new ReactiveCommand();

        public MainWindowViewModel()
        {
            SearchCommand.Subscribe(parameter =>
            {
                var input = parameter as string;

                // ここで検索とかの処理をやる
                DisplayText.Value = input;
            });
        }
    }

実行結果:
f:id:kmua:20211124201636p:plain
↓(Enterキー押下)
f:id:kmua:20211124201655p:plain
※ 見やすさのためMargin等を別途設定しています

参考:

【C#,WPF】MVVMで簡単なコードで画面を開いたり閉じたりする

MVVMの目的は、Model(M)、ViewModel(VM)、View(V) を疎結合にすることです。
このため、View は VM を知っていても許すけど、VM が View を知っていることは許さない。という原則があります。

ここで、子画面をモーダルで開くことを考えてみます。すると、途端に困ってしまうのです。

  • 親画面のVMから子画面を開くとき、Owner として自分の画面を渡せない!
  • 子画面のVMから自分を閉じたいとき、自分の画面が閉じられない!

じゃあ、どうするか。
主流なのは、Messenger パターンを使う方法です。
これは Messenger という第三者に、Message をVMからVに伝達してもらう方法です。具体的には、

  • View には、予め各種 Message を受け取ったときの動作を定義しておく。
  • 子画面を開くには、VM から Messenger を介して ShowDialogMessage を送る。
  • 自分の画面を閉じるには、VM から Messenger を介して CloseDialogMessage を送る。

といった感じの手順になります。でも、ただ子画面を開くだけなのに、あまりにも面倒です。
(Livet や MVVM Light Toolkit あたりのMVVMフレームワークを入れれば Messenger を利用できますが、ReactiveProperty 入れてるのに二重に入れるのもなんだかなー。ブラックボックス化しちゃうのもやだし)

ということで、MVVM的にはグレーゾーンな気がしますが、簡単なコードでできる方法を紹介します。
手順は以下です。

  • VM で、デリゲート型のプロパティを用意する。
  • View のコードビハインドで、用意したプロパティに、View を使う動作をセットする。
  • VM でデリゲートを実行すると、View を使う動作が動く。

具体的につくっていきます。まずは簡単な、自分の画面を閉じる方法から。

  • VM にデリゲート型のプロパティpublic Action CloseAction { get; set; }を定義します。
  • View のコードビハインドで、VM の CloseAction に「画面を閉じる処理」をセットします。
        // 画面のコンストラクタ
        public SubWindow(SubWindowViewModel viewModel)
        {
            InitializeComponent();
            DataContext = viewModel;

            // 画面を閉じる処理をセット
            viewModel.CloseAction = () =>
            {
                DialogResult = true;
                Close();
            };
        }
  • あとは、VMCloseAction()を呼べば、画面が閉じます。

子画面を開くほうも、デリゲートの型が変わるだけであとはほぼ同じです。

  • VM にデリゲート型のプロパティpublic Func<Window, bool?> ShowDialog { get; set; }を定義します。
    デリゲートの Window 型の引数は子画面を渡すのに、bool? 型の戻り値は結果を受け取るのに使います。
  • View のコードビハインドで、VM の ShowDialog に「指定された画面を開いて、結果を返す処理」をセットします。
        // 画面のコンストラクタ
        public MainWindow(MainWindowViewModel viewModel)
        {
            InitializeComponent();
            DataContext = viewModel;

            // 子画面を開く処理をセット
            viewModel.ShowDialog = window =>
            {
                window.Owner = this;
                return window.ShowDialog();
            };
        }
  • あとは、VMShowDialog(子画面のView)を呼べば、子画面を開けます。
                var subWindowVM = new SubWindowViewModel();
                var subWindow = new SubWindow(subWindowVM);
                if (ShowDialog(subWindow) != true)
                {
                    MessageBox.Show("キャンセルボタンまたは右上の閉じるボタンが押されました。");
                    return;
                }

                MessageBox.Show("OKボタンが押されました。");

以上で完了です。

この方法は、子画面を開く・閉じるだけではなく何にでも応用できます。
ただし、例えばFunc<Window>を使って View 自体をVMに公開したりすると、簡単にMVVMが崩壊します。
バインディングやコマンド等の仕組みではどうにもならない場合のみ、用法・用量を守って使うことをおすすめします。

サンプルコード

最後にコード全体を貼っておきます。 これは子画面を作るときのテンプレになります。
(MVVMライブラリとして ReactiveProperty を使用します)

MainWindow.xaml

<Window x:Class="WpfTestApp.Windows.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow"
        SizeToContent="WidthAndHeight"
        ResizeMode="NoResize">
    <StackPanel>
        <Button Content="子画面を開く" Command="{Binding OpenSubWindow}" />
    </StackPanel>
</Window>

MainWindow.xaml.cs

using System.Windows;

namespace WpfTestApp.Windows
{
    public partial class MainWindow : Window
    {
        // 画面のコンストラクタ
        public MainWindow(MainWindowViewModel viewModel)
        {
            InitializeComponent();
            DataContext = viewModel;

            // 子画面を開く処理をセット
            viewModel.ShowDialog = window =>
            {
                window.Owner = this;
                return window.ShowDialog();
            };
        }
    }
}

MainWindowViewModel.cs

using Reactive.Bindings;
using System;
using System.ComponentModel;
using System.Windows;

namespace WpfTestApp.Windows
{
    public class MainWindowViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        // 子画面を開く処理
        public Func<Window, bool?> ShowDialog { get; set; }

        // 子画面を開くコマンド
        public ReactiveCommand OpenSubWindow { get; } = new ReactiveCommand();

        // コンストラクタ
        public MainWindowViewModel()
        {
            OpenSubWindow.Subscribe(() =>
            {
                var subWindowVM = new SubWindowViewModel();
                var subWindow = new SubWindow(subWindowVM);
                if (ShowDialog(subWindow) != true)
                {
                    MessageBox.Show("キャンセルボタンまたは右上の閉じるボタンが押されました。");
                    return;
                }

                MessageBox.Show("OKボタンが押されました。");
            });
        }
    }
}

SubWindow.xaml

<Window x:Class="WpfTestApp.Windows.SubWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="SubWindow"
        SizeToContent="WidthAndHeight"
        ResizeMode="NoResize"
        ShowInTaskbar="False">
    <StackPanel>
        <!-- OK・キャンセル -->
        <StackPanel Orientation="Horizontal">
            <Button Content="OK"
                    IsDefault="True"
                    Command="{Binding OKCommand}"/>
            <Button Content="キャンセル"
                    IsCancel="True"/>
        </StackPanel>
    </StackPanel>
</Window>

SubWindow.xaml.cs

using System.Windows;

namespace WpfTestApp.Windows
{
    public partial class SubWindow : Window
    {
        // 画面のコンストラクタ
        public SubWindow(SubWindowViewModel viewModel)
        {
            InitializeComponent();
            DataContext = viewModel;

            // 画面を閉じる処理をセット
            viewModel.CloseAction = () =>
            {
                DialogResult = true;
                Close();
            };
        }
    }
}

SubWindowViewModel.cs

using Reactive.Bindings;
using System;
using System.ComponentModel;

namespace WpfTestApp.Windows
{
    public class SubWindowViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        // 自分を閉じる処理
        public Action CloseAction { get; set; }

        // OKボタンのコマンド
        public ReactiveCommand OKCommand { get; } = new ReactiveCommand();

        // コンストラクタ
        public SubWindowViewModel()
        {
            OKCommand.Subscribe(() => CloseAction());
        }
    }
}

実行結果(見やすさのためにMargin等を別途指定しています):




or
※ キャンセルボタンは、単にIsCancel="True"で閉じています。
  これを設定しておくと、Escキー押下時のほか普通にボタンを押したときも画面が閉じ、DialogResult は false になります。

参考:

【C#,WPF】プログラム作成のルーティン(MVVM)

WPFはやっぱりMVVMでつくりたいですが、最初のプロジェクトをつくるところがちょっと面倒です。
本当はプロジェクトテンプレート的なものにすればいいと思うんですが、とりあえずいつもの手順を書き出してみます。

プロジェクトをつくる

  • Visual Studio 2019 を起動し、新しいアプリケーションの作成→WPFアプリケーション(C#) を選択します。
  • 適当に情報を入力してプロジェクトをつくります。今回は WPFTestApp という名前にしています。
  • プロジェクトができたら、MVVMフレームワークとして、NuGet で ReactiveProperty をインストールします。
    (ReactivePropertyについては こちら

メイン画面をつくる

  • Windows という名前のフォルダをつくります。ここにView, ViewModelを入れることにします。
  • 自動でできた MainWindow.xaml は削除して、Windows フォルダの中に新しくウィンドウ(WPF) MainWindow.xaml を追加します。※もちろん別の名前でもよいです
  • MainWindow.xaml で、Heidht, Width の指定をSizeToContent="WidthAndHeight"に書き換えます。(とりあえず)
  • ViewModelとしてクラス MainWindowViewModel.cs も追加し、アクセス修飾子をpublicにします。また、使わないけど INotifyPropertyChanged を実装します。(メモリリーク対策)
    public class MainWindowViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;
    }
  • MainWindow.xaml.cs は、コンストラクタで MainWindowViewModel を受け取って DataContext にセットするようにします。
        public MainWindow(MainWindowViewModel viewModel)
        {
            InitializeComponent();
            DataContext = viewModel;
        }

メイン画面を開く

デフォルトではエントリポイントに相当する App.xaml から MainWindow.xaml を開く指定になっていますが、C#のコードのほうがいろいろ制御しやすいので、そのように変えます。

  • App.xaml で、StartupUri="MainWindow.xaml"Startup="Application_Startup"に書き換えます。
  • App.xaml.cs で、Application_Startupのイベントハンドラに、MainWindowを開くコードを書きます。
        private void Application_Startup(object sender, StartupEventArgs e)
        {
            var mainWindowVM = new MainWindowViewModel();
            new MainWindow(mainWindowVM).ShowDialog();
        }

まとめ

ここまで作業を行うと、以下のような状態になります。
f:id:kmua:20211113170841p:plain
MainWindow.xaml

<Window x:Class="WpfTestApp.Windows.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow"
        SizeToContent="WidthAndHeight">
    <Grid>
    </Grid>
</Window>

MainWindow.xaml.cs

using System.Windows;

namespace WpfTestApp.Windows
{
    public partial class MainWindow : Window
    {
        public MainWindow(MainWindowViewModel viewModel)
        {
            InitializeComponent();
            DataContext = viewModel;
        }
    }
}

MainWindowViewModel.cs

namespace WpfTestApp.Windows
{
    public class MainWindowViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;
    }
}

App.xaml

<Application x:Class="WpfTestApp.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             Startup="Application_Startup">
</Application>

App.xaml.cs

using System.Windows;
using WpfTestApp.Windows;

namespace WpfTestApp
{
    public partial class App : Application
    {
        private void Application_Startup(object sender, StartupEventArgs e)
        {
            var mainWindowVM = new MainWindowViewModel();
            new MainWindow(mainWindowVM).ShowDialog();
        }
    }
}

バインディングを確認する

MainWindowViewModel から MainWindow にバインディングができることを確認します。

    public class MainWindowViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        public ReactivePropertySlim<string> Greeting { get; } = new ReactivePropertySlim<string>("Hello, world!");
    }
<Window x:Class="WpfTestApp.Windows.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow"
        SizeToContent="WidthAndHeight">
    <Grid>
        <TextBlock Text="{Binding Greeting.Value}" FontSize="100" />
    </Grid>
</Window>

f:id:kmua:20211113174133p:plain
世界に挨拶できました。

修正履歴:

  • 2021/11/13 ViewModel の INotifyPropertyChanged 実装を追加(メモリリーク対策)