UWPのItemsSourceでGroupingをかけているときにCollectionをいじると子要素が反映されなくなる問題とその解決法
タイトルだけだとよくわからないと思いますので、まずはこの動画をご覧ください。
UWPのDataGridでグループ化をしたいと思っています。ReactiveCollectionで初期データをつくってからCollectionViewSourceを作るときちんとグループ化されているのですが、
— ikaros (@ikarostech) 2020年6月6日
後から追加をしようとするとグルーピングされる中身が追加されないという問題が発生しています。https://t.co/xwaQuQg6Je pic.twitter.com/8CmgJCmXEn
最初はグループ化されていて子要素を折りたたんで表示することができるDataGridでしたが、Addボタンをクリックして要素を追加すると、その中に入っている子要素が表示されなくなるという現象に悩まされていました。
ItemsSourceとCollectionViewSourceを用いたグルーピングについてはMicrosoft公式による記事が存在しています。
docs.microsoft.com
この記事を参考にViewModel.cs、MainView.xamlを下のように構成していました。
//ViewModel.cs using Reactive.Bindings; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using Windows.UI.Xaml.Data; namespace DataGridGrouping { public class ViewModel { public CollectionViewSource CollectionView { get; set; } = new CollectionViewSource(); public ReactiveCollection<User> Users { get; set; } = new ReactiveCollection<User>(); public ViewModel() { // 初期データを作成 Todo changePassword = new Todo(); changePassword.Name.Value = "Change Password"; changePassword.Content.Value = "change your password. init password is \"hack me\""; User user1 = new User(); user1.Todos.Add(changePassword); User user2 = new User(); user2.Todos.Add(changePassword); Users.Add(user1); Users.Add(user2); //CollectionViewSourceを作成 CollectionView.IsSourceGrouped = true; CollectionView.Source = Users; CollectionView.ItemsPath = new Windows.UI.Xaml.PropertyPath("Todos"); } public void AddUser() { Todo changePassword = new Todo(); changePassword.Name.Value = "Change Password"; changePassword.Content.Value = "change your password. init password is \"hack me\""; User user = new User(); user.Todos.Add(changePassword); Users.Add(user); } } }
<!-- MainView.xaml --> <Page x:Class="DataGridGrouping.MainPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:DataGridGrouping" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:controls="using:Microsoft.Toolkit.Uwp.UI.Controls" mc:Ignorable="d" Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> <Grid> <Button Content="Add" Width="100" Height="50" Margin="450,225,0,0" VerticalAlignment="Top" HorizontalAlignment="Left" Click="{x:Bind viewModel.AddUser}" /> <controls:DataGrid Width="800" Height="400" Margin="450,280,450,0" VerticalAlignment="Top" HorizontalAlignment="Left" ItemsSource="{x:Bind viewModel.CollectionView.View}" /> </Grid> </Page>
公式のとおりCollectionViewSourceを用いてGroupingをかけていましたが、このままではItemsSourceを更新した際に子要素が追加されないというわけです。
さて、ItemsSourceにBindingで追加したCollectionがどのようにそれぞれのUWP Controlに組み込まれるのかを考えてみます。
まずはGroupingがない場合、
次にGroupingがかかっている場合、
上の2図からわかるようにGroupingをかける場合にはSourceにIGroupingインターフェースを実装することが必要になります。
docs.microsoft.com
上述したMicrosoftの公式のデモではCollectionViewSourceにIsGroupedをtrueにすることで、
IGroupingインターフェースを実装した別のCollectionViewSourceを生成することになります。(内部でLINQのGroupBy句を使って新しいコレクションを作っているイメージです)
ただしIsGroupedをtrueにした実装ではCollectionが変更され通知された際に、Groupを新しく作ることがうまくいかないようです(ごめんなさい。ここら辺まだ検証できていません)
そこで、この現象を解消するためにあらかじめ自前でIGroupingを実装した要素を持つIEnumerableの実装クラスをItemsSourceに流し込むことで、CollectionViewSourceでのGroupingを避ける実装を行います。
/* GroupedCollection.cs */ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.Linq; using System.Text; using System.Threading.Tasks; namespace DataGridGrouping { public class Grouping<TKey, TElement> : ObservableCollection<TElement>, IGrouping<TKey, TElement> { public Grouping(TKey key) { this.Key = key; } public Grouping(TKey key, IEnumerable<TElement> items) : this(key) { foreach (var item in items) { this.Add(item); } } public TKey Key { get; } } public class GroupedCollection<TKey,TElement> : ObservableCollection<Grouping<TKey,TElement>> { public void Add(TKey key,TElement element) { FindOrCreateGroup(key).Add(element); } private Grouping<TKey, TElement> FindOrCreateGroup(TKey key) { var match = this.Select((group, index) => new { group, index }).FirstOrDefault(i => i.group.Key.Equals(key)); Grouping<TKey, TElement> result; if (match == null) { // Group doesn't exist and the new group needs to go at the end result = new Grouping<TKey, TElement>(key); this.Add(result); } else { result = match.group; } return result; } public bool Remove(TKey key, TElement element) { var group = this.FirstOrDefault(i => i.Key.Equals(key)); var success = group != null && group.Remove(element); if (group != null && group.Count == 0) { Remove(group); } return success; } } }
/* ViewModel.cs */ using Reactive.Bindings; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using Windows.UI.Xaml.Data; namespace DataGridGrouping { public class ViewModel { public CollectionViewSource CollectionView { get; set; } = new CollectionViewSource(); public ReactiveCollection<User> Users { get; set; } = new ReactiveCollection<User>(); public GroupedCollection<User, Todo> Todos { get; set; } = new GroupedCollection<User, Todo>(); public ViewModel() { // 初期データを作成 Todo changePassword = new Todo(); changePassword.Name.Value = "Change Password"; changePassword.Content.Value = "change your password. init password is \"hack me\""; User user1 = new User(); user1.Todos.Add(changePassword); User user2 = new User(); user2.Todos.Add(changePassword); Users.Add(user1); Users.Add(user2); Todos.Add(user1,changePassword); Todos.Add(user2,changePassword); //CollectionViewSourceを作成 CollectionView.IsSourceGrouped = true; CollectionView.Source = Todos; } public void AddUser() { Todo changePassword = new Todo(); changePassword.Name.Value = "Change Password"; changePassword.Content.Value = "change your password. init password is \"hack me\""; User user = new User(); user.Todos.Add(changePassword); Users.Add(user); Todos.Add(user,changePassword); } } }
<!-- MainView.xaml --> <Page x:Class="DataGridGrouping.MainPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:DataGridGrouping" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:controls="using:Microsoft.Toolkit.Uwp.UI.Controls" mc:Ignorable="d" Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"> <Grid> <Button Content="Add" Width="100" Height="50" Margin="450,225,0,0" VerticalAlignment="Top" HorizontalAlignment="Left" Click="{x:Bind viewModel.AddUser}"/> <controls:DataGrid Width="800" Height="400" Margin="450,280,450,0" VerticalAlignment="Top" HorizontalAlignment="Left" ItemsSource="{x:Bind viewModel.CollectionView.View}" /> </Grid> </Page>
手動でGroupを管理するCollectionを作ってあげることで、Collectionに新しい要素が追加された際にも画面に反映されるようになりました。
このGroupedCollectionの実装については mikegoatlyさんのGroupedObservableCollectionを大分に参考にさせていただきました。
github.com