Silverlight + MVVM モデルで DataGrid をバインドの落とし穴(その2)

落とし穴シリーズ(にするつもりは無いのですが)の続きです。
本当は、動的バインドの話を書こうと思ったのですが、ItemsSourceプロパティにバインドするときの注意を忘れてました。

DataGrid コントロールのItemsSourceプロパティには、配列やListコレクションなどが渡せます。最近はジェネリックの流行りが功を奏して(かな?)、List<>を渡す場合が多くなっています。
実は IList と List<> の違いどころを間違えるとややこしいことになるのですが、それは別の機会には話します。型チェックのキャストでは List<> の方が不利なんです。型が2つあるからね。

さて、先日のDataGridのソースでは List<> を渡しました。
ページクラスのコンストラクタを改めてみてみると、次のようになっています。

public PageGrid()
{
    InitializeComponent();
    MyModel = new ModelGrid();
    this.DataContext = MyModel;

    // コレクションを作成
    List<Product> list = new List<Product>();
    list.Add( new Product(){ ID="001", Name="Silverlight 3" });
    list.Add( new Product(){ ID="002", Name="Visual Studio 2010" });
    list.Add( new Product(){ ID="003", Name="Expression Bend 3" });
    // グリッドに設定
    this.DGrid.ItemsSource = list;
}

ここでは、コントロールのグリッドに直接設定しているのですが、これは MyModel クラスの Items プロパティに設定してもよいはずです。Items プロパティは DataGrid の ItemsSource プロパティにバインドされています。

public PageGridEdit()
{
    InitializeComponent();
    MyModel = new ModelGridEdit();
    this.DataContext = MyModel;

    // コレクションを作成
    List<Product> list = new List<Product>();
    list.Add(new Product() { ID = "001", Name = "Silverlight 3" });
    list.Add(new Product() { ID = "002", Name = "Visual Studio 2010" });
    list.Add(new Product() { ID = "003", Name = "Expression Blend 3" });
    MyModel.Items = list ;	★
    
}

変わった部分は★だけです。これを画面で動作確認すると、正常に動きます。
そうなんです。ここまでは、検索の例と同じになります。

ここで、このグリッドを編集できるようにしましょう。
グリッド上で編集することもできますが、ここは定番のテキストボックスを使って編集する方法を考えましょう。

<Grid x:Name="LayoutRoot" Background="White">
<StackPanel>
    <TextBox Name="TextID" Width="100" Text="{Binding ProductID, Mode=TwoWay}" />
    <TextBox Name="TextName" Width="200" Text="{Binding ProductName, Mode=TwoWay}"/>
    <Button Name="BtnAdd" Width="100" Content="新規追加" Click="BtnAdd_Click"/>
    <Button Name="BtnDel" Width="100" Content="削除" Click="BtnDel_Click"/>
    <Button Name="BtnUpdate" Width="100" Content="更新" Click="BtnUpdate_Click"/>
    <dt:DataGrid Name="DGrid" Width="300" Height="200"
                 ItemsSource="{Binding Items, Mode=TwoWay}" 
                 SelectionChanged="DGrid_SelectionChagned"
                 />
</StackPanel>
</Grid>

TextID と TextName が編集のためのテキストボックスです。これも ProductID と ProductName という名前でバインドしておきます。バインド先は MyModel です。
# 本当は、編集用のProductクラスにバインドするのですが、説明のために MyModel にバインドします。

ボタンは「新規追加」「削除」「更新」ボタンの3つを用意します。実際は、編集している途中はグリッドを動かせないとか、キャンセルができるとか、色々やることがありますが、今は基本だけを押さえておきましょう。

もうひとつ DataGrid コントロールのイベントを追加します。SelectionChangedイベントです。これはグリッドのカーソルが移動したときに発生するイベントで、カーソルを移動したときに、TextID や TextName コントロールの内容を変更します。

3つのボタンのイベントとデータグリッドの選択イベントを記述したのが次のコードです。

/// <summary>
/// 新規追加ボタン
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void BtnAdd_Click(object sender, RoutedEventArgs e)
{
    Product item = new Product();
    item.ID = MyModel.ProductID;
    item.Name = MyModel.ProductName;
    MyModel.Items.Add(item);
}
/// <summary>
/// 削除ボタン
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void BtnDel_Click(object sender, RoutedEventArgs e)
{
    if (this.DGrid.SelectedItem != null)
    {
        Product item = (Product)DGrid.SelectedItem;
        MyModel.Items.Remove(item);
    }
}
/// <summary>
/// 更新ボタン
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void BtnUpdate_Click(object sender, RoutedEventArgs e)
{
    if (this.DGrid.SelectedItem != null)
    {
        Product item = (Product)DGrid.SelectedItem;
        item.ID = MyModel.ProductID;
        item.Name = MyModel.ProductName;
    }
}
/// <summary>
/// カーソル移動
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void DGrid_SelectionChagned(object sender, SelectionChangedEventArgs e)
{
    if (DGrid.SelectedItem != null)
    {
        Product item = (Product)DGrid.SelectedItem;
        MyModel.ProductID = item.ID;
        MyModel.ProductName = item.Name;
    }
}

データグリッドで選択行を取得する以外は、MyModelのプロパティを使います。ここの部分が MVVM の原則をはみ出しているような気がする場合は、MyModel に SelectionChanged イベントを作るか、OnSelectionChangedメソッドを作って、それを呼び出せばよいでしょう。

さて、モデルクラスのほうは ProductID と ProductName プロパティを追加すればおわりです。

/// <summary>
/// モデルクラス
/// </summary>
public class ModelGridEdit : INotifyPropertyChanged
{
/// <summary>
/// グリッドのデータ
/// </summary>
private IList _Items;
public IList Items
{
    get { return _Items; }
    set
    {
        if (_Items != value)
        {
            _Items = value;
            OnPropertyChanged("Items");
        }
    }
}

private string _ProductID ;
public string ProductID
{
    get { return _ProductID; }
    set
    {
        if (_ProductID != value)
        {
            _ProductID = value;
            OnPropertyChanged("ProductID");
        }
    }
}

private string _ProductName ;
public string ProductName
{
    get { return _ProductName; }
    set
    {
        if (_ProductName != value)
        {
            _ProductName = value;
            OnPropertyChanged("ProductName");
        }
    }
}

#region INotifyPropertyChanged メンバ
/// <summary>
/// プロパティ変更時のイベント
/// </summary>
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged(PropertyChangedEventArgs e)
{
    if (PropertyChanged != null)
        PropertyChanged(this, e);
}
protected virtual void OnPropertyChanged(string name)
{
    if (PropertyChanged != null)
        PropertyChanged(this, new PropertyChangedEventArgs(name));
}
#endregion
}

これを画面で実行すると、初期画面にリストが表示されるのですが。。。
新規追加ボタンと削除ボタンがうまく動作しません。
テキストボックスにIDとNameを入力して、追加ボタンを押してもグリッドが更新されません。ブラウザを一旦、最小化させたり、再表示させてもダメです。
しかし、この状態で別のグリッドをクリックすると、行が追加されます。変な動きですね。バグみたいです。

ちなみに、行をたくさん追加すると、スクロールバーを動かしたときにグリッドの表示が更新されます。変な動きですね。

「これはDataGridの不具合だ!」

と思ったのは私だけではないでしょう!

が、違うのです。実はバグではありません。ItemsSource プロパティの設定の仕方が悪いのです。
さっき、ItemsSource プロパティに入れたのは List<> のコレクションでした。そして、List<> コレクションの中身のオブジェクトは当然ながらコレクションの子項目になります。
あれ?先のエントリを読んでいる方は気づいたかもしれません。
そうです。TextコントロールやDataGridコントロールを直接バインドした場合には、それぞれのプロパティの変更は追随しないのです。画面の表示時に読みだすため、一見「バインドが遅い」という感じがするのです。

というわけで、List<> コレクションで自動的に DataGrid を更新することはできません。
困った。
というわけで、MSDN をよく読んでみると、「ObservableCollection<> コレクションを使え」となっています。なんでしょう?この ObservableCollection コレクションってのは?

実は ObservableCollection はリストへの追加や削除のイベントが拾えるコレクションです。というわけで、ObservableCollectionコレクションを使ってリストを作ると、このリストに追加/削除すると、元々のDataGridにイベントが飛ぶんですね。

では早速仰せの通り変更してみましょう。

public PageGrid()
{
    InitializeComponent();
    MyModel = new ModelGrid();
    this.DataContext = MyModel;

    // コレクションを作成
    ObservableCollection<Product> list = new ObservableCollection<Product>(); ★
    list.Add( new Product(){ ID="001", Name="Silverlight 3" });
    list.Add( new Product(){ ID="002", Name="Visual Studio 2010" });
    list.Add( new Product(){ ID="003", Name="Expression Bend 3" });
    // グリッドに設定
    MyModel.Items = list ;
}

修正する箇所は★の一か所だけです。使い慣れない名前ですが、まあ、インテリセンスを利用して打ち込んでみてください。
これを動かすと、おお動くじゃないですか!追加も削除もOK.グリッドでカーソルをぽちぽち動かしても大丈夫。「これはDataGridの不具合だ!」なんて思ってごめんね、Silverlight君。

ってなわけで、これでめでたしめでたし。ってなことになるはずですが、そう、忘れていましたね。
更新ボタンの処理です。

グリッドを選択して、テキストで編集して、更新ボタンをクリック。
さて、グリッドの表示は、変わりません!

なにこれぇ!MVVMって全然使えないジャン。データグリッドを更新してくれなくちゃ意味ないよ。
「これこそDataGridの不具合だ!」
って思って、投げ出したくなります。実際、私も投げ出したくなりました。データグリッドを無理矢理更新しようと思って、Remove/Insertをしたり、一旦ItemsSourceにnullを入れて改めてリストを代入したり、と。
巷のブログでも似たようなことで困っている人がいらっしゃいます。

実はこれはObservableCollectionコレクションに渡すオブジェクトが悪いのです。
Productクラスは次のように単純なデータ型です。

public class Product
{
    public string ID { get; set; }
    public string Name { get; set; }
}

実は、データを更新したときにObservableCollectionコレクションが受け取れるように(ItemsSourceプロパティが受け取れるように)、ここに INotifyPropertyChanged インターフェースが必要なのです。
う~ん。これに気付けってってのが難しいのですが、落とし穴ですね。

public class Product: INotifyPropertyChanged
{
private string _ID;
public string ID
{
    get { return _ID; }
    set
    {
        if (_ID != value)
        {
            _ID = value;
            OnPropertyChanged("ID");
        }
    }
}

private string _Name;
public string Name
{
    get { return _Name; }
    set
    {
        if (_Name != value)
        {
            _Name = value;
            OnPropertyChanged("Name");
        }
    }
}

#region INotifyPropertyChanged メンバ
/// <summary>
/// プロパティ変更時のイベント
/// </summary>
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged(PropertyChangedEventArgs e)
{
    if (PropertyChanged != null)
        PropertyChanged(this, e);
}
protected virtual void OnPropertyChanged(string name)
{
    if (PropertyChanged != null)
        PropertyChanged(this, new PropertyChangedEventArgs(name));
}
#endregion
}

こんな風にProductクラスを書き換えます。定番となったOnPropertyChangedはそのままコピー&ペーストしてください。
# プロパティ作成のスペニットがあればいんですが、作り方がよくわからないし。いつもコピー&ペーストして置換をしています。まあ、後でスペニットを作りましょう。
# つーか、この書き方じゃない別に方法を考えましょう。Cの#defineマクロみたいなやつを。

2つの落とし穴があるわけですが、これを修正するとDataGridは思ったように動きます。多分Detailコントロールなんかはこのあたりを自動化しているのでしょう。

落とし穴をまとめると、

・ItemsSourceプロパティにはObservableCollection<>を使え!
・ObservableCollectionコレクションの型にはINotifyPropertyChangedインターフェースを使え!

です。

やれやれ(空条承太郎風ではなくてカート・ヴォネガット風に)。

カテゴリー: 開発 パーマリンク

Silverlight + MVVM モデルで DataGrid をバインドの落とし穴(その2) への2件のフィードバック

  1. ゆもと みちたか のコメント:

    全く同じポイント(データグリッドを更新してくれなくちゃ意味ない)で、30分ほど嵌りまして・・・ObservableCollectionの中身の型にもINotifyPropertyChangedを実装したらSelectedItemのプロパティを変えると、グリッドも変更されました。

    大変参考になりました。ありがとうございました。

    • masuda のコメント:

      そうなんです。表示のみだったらいいんですけど、更新も込みになると、Item クラスにも INotifyPropertyChanged が必要なんですよね。結構はまります。

コメントは停止中です。