CollectionViewの要素は、部分的に更新できないものなのか?
はじめに
数年前からXamarinを使い始めまして、1つのプロジェクトで3OS向けにビルドできてしまう有用性は、十分体感してきたのですが、あわせて、何か不安定で商用リリースには耐えられないなぁという感触も得ていました。原因の一つは筆者がCollectionView
を使いこなせていないことにあり、XAMLのバインディングアーキテクチャを正しく理解していないことにあるのだろうとは思っていたのですが、差し迫った案件もなく、放置プレーを続けておりました。
そうこうしているうちに、Microsoftがせっかく買収したXamarinをディスコンにするとのこと.NET MAUIに移行せよというリリースを目にし、Windows専用ならWinFormで十分と思っている筆者も、3OSで動くならということで、この機会に.NET MAUIを使って、XAMLとデータバインディングの世界に迷い込んでみました。
CollectionViewで実現したいこと
XAMLの有用性は、スライダを動かしたらリアルタイムに値が変化する等、UI同士の結びつきをいちいちコードで記述しなくて済むという点。それはわかっているのですが、筆者が携わっているアプリは、だいたいHTTPやデータベースから取得したデータで、UIを再描画するタイプのアプリなので、公式をはじめとするサンプルコードがなかなかマッチせずいつも四苦八苦しています。どちらかというと、商用プログラムのほとんどは、UIインプット型より、データ更新型だと思うのですが…
さて今回、以下のような6個のボタンからなるカタマリが、上下方向にスクロールすることを実現したいと思います。
| A00 | A10 | A20 |
| A01 | A11 | A21 |
| B00 | B10 | B20 |
| B01 | B11 | B21 |
CollectionView
を使いますが、CollectionView
に登録するカタマリについては将来増やす可能性があるので、XAMLでスタティックに記述しません。XAMLは中身がない形になります。
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="MauiApp1.MainPage">
<ContentPage.Content>
<CollectionView x:Name="CollectionView">
<CollectionView.ItemTemplate>
<DataTemplate>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</ContentPage.Content>
</ContentPage>
CollectionView
に登録する中身はITEM
になります。サンプルコードに従いCollectionView
と親和性が高いObservableCollection
を使います。
- コンストラクタは引数をもち、Viewから初期データをもらえるようにしておきます。
-
ITEM
のプロパティは、とりあえずボタン6個分です。 -
class Button
にはText
しか用意していませんが、TextColor
,FontSize
等も追加できます。
public class ViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
public ObservableCollection<ITEM> Items { get; private set; }
public ViewModel(List<ITEM> source)
{
Items = new ObservableCollection<ITEM>(source);
}
}
public class ITEM
{
public ITEM(string[,] ary)
{
for (int c = 0; c < ary.GetLength(0); c++)
{
for (int r = 0; r < ary.GetLength(1); r++)
{
switch (c, r)
{
default:
case (0, 0): Button00 = new BUTTON(ary[c, r]); break;
case (0, 1): Button01 = new BUTTON(ary[c, r]); break;
case (1, 0): Button10 = new BUTTON(ary[c, r]); break;
case (1, 1): Button11 = new BUTTON(ary[c, r]); break;
case (2, 0): Button20 = new BUTTON(ary[c, r]); break;
case (2, 1): Button21 = new BUTTON(ary[c, r]); break;
}
}
}
}
public BUTTON Button00 { get; set; }
public BUTTON Button01 { get; set; }
public BUTTON Button10 { get; set; }
public BUTTON Button11 { get; set; }
public BUTTON Button20 { get; set; }
public BUTTON Button21 { get; set; }
}
public class BUTTON
{
public BUTTON(string s = "")
{
Text = s;
}
public string Text { get; set; }
}
さて順番が逆になりましたが親玉のViewです。
- 前述の通り、
ViewMode
を生成する際に画面データを渡します。 - XAMLがほとんど空であった分
DataTemplate
で記述することになります。 -
btn.SetBinding
でButton.TextProperty
とITEM
のプロパティをバインドさせます。
public class MainPage : ContentPage
{
ViewModel VM;
public MainPage()
{
InitializeComponent();
VM = new ViewModel(source);
CollectionView.ItemsSource = VM.Items;
DataTemplate dt = new DataTemplate(() =>
{
var cd = new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) };
var rd = new RowDefinition { Height = new GridLength(1, GridUnitType.Star) };
Grid grid = new Grid
{
ColumnDefinitions = { cd, cd, cd },
RowDefinitions = { rd, rd }
};
for (int c = 0; c < 3; c++)
{
for (int r = 0; r < 2; r++)
{
Button btn = new Button();
Grid.SetColumn(btn, c);
Grid.SetRow(btn, r);
btn.SetBinding(Button.TextProperty, $"Button{c}{r}.Text");
grid.Children.Add(btn);
}
}
StackLayout layout = new StackLayout();
layout.Children.Add(grid);
return layout;
});
CollectionView.ItemTemplate = dt;
}
//画面データ
List<ITEM> source = new List<ITEM>
{
new ITEM(new string[,] { {"A00", "A01"}, {"A10", "A11"}, {"A20", "A21"} }),
new ITEM(new string[,] { {"B00", "B01"}, {"B10", "B11"}, {"B20", "B21"} }),
new ITEM(new string[,] { {"C00", "C01"}, {"C10", "C11"}, {"C20", "C21"} }),
new ITEM(new string[,] { {"D00", "D01"}, {"D10", "D11"}, {"D20", "D21"} }),
new ITEM(new string[,] { {"E00", "E01"}, {"E10", "E11"}, {"E20", "E21"} }),
new ITEM(new string[,] { {"F00", "F01"}, {"F10", "F11"}, {"F20", "F21"} }),
new ITEM(new string[,] { {"G00", "G01"}, {"G10", "G11"}, {"G20", "G21"} }),
new ITEM(new string[,] { {"H00", "H01"}, {"H10", "H11"}, {"H20", "H21"} })
};
public void OnUpdate()
{
//これでは更新されない
VM.Items[1].Button01.Text = "更新";
//これなら更新できるが、これでいいのか?
var i = VM.Items[1];
i.Button01.Text = "更新";
VM.Items[1] = i;
}
}
Windowsアプリとして動作させると下記のような結果になります。
![アプリキャプチャ](https://storage.googleapis.com/zenn-user-upload/ccdc5476d8d4-20230909.png =250)
CollectionViewの要素を部分的に更新する
さてこの実装に対して、ボタンのText
を書き換えようとすると、OnUpdate()
のようなインターフェースを用意することになろうかと思うのですが、コメントにありますようにText
のみの更新はうまくいかず、よくわからないけどITEM
をまるごと入れ替えれば、更新されることはわかっていました。何か違和感はありましたが、とりあえず動いていたので放置していましたが、OSによっては、無駄に全体が再描画されているようにも見え、残念ながら実用に耐えられる感触は得られないままでした。
今回.NET MAUIへの移行してみると、事態は深刻化しており、無駄な再描画に加えて、先頭へのスクロールも発生、ITEMを入れ替える方法は使い物にならないことがはっきりしました。解決策を模索していると、下記のような記事に巡り合い、
そもそもCollectionView
の実装に出てくるObservableCollection
は、追加や削除でしか動作せず、コレクションされた要素の変更は別途実装しなくてはならない旨が指摘されておりました。具体的には更新したいプロパティのset
にNotifyPropertyChanged()
を付加すれば良いという指摘です。
この指摘を受けclass BUTTON
を下記のように書き直し、無事Text
のみを更新することに成功しました。作者の方には、この場を借りて厚く御礼申し上げます。
public class BUTTON : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
{
if (PropertyChanged != null)
{
PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}
}
public BUTTON(string s = "")
{
Text = s;
}
string _text;
public string Text {
get {
return _text;
}
set {
_text = value;
NotifyPropertyChanged();
}
}
}
(了)
Discussion