UWP 可观察集合排序和分组

本文关键字:排序 集合 观察 UWP | 更新日期: 2023-09-27 18:31:24

在 UWP 应用中,如何对可观察集合进行分组和排序并保留所有实时通知的优点?

在我见过的大多数简单的 UWP 示例中,通常有一个 ViewModel,它公开了一个 ObservableCollection,然后将其绑定到视图中的列表视图。在 ObservableCollection 中添加或删除项时,ListView 会通过对 INotifyCollectionChanged 通知做出反应来自动反映更改。在未排序或未分组的 ObservableCollection 的情况下,这一切都可以正常工作,但如果集合需要排序或分组,似乎没有明显的方法来保留更新通知。更重要的是,动态更改排序或组顺序似乎会引发严重的实现问题。

++

假设您有一个现有的数据缓存后端,该后端公开了一个非常简单的类联系人的可观察集合。

public class Contact
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string State { get; set; }
}

此可观察集合随时间而变化,我们希望在更新的视图中显示实时分组和排序列表以响应数据缓存中的更改。我们还希望为用户提供在姓氏和州之间动态切换分组的选项。

++

在 WPF 世界中,这是相对微不足道的。我们可以创建一个简单的 ViewModel 来引用按原样显示缓存的联系人集合的数据缓存。

public class WpfViewModel 
{
    public WpfViewModel()
    {
        _cache = GetCache();
    }
    Cache _cache;
    public ObservableCollection<Contact> Contacts
    {
        get { return _cache.Contacts; }
    }
}

然后,我们可以将其绑定到一个视图,在该视图中,我们将 CollectionViewSource 和排序和组定义实现为 XAML 资源。

<Window .....
   xmlns:scm="clr-namespace:System.ComponentModel;assembly=WindowsBase">
   <Window.DataContext>
      <local:WpfViewModel />
   </Window.DataContext>
    <Window.Resources>
        <CollectionViewSource x:Key="cvs" Source="{Binding Contacts}" />
        <PropertyGroupDescription x:Key="stategroup" PropertyName="State" />
        <PropertyGroupDescription x:Key="initialgroup" PropertyName="LastName[0]" />
        <scm:SortDescription x:Key="statesort" PropertyName="State" Direction="Ascending" />
        <scm:SortDescription x:Key="lastsort" PropertyName="LastName" Direction="Ascending" />
        <scm:SortDescription x:Key="firstsort" PropertyName="FirstName" Direction="Ascending" />
    </Window.Resources>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>
        <ListView ItemsSource="{Binding Source={StaticResource cvs}}">
            <ListView.ItemTemplate>
                <DataTemplate>
                    <Grid>
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="100" />
                            <ColumnDefinition Width="100" />
                            <ColumnDefinition Width="*" />
                        </Grid.ColumnDefinitions>
                        <TextBlock Text="{Binding LastName}" />
                        <TextBlock Text="{Binding FirstName}" Grid.Column="1" />
                        <TextBlock Text="{Binding State}" Grid.Column="2" />
                    </Grid>
                </DataTemplate>
            </ListView.ItemTemplate>
            <ListView.GroupStyle>
                <GroupStyle>
                    <GroupStyle.HeaderTemplate>
                        <DataTemplate>
                            <Grid Background="Gainsboro">
                                <TextBlock FontWeight="Bold" 
                                           FontSize="14" 
                                           Margin="10,2"
                                           Text="{Binding Name}"/>
                            </Grid>
                        </DataTemplate>
                    </GroupStyle.HeaderTemplate>
                </GroupStyle>
            </ListView.GroupStyle>
        </ListView>
        <StackPanel Orientation="Horizontal" Grid.Row="1">
            <Button Content="Group By Initial" Click="InitialGroupClick" />
            <Button Content="Group By State" Click="StateGroupClick" />
        </StackPanel>
    </Grid>
</Window>

然后,当用户单击窗口底部的 GroupBy 按钮时,我们可以在代码隐藏中即时分组和排序。

private void InitialGroupClick(object sender, RoutedEventArgs e)
{
     var cvs = FindResource("cvs") as CollectionViewSource;
     var initialGroup = (PropertyGroupDescription)FindResource("initialgroup");
     var firstSort = (SortDescription)FindResource("firstsort");
     var lastSort = (SortDescription)FindResource("lastsort");
     using (cvs.DeferRefresh())
     {
         cvs.GroupDescriptions.Clear();
         cvs.SortDescriptions.Clear();
         cvs.GroupDescriptions.Add(initialGroup);
         cvs.SortDescriptions.Add(lastSort);
         cvs.SortDescriptions.Add(firstSort);
     }
}
private void StateGroupClick(object sender, RoutedEventArgs e)
{
     var cvs = FindResource("cvs") as CollectionViewSource;
     var stateGroup = (PropertyGroupDescription)FindResource("stategroup");
     var stateSort = (SortDescription)FindResource("statesort");
     var lastSort = (SortDescription)FindResource("lastsort");
     var firstSort = (SortDescription)FindResource("firstsort");
     using (cvs.DeferRefresh())
     {
         cvs.GroupDescriptions.Clear();
         cvs.SortDescriptions.Clear();
         cvs.GroupDescriptions.Add(stateGroup);
         cvs.SortDescriptions.Add(stateSort);
         cvs.SortDescriptions.Add(lastSort);
         cvs.SortDescriptions.Add(firstSort);
     }
}

这一切都工作正常,并且随着数据缓存集合的更改,项目会自动更新。列表视图分组和选择不受集合更改的影响,并且新联系人项目已正确分组。用户可以在运行时在"状态"和"姓氏"初始值之间交换分组。

++

在 UWP 世界中,CollectionViewSource 不再具有 GroupDescriptions 和 SortDescriptions 集合,并且需要在 ViewModel 级别执行排序/分组。我发现的最接近可行解决方案的方法类似于 Microsoft 的示例包

https://github.com/Microsoft/Windows-universal-samples/tree/master/Samples/XamlListView

和这篇文章

http://motzcod.es/post/94643411707/enhancing-xamarinforms-listview-with-grouping

其中,ViewModel 使用 Linq 对可观察集合进行分组,并将其作为分组项的可观察集合呈现给视图

public ObservableCollection<GroupInfoList> GroupedContacts
{
    ObservableCollection<GroupInfoList> groups = new ObservableCollection<GroupInfoList>();
    var query = from item in _cache.Contacts
                group item by item.LastName[0] into g
                orderby g.Key
                select new { GroupName = g.Key, Items = g };
    foreach (var g in query)
    {
         GroupInfoList info = new GroupInfoList();
         info.Key = g.GroupName;
         foreach (var item in g.Items)
         {
             info.Add(item);
         }
         groups.Add(info);
    }
    return groups;
}

其中,组信息列表定义为

public class GroupInfoList : List<object>
{
   public object Key { get; set; }
}

这至少会让我们在视图中显示一个分组集合,但对数据缓存集合的更新不再实时反映。我们可以捕获数据缓存的 CollectionChanged 事件并在视图模型中使用它来刷新 GroupedContacts 集合,但这会为数据缓存中的每个更改创建一个新集合,导致 ListView 闪烁并重置选择等,这显然是次优的。

此外,动态交换分组似乎

需要为每个分组方案提供完全独立的分组项的可观察集合,并在运行时交换 ListView 的 ItemSource 绑定。

我所看到的 UWP 环境的其余内容似乎非常有用,所以我惊讶地发现一些重要的东西,比如对列表进行分组和排序,从而抛出障碍......

有人知道如何正确地做到这一点吗?

UWP 可观察集合排序和分组

我已经开始整理一个名为 GroupedObservableCollection 的库,它为我的一个应用程序做了一些类似的事情。

我需要解决的关键问题之一是刷新用于创建组的原始列表,即我不希望用户使用略有不同的条件进行搜索以刷新整个列表,只是差异。

以目前的形式,它现在可能无法回答您所有的排序问题,但对于其他人来说可能是一个很好的起点。

到目前为止尽最大努力使用以下帮助程序类 可观察分组集合

public class ObservableGroupingCollection<K, T> where K : IComparable
{
    public ObservableGroupingCollection(ObservableCollection<T> collection)
    {
        _rootCollection = collection;
        _rootCollection.CollectionChanged += _rootCollection_CollectionChanged;
    }
    ObservableCollection<T> _rootCollection;
    private void _rootCollection_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        HandleCollectionChanged(e);
    }
    ObservableCollection<Grouping<K, T>> _items;
    public ObservableCollection<Grouping<K, T>> Items
    {
        get { return _items; }
    }
    IComparer<T> _sortOrder;
    Func<T, K> _groupFunction;
    public void ArrangeItems(IComparer<T> sortorder, Func<T, K> group)
    {
        _sortOrder = sortorder;
        _groupFunction = group;
        var temp = _rootCollection
            .OrderBy(i => i, _sortOrder)
            .GroupBy(_groupFunction)
            .ToList()
            .Select(g => new Grouping<K, T>(g.Key, g));
        _items = new ObservableCollection<Grouping<K, T>>(temp);
    }
    private void HandleCollectionChanged(NotifyCollectionChangedEventArgs e)
    {
        if (e.Action == NotifyCollectionChangedAction.Add)
        {
            var item = (T)(e.NewItems[0]);
            var value = _groupFunction.Invoke(item);
            // find matching group if exists
            var existingGroup = _items.FirstOrDefault(g => g.Key.Equals(value));
            if (existingGroup == null)
            {
                var newlist = new List<T>();
                newlist.Add(item);
                // find first group where Key is greater than this key
                var insertBefore = _items.FirstOrDefault(g => ((g.Key).CompareTo(value)) > 0);
                if (insertBefore == null)
                {
                    // not found - add new group to end of list
                    _items.Add(new Grouping<K, T>(value, newlist));
                }
                else
                {
                    // insert new group at this index
                    _items.Insert(_items.IndexOf(insertBefore), new Grouping<K, T>(value, newlist));
                }
            }
            else
            {
                // find index to insert new item in existing group
                int index = existingGroup.ToList().BinarySearch(item, _sortOrder);
                if (index < 0)
                {
                    existingGroup.Insert(~index, item);
                }
            }
        }
        else if (e.Action == NotifyCollectionChangedAction.Remove)
        {
            var item = (T)(e.OldItems[0]);
            var value = _groupFunction.Invoke(item);
            var existingGroup = _items.FirstOrDefault(g => g.Key.Equals(value));
            if (existingGroup != null)
            {
                // find existing item and remove
                var targetIndex = existingGroup.IndexOf(item);
                existingGroup.RemoveAt(targetIndex);
                // remove group if zero items
                if (existingGroup.Count == 0)
                {
                    _items.Remove(existingGroup);
                }
            }
        }
    }
}

其中泛型分组类(它本身公开了一个 ObservableCollection)来自本文

http://motzcod.es/post/94643411707/enhancing-xamarinforms-listview-with-grouping

制作工作演示:-

从新的 UWP 空白应用程序中,添加上述可观察分组集合类。然后在同一命名空间中添加另一个类文件并添加所有后续类

// Data models
public class Contact
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string State { get; set; }
}
public class DataPool
{
    public static string GenerateFirstName(Random random)
    {
        List<string> names = new List<string>() { "Lilly", "Mukhtar", "Sophie", "Femke", "Abdul-Rafi", "Mariana", "Aarif", "Sara", "Ibadah", "Fakhr", "Ilene", "Sardar", "Hanna", "Julie", "Iain", "Natalia", "Henrik", "Rasa", "Quentin", "Gadi", "Pernille", "Ishtar", "Jimmy", "Justine", "Lale", "Elize", "Randy", "Roshanara", "Rajab", "Marcus", "Mark", "Alima", "Francisco", "Thaqib", "Andreas", "Marianna", "Amalie", "Rodney", "Dena", "Amar", "Anna", "Nasreen", "Reema", "Tomas", "Filipa", "Frank", "Bari'ah", "Parvaiz", "Jibran", "Tomas", "Elli", "Carlos", "Diego", "Henrik", "Aruna", "Vahid", "Eliana", "Roxanne", "Amanda", "Ingrid", "Wesley", "Malika", "Basim", "Eisa", "Alina", "Andreas", "Deeba", "Diya", "Parveen", "Bakr", "Celine", "Daniel", "Mattheus", "Edmee", "Hedda", "Maria", "Maja", "Alhasan", "Alina", "Hedda", "Vanja", "Robin", "Victor", "Aaftab", "Guilherme", "Maria", "Kai", "Sabien", "Abdel", "Jason", "Bahaar", "Vasco", "Jibran", "Parsa", "Catalina", "Fouad", "Colette", "John", "Fred", "James", "Harry", "Ben", "Steven", "Philip", "Dougal", "Jasper", "Elliott", "Charles", "Gerty", "Sarah", "Sonya", "Svetlana", "Dita", "Karen", "Christine", "Angela", "Heather", "Spence", "Graham", "David", "Bernie", "Darren", "Lester", "Vince", "Colin", "Bernhard", "Dieter", "Norman", "William", "Nigel", "Nick", "Nikki", "Trent", "Devon", "Steven", "Eric", "Derek", "Raymond", "Craig" };
        return names[random.Next(0, names.Count)];
    }
    public static string GenerateLastName(Random random)
    {
        List<string> lastnames = new List<string>() { "Carlson", "Attia", "Quincey", "Hollenberg", "Khoury", "Araujo", "Hakimi", "Seegers", "Abadi", "Krommenhoek", "Siavashi", "Kvistad", "Vanderslik", "Fernandes", "Dehmas", "Sheibani", "Laamers", "Batlouni", "Lyngvær", "Oveisi", "Veenhuizen", "Gardenier", "Siavashi", "Mutlu", "Karzai", "Mousavi", "Natsheh", "Nevland", "Lægreid", "Bishara", "Cunha", "Hotaki", "Kyvik", "Cardoso", "Pilskog", "Pennekamp", "Nuijten", "Bettar", "Borsboom", "Skistad", "Asef", "Sayegh", "Sousa", "Miyamoto", "Medeiros", "Kregel", "Shamoun", "Behzadi", "Kuzbari", "Ferreira", "Barros", "Fernandes", "Xuan", "Formosa", "Nolette", "Shahrestaani", "Correla", "Amiri", "Sousa", "Fretheim", "Van", "Hamade", "Baba", "Mustafa", "Bishara", "Formo", "Hemmati", "Nader", "Hatami", "Natsheh", "Langen", "Maloof", "Patel", "Berger", "Ostrem", "Bardsen", "Kramer", "Bekken", "Salcedo", "Holter", "Nader", "Bettar", "Georgsen", "Cuninho", "Zardooz", "Araujo", "Batalha", "Antunes", "Vanderhoorn", "Srivastava", "Trotter", "Siavashi", "Montes", "Sherzai", "Vanderschans", "Neves", "Sarraf", "Kuiters", "Hestoe", "Cornwall", "Paisley", "Cooper", "Jakoby", "Smith", "Davies", "Jonas", "Bowers", "Fernandez", "Perez", "Black", "White", "Keller", "Hernandes", "Clinton", "Merryweather", "Freeman", "Anguillar", "Goodman", "Hardcastle", "Emmott", "Kirkby", "Thatcher", "Jamieson", "Spender", "Harte", "Pinkman", "Winterman", "Knight", "Taylor", "Wentworth", "Manners", "Walker", "McPherson", "Elder", "McDonald", "Macintosh", "Decker", "Takahashi", "Wagoner" };
        return lastnames[random.Next(0, lastnames.Count)];
    }
    public static string GenerateState(Random random)
    {
        List<string> states = new List<string>() { "Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado", "Connecticut", "Delaware", "District Of Columbia", "Florida", "Georgia", "Hawaii", "Idaho", "Illinois", "Indiana", "Iowa", "Kansas", "Kentucky", "Louisiana", "Maine", "Maryland", "Massachusetts", "Michigan", "Minnesota", "Mississippi", "Missouri", "Montana", "Nebraska", "Nevada", "New Hampshire", "New Jersey", "New Mexico", "New York", "North Carolina", "North Dakota", "Ohio", "Oklahoma", "Oregon", "Pennsylvania", "Rhode Island", "South Carolina", "South Dakota", "Tennessee", "Texas", "Utah", "Vermont", "Virginia", "Washington", "West Virginia", "Wisconsin", "Wyoming" };
        return states[random.Next(0, states.Count)];
    }
}
public class Cache
{
    public Cache()
    {
        InitializeCacheData();
        SimulateLiveChanges(new TimeSpan(0, 0, 1));
    }
    public ObservableCollection<Contact> Contacts { get; set; }
    private static Random rnd = new Random();
    private void InitializeCacheData()
    {
        Contacts = new ObservableCollection<Contact>();
        var i = 0;
        while (i < 5)
        {
            Contacts.Add(new Contact()
            {
                FirstName = DataPool.GenerateFirstName(rnd),
                LastName = DataPool.GenerateLastName(rnd),
                State = DataPool.GenerateState(rnd)
            });
            i++;
        }
    }
    private async void SimulateLiveChanges(TimeSpan MyInterval)
    {
        double MyIntervalSeconds = MyInterval.TotalSeconds;
        while (true)
        {
            await Task.Delay(MyInterval);
            //int addOrRemove = rnd.Next(1, 10);
            //if (addOrRemove > 3)
            //{
            // add item
            Contacts.Add(new Contact()
            {
                FirstName = DataPool.GenerateFirstName(rnd),
                LastName = DataPool.GenerateLastName(rnd),
                State = DataPool.GenerateState(rnd)
            });
            //}
            //else
            //{
            //    // remove random item
            //    if (Contacts.Count > 0)
            //    {
            //        Contacts.RemoveAt(rnd.Next(0, Contacts.Count - 1));
            //    }
            //}
        }
    }
}
// ViewModel
public class ViewModel : BaseViewModel
{       
    public ViewModel()
    {
        _groupingCollection = new ObservableGroupingCollection<string, Contact>(new Cache().Contacts);
        _groupingCollection.ArrangeItems(new StateSorter(), (x => x.State));
        NotifyPropertyChanged("GroupedContacts");
    }
    ObservableGroupingCollection<string, Contact> _groupingCollection;
    public ObservableCollection<Grouping<string, Contact>> GroupedContacts
    {
        get
        {
            return _groupingCollection.Items;
        }
    }
    // swap grouping commands
    private ICommand _groupByStateCommand;
    public ICommand GroupByStateCommand
    {
        get
        {
            if (_groupByStateCommand == null)
            {
                _groupByStateCommand = new RelayCommand(
                    param => GroupByState(),
                    param => true);
            }
            return _groupByStateCommand;
        }
    }
    private void GroupByState()
    {
        _groupingCollection.ArrangeItems(new StateSorter(), (x => x.State));
        NotifyPropertyChanged("GroupedContacts");
    }
    private ICommand _groupByNameCommand;
    public ICommand GroupByNameCommand
    {
        get
        {
            if (_groupByNameCommand == null)
            {
                _groupByNameCommand = new RelayCommand(
                    param => GroupByName(),
                    param => true);
            }
            return _groupByNameCommand;
        }
    }
    private void GroupByName()
    {
        _groupingCollection.ArrangeItems(new NameSorter(), (x => x.LastName.First().ToString()));
        NotifyPropertyChanged("GroupedContacts");
    }
}
// View Model helpers
public class BaseViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;
    protected virtual void NotifyPropertyChanged([CallerMemberName] string propertyName = null)
    {
        if (PropertyChanged != null)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}
public class RelayCommand : ICommand
{
    readonly Action<object> _execute;
    readonly Predicate<object> _canExecute;
    public RelayCommand(Action<object> execute)
        : this(execute, null)
    {
    }
    public RelayCommand(Action<object> execute, Predicate<object> canExecute)
    {
        if (execute == null)
            throw new ArgumentNullException("execute");
        _execute = execute;
        _canExecute = canExecute;
    }
    public bool CanExecute(object parameter)
    {
        return _canExecute == null ? true : _canExecute(parameter);
    }
    public event EventHandler CanExecuteChanged
    {
        add { } 
        remove { } 
    }
    public void Execute(object parameter)
    {
        _execute(parameter);
    }
}
// Sorter classes
public class NameSorter : Comparer<Contact>
{
    public override int Compare(Contact x, Contact y)
    {
        int result = x.LastName.First().CompareTo(y.LastName.First());
        if (result != 0)
        {
            return result;
        }
        else
        {
            result = x.LastName.CompareTo(y.LastName);
            if (result != 0)
            {
                return result;
            }
            else
            {
                return x.FirstName.CompareTo(y.FirstName);
            }
        }
    }
}
public class StateSorter : Comparer<Contact>
{
    public override int Compare(Contact x, Contact y)
    {
        int result = x.State.CompareTo(y.State);
        if (result != 0)
        {
            return result;
        }
        else
        {
            result = x.LastName.CompareTo(y.LastName);
            if (result != 0)
            {
                return result;
            }
            else
            {
                return x.FirstName.CompareTo(y.FirstName);
            }
        }
    }
}
// Grouping class 
// credit
// http://motzcod.es/post/94643411707/enhancing-xamarinforms-listview-with-grouping
public class Grouping<K, T> : ObservableCollection<T>
{
    public K Key { get; private set; }
    public Grouping(K key, IEnumerable<T> items)
    {
        Key = key;
        foreach (var item in items)
        {
            this.Items.Add(item);
        }
    }
}

最后,按如下方式编辑主页

  <Page.DataContext>
        <local:ViewModel />
    </Page.DataContext>
    <Page.Resources>
        <CollectionViewSource 
            x:Key="cvs" 
            Source="{Binding GroupedContacts}" 
            IsSourceGrouped="True" />
    </Page.Resources>
    <Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
        <Grid.RowDefinitions>
            <RowDefinition Height="*" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>
        <ListView ItemsSource="{Binding Source={StaticResource cvs}}"
                  x:Name="targetListBox">
            <ListView.ItemTemplate>
                <DataTemplate>
                    <Grid>
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="100" />
                            <ColumnDefinition Width="100" />
                            <ColumnDefinition Width="*" />
                        </Grid.ColumnDefinitions>
                        <TextBlock Text="{Binding LastName}" />
                        <TextBlock Text="{Binding FirstName}" Grid.Column="1" />
                        <TextBlock Text="{Binding State}" Grid.Column="2" HorizontalAlignment="Right" />
                    </Grid>
                </DataTemplate>
            </ListView.ItemTemplate>
            <ListView.GroupStyle>
                <GroupStyle>
                    <GroupStyle.HeaderTemplate>
                        <DataTemplate>
                            <Grid Background="Gainsboro">
                                <TextBlock FontWeight="Bold" 
                                           FontSize="14" 
                                           Margin="10,2"
                                           Text="{Binding Key}"/>
                            </Grid>
                        </DataTemplate>
                    </GroupStyle.HeaderTemplate>
                </GroupStyle>
            </ListView.GroupStyle>
        </ListView>
        <StackPanel Orientation="Horizontal" Grid.Row="1">
            <Button Content="Group By Initial" Command="{Binding GroupByNameCommand}" />
            <Button Content="Group By State" Command="{Binding GroupByStateCommand}" />
        </StackPanel>
    </Grid>

HandleCollectionChanged 方法到目前为止仅处理添加/删除,如果 NotifyCollectionChangedEventArgs 参数包含多个项目,则会分解(现有 ObservableCollection 类一次只通知一个更改)

所以它工作正常,但感觉有点笨拙。

非常欢迎改进建议。