WPF で ItemsControl 内の子コントロールの変更を親へ通知する

◆ はじめに

ItemsControl にバインドしたリスト要素に応じて、TextBox が生成されるような場面で、
TextBox の値が変わったらその都度変更通知を受け取りたい、そんなニッチな要望を満たすためにかなりはまったのでメモ。
素の WPF ならどう実現できるか、ライブラリを駆使したらどうなるか、など色々思考錯誤してみた。

◆ 具体的にはどんな画面か

サンプルとして、以下イメージの画面で実装していく。
ItemsControl にバインドされたリスト(数値)分だけ TextBox が生成され、
その TextBox の合計が一番下の TextBlock に表示される。
合計の値は、ItemsControl 内 TextBox の値が変更されるたびに更新されるようにする。

図

実現方法については、3つの方法で試してみた。

  1. 素の WPF のみ
  2. Prism
  3. Prism + ReactiveProperty

◆ 実現方法

1. 素の WPF のみ

リストに表示するためのデータをクラスとして作成し、そのクラス内のプロパティ変更箇所( Setter )で、
親(リストが配置されている ViewModel )に対しての変更通知処理を実行する。ポイントは、以下3点かな。

  • データクラスに INotifyPropertyChanged インターフェースを実装
  • リストは ObservableCollection で定義
  • 親への変更通知は、 Action で実現し、データクラスのコンストラクタで設定

多分この方法が一番簡単だと思います(優しいマサカリ期待)。

Number.cs

using System;
using System.ComponentModel;

namespace WPFSampleTextBoxListNotification.Models
{
    public class Number : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        public void OnPropertyChanged(string name)
            => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));

        private int _amount;
        public int Amount
        {
            get => _amount;
            set
            {
                _amount = value;
                OnPropertyChanged(nameof(Amount));

                _amountChangedNotification?.Invoke();
            }
        }

        private Action _amountChangedNotification = null;

        public Number(int amount, Action amountChangedNotification = null)
        {
            Amount = amount;
            _amountChangedNotification = amountChangedNotification;
        }
    }
}

NormalWindowViewModel.cs

using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using WPFSampleTextBoxListNotification.Models;

namespace WPFSampleTextBoxListNotification.ViewModels
{
    public class NormalWindowViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        public void OnPropertyChanged(string name)
            => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));

        public string _title = "素のWPF";
        public string Title
        {
            get => _title;
            set
            {
                _title = value;
                OnPropertyChanged(nameof(Title));
            }
        }

        public ObservableCollection<Number> _numbers;
        public ObservableCollection<Number> Numbers
        {
            get => _numbers;
            set
            {
                _numbers = value;
                OnPropertyChanged(nameof(Title));
            }
        }

        public int _total;
        public int Total
        {
            get => Numbers.Sum(x => x.Amount);
        }

        public NormalWindowViewModel()
        {
            var list = new ObservableCollection<Number>()
            {
                new Number(100, () => OnPropertyChanged(nameof(Total))),
                new Number(200, () => OnPropertyChanged(nameof(Total))),
                new Number(300, () => OnPropertyChanged(nameof(Total))),
            };

            Numbers = list;
        }
    }
}

NormalWindow.xaml

<Window
    x:Class="WPFSampleTextBoxListNotification.Views.NormalWindow"
    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"
    Title="NormalWindow"
    Width="300"
    Height="400"
    WindowStartupLocation="CenterScreen"
    mc:Ignorable="d">
    <Grid Margin="10">
        <Grid.RowDefinitions>
            <RowDefinition Height="auto" />
            <RowDefinition Height="*" />
            <RowDefinition Height="auto" />
        </Grid.RowDefinitions>

        <Label Grid.Row="0" Content="素のWPF" />
        <ItemsControl
            Grid.Row="1"
            Margin="5"
            Padding="5"
            ItemsSource="{Binding Numbers}">
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <TextBox
                        Margin="5"
                        Padding="5"
                        HorizontalContentAlignment="Right"
                        Text="{Binding Amount, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
                </DataTemplate>
            </ItemsControl.ItemTemplate>
        </ItemsControl>
        <TextBlock
            Grid.Row="2"
            Margin="5"
            Padding="5"
            HorizontalAlignment="Right"
            Text="{Binding Total, Mode=OneWay}" />
    </Grid>
</Window>

2. Prism

次は、 MVVM フレームワークである Prism を導入したパターンで実装してみる。
基本、素の WPF と同じものになる。変更通知のメソッドが Prsim が提供する RaisePropertyChanged になっただけ。
また、 INotifyPropertyChanged のインターフェースも自動実装されているので、ViewModel 内の記述量が減った感じ。

3. Prism + ReactiveProperty

最後は、Prism + ReactiveProperty で実装したパターン。
ReactiveProperty については、以下を眺めれば何となくわかると思われる。

このパターンだと、上述した2つのパターンのようにリスト要素のデータクラス内に通知する仕組みを実装する必要がない。
ReactiveProperty が提供する ObserveElementObservableProperty でリスト内要素の変更監視が可能となっている。
ただ、リスト要素のデータクラスを Prism の変更通知で実装するとうまく動かなかった。
なので、 ReactiveProperty で全て実装している。

http://okazuki.jp/ReactiveProperty/features/Extension-methods/#observe-propertychanged-events-of-observablecollections-items

また、はまりどころとしては、using Sysmte; の記述がないと、Subscribe でエラーとなる。

https://qiita.com/YSRKEN/items/5a36fb8071104a989fb8#q-reactivepropertyt%E3%81%AE%E5%80%A4%E3%81%8C%E5%A4%89%E6%9B%B4%E3%81%97%E3%81%9F%E6%99%82%E3%81%AE%E5%8B%95%E4%BD%9C%E3%81%AF%E3%81%A9%E3%81%86%E8%A8%98%E8%BF%B0%E3%81%99%E3%82%8C%E3%81%B0%E3%81%84%E3%81%84%E3%81%AE

NumberReactiveProperty.cs

using Prism.Mvvm;
using Reactive.Bindings;

namespace WPFSampleTextBoxListNotification.Models
{
    public class NumberReactiveProperty : BindableBase
    {
        public ReactiveProperty<int> Amount { get; set; } = new ReactiveProperty<int>();

        public NumberReactiveProperty(int amount)
        {
            Amount.Value = amount;
        }
    }
}

ReactivePropertyWindowViewModel.cs

using Prism.Mvvm;
using Reactive.Bindings;
using Reactive.Bindings.Extensions;
using System;
using System.Collections.ObjectModel;
using System.Linq;
using WPFSampleTextBoxListNotification.Models;

namespace WPFSampleTextBoxListNotification.ViewModels
{
    public class ReactivePropertyWindowViewModel : BindableBase
    {
        public ReactiveProperty<string> Title { get; set; }
            = new ReactiveProperty<string>("Prism + ReactiveProperty");

        public ReactiveCollection<NumberReactiveProperty> Numbers { get; }
            = new ReactiveCollection<NumberReactiveProperty>();

        public ReactiveProperty<int> Total { get; } = new ReactiveProperty<int>();

        public ReactivePropertyWindowViewModel()
        {
            var list = new ObservableCollection<NumberReactiveProperty>()
            {
                new NumberReactiveProperty(100),
                new NumberReactiveProperty(200),
                new NumberReactiveProperty(300),
            };

            Numbers
                .ObserveElementObservableProperty(x => x.Amount)
                .Subscribe(x =>
                {
                    Total.Value = Numbers.Sum(y => y.Amount.Value);
                });
            Numbers.AddRange(list);
        }
    }
}

ReactivePropertyWindow.xaml

<Window
    x:Class="WPFSampleTextBoxListNotification.Views.ReactivePropertyWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:prism="http://prismlibrary.com/"
    Title="{Binding Title.Value}"
    Width="300"
    Height="400"
    prism:ViewModelLocator.AutoWireViewModel="True"
    WindowStartupLocation="CenterScreen">
    <Grid Margin="10">
        <Grid.RowDefinitions>
            <RowDefinition Height="auto" />
            <RowDefinition Height="*" />
            <RowDefinition Height="auto" />
        </Grid.RowDefinitions>

        <Label Grid.Row="0" Content="Prism + ReactiveProperty" />
        <ItemsControl
            Grid.Row="1"
            Margin="5"
            Padding="5"
            ItemsSource="{Binding Numbers}">
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <TextBox
                        Margin="5"
                        Padding="5"
                        HorizontalContentAlignment="Right"
                        Text="{Binding Amount.Value, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
                </DataTemplate>
            </ItemsControl.ItemTemplate>
        </ItemsControl>
        <TextBlock
            Grid.Row="2"
            Margin="5"
            Padding="5"
            HorizontalAlignment="Right"
            Text="{Binding Total.Value, Mode=OneWay}" />
    </Grid>
</Window>

◆ まとめ

なんかできそうなんだけど、デフォルトではできないみたいなニッチさで、調べたり実験したりするのが大変だった。
どの実装でもそこそこ綺麗にできたので満足している。
ReactiveProperty については、基本便利だけど、細かい部分で動きがつかみ辛く、
自分のノウハウ貯めないと仕事では使えないかなーって感じ。

サンプルソースは以下。見たい方はどうぞ。
https://github.com/shuntaro4/WPFSampleTextBoxListNotification