UWP ObservableCollection排序和分组

时间:2016-01-21 04:10:41

标签: c# sorting listview win-universal-app uwp

在UWP应用程序中,如何对ObservableCollection进行分组和排序并保持所有实时通知的良好性?

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

++

假设您有一个现有的datacache后端,该后端公开了一个非常简单的类Contact的ObservableCollection。

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

此ObservableCollection随时间而变化,我们希望在更新的视图中显示实时分组和排序列表 响应datacache的变化。我们还希望为用户提供在运行时切换LastName和State之间分组的选项。

++

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

public class WpfViewModel 
{
    public WpfViewModel()
    {
        _cache = GetCache();
    }

    Cache _cache;

    public ObservableCollection<Contact> Contacts
    {
        get { return _cache.Contacts; }
    }
}

然后我们可以将它绑定到一个视图,我们将CollectionViewSource和Sort和Group定义实现为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);
     }
}

一切正常,项目会随着数据缓存集合的变化而自动更新。 Listview分组和选择不受集合更改的影响,并且新的联系人项目已正确分组。在运行时,用户可以在State和LastName之间交换分组。

++

在UWP世界中,CollectionViewSource不再具有GroupDescriptions和SortDescriptions集合,并且需要在ViewModel级别执行排序/分组。我找到的最接近可行解决方案的方法是微软的样本包

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

和本文

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

其中ViewModel使用Linq对ObservableCollection进行分组,并将其作为分组项的ObservableCollection显示给视图

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;
}

其中GroupInfoList定义为

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

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

同时交换分组似乎需要为每个分组场景完全单独的ObservableCollection分组项,并且要在运行时交换ListView的ItemSource绑定。

我在UWP环境中看到的其余内容似乎非常有用,所以我很惊讶地找到了像分组和排序列表一样重要的东西...... ...

任何人都知道如何正确地做到这一点?

2 个答案:

答案 0 :(得分:5)

我已经开始组建一个名为GroupedObservableCollection的库,它可以为我的某个应用程序执行这些操作。

我需要解决的一个关键问题是刷新用于创建组的原始列表,即我不希望用户搜索具有稍微不同的标准以使整个列表成为精神焕发,只是差异。

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

答案 1 :(得分:1)

目前为止的最佳努力使用了以下助手类ObservableGroupingCollection

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 Blank应用程序中,添加上面的ObservableGroupingCollection类。然后在同一名称空间中添加另一个类文件,并添加以下所有类

// 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);
        }
    }
}

最后,编辑MainPage如下

  <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类仅一次通知一个更改),则会分解

所以它运作正常,但这一切都让人觉得有点hacky。

欢迎提出改进建议。