Home > Software engineering >  Show empty groups in ListBox
Show empty groups in ListBox

Time:06-26

I am trying to display a ListBox that has items grouped by a property named Status. That is easy enough using CollectionViewSource.GroupDescriptions, but I run into problems when I want to show empty groups like this:

List box with empty groups

I discovered how to add Group names to a group description but they are not unique. Running this code displays all statuses:

var collectionViewSource = (CollectionViewSource)FindResource("Source");
var groupDescription = new PropertyGroupDescription("Status");
foreach (var name in Enum.GetNames(typeof(Status)))
{
    groupDescription.GroupNames.Add(name);
}
collectionViewSource.GroupDescriptions.Add(groupDescription);

but all the groups with items in are repeated. Also these manually created groups do not have items inside and are a total duplicates different from the groups WPF created:

enter image description here

I figured I could listen to the CollectionChanged on the GroupNames ObservableCollection and add or remove these custom group names manually but GroupNames is always empty (unless the groups names are added manually as in the above snippet) so CollectionChanged is never fired.

Here is the code without the custom GroupName logic:

C#:

    public enum Status
    {
        Present,
        Sick,
        Vacation,
        BusinessTrip
    }
    
    public class ViewModel
    {
        public string Id { get; }
        
        public Status Status { get; }
        
        public ViewModel(string id, Status status)
        {
            Id = id;
            Status = status;
        }
    }
    
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow
    {
        public MainWindow()
        {
            InitializeComponent();

            DataContext = new List<ViewModel> {
                new ("Zack", Status.Present),
                new ("Adam", Status.Sick),
                new ("Matt", Status.Present),
                new ("Nick", Status.Present),
                new ("Boss", Status.Vacation),
                new ("Phil", Status.Vacation)
            };

            var collectionViewSource = (CollectionViewSource)FindResource("Source");
            var groupDescription = new PropertyGroupDescription("Status");
            collectionViewSource.GroupDescriptions.Add(groupDescription);
        }
    }

XAML:

<Window x:Class="EmptyGroups.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.Resources>
        <CollectionViewSource x:Key="Source" Source="{Binding}"/>
    </Window.Resources>
    <Grid>
        <ListBox ItemsSource="{Binding Source={StaticResource Source}}">
            <ListBox.GroupStyle>
                <GroupStyle>
                    <GroupStyle.ContainerStyle>
                        <Style TargetType="GroupItem">
                        <Setter Property="Template">
                            <Setter.Value>
                                <ControlTemplate TargetType="{x:Type GroupItem}">
                                    <Expander>
                                        <Expander.Header>
                                            <StackPanel Orientation="Horizontal">
                                                <TextBlock Text="{Binding Name}" VerticalAlignment="Center"/>    
                                                <TextBlock Margin="10" Background="Aqua" Text="{Binding ItemCount}"/>
                                            </StackPanel>
                                        </Expander.Header>
                                        <ItemsControl ItemsSource="{Binding Items}">
                                            <ItemsControl.ItemTemplate>
                                                <DataTemplate>
                                                    <TextBlock Margin="50 10" Text="{Binding Id}"/>
                                                </DataTemplate>
                                            </ItemsControl.ItemTemplate>
                                        </ItemsControl>
                                    </Expander>
                                </ControlTemplate>
                            </Setter.Value>
                        </Setter>
                        </Style>
                    </GroupStyle.ContainerStyle>
                    
                </GroupStyle>
            </ListBox.GroupStyle>
        </ListBox>
    </Grid>
</Window>

Any ideas? I have a feeling there is a different and better way of getting this Empty Group feature.

Thanks!

CodePudding user response:

I found a solution, tho it still feels hacky...

Create a class that inherits from CollectionViewSource:

public class CustomCollectionViewSource : CollectionViewSource
{
    public static readonly DependencyProperty AllGroupNamesProperty = DependencyProperty.Register(
        "AllGroupNames", typeof(List<string>), typeof(CustomCollectionViewSource),
        new PropertyMetadata(new List<string>(), OnAllGroupNamesChanged));

    private static void OnAllGroupNamesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) =>
        ((CustomCollectionViewSource) d).UpdateGroupNames();

    public List<string> AllGroupNames
    {
        get => (List<string>) GetValue(AllGroupNamesProperty);
        set => SetValue(AllGroupNamesProperty, value);
    }

    public CustomCollectionViewSource()
    {
        DependencyPropertyDescriptor
            .FromProperty(ViewProperty, typeof(CustomCollectionViewSource))
            .AddValueChanged(this, OnViewChanged);
    }

    private void OnViewChanged(object sender, EventArgs e) => UpdateGroupNames();

    private void OnViewCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) => UpdateGroupNames();

    private void UpdateGroupNames()
    {
        if (View == null) return;
        if (View.GroupDescriptions.Count != 1)
            throw new Exception("CustomCollectionViewSource must have exactly one GroupDescription!");

        View.CollectionChanged -= OnViewCollectionChanged;
        try
        {
            var groupDescription = View.GroupDescriptions.First();
            groupDescription.GroupNames.Clear();
            var names = View.Groups.Cast<CollectionViewGroup>().Select(cvg => cvg.Name.ToString());
            foreach (var s in AllGroupNames.Except(names))
                groupDescription.GroupNames.Add(s);
        }
        finally
        {
            View.CollectionChanged  = OnViewCollectionChanged;
        }
    }
}

Then use it in instead of CollectionViewSource:

<emptyGroups:CustomCollectionViewSource x:Key="Source" Source="{Binding}">
    <emptyGroups:CustomCollectionViewSource.GroupDescriptions>
         <PropertyGroupDescription PropertyName="Status"/>
    </emptyGroups:CustomCollectionViewSource.GroupDescriptions>
</emptyGroups:CustomCollectionViewSource>

Then there are two ways to specify the groups:

  1. Assign them manually in code behind:

    var collectionViewSource = (CustomCollectionViewSource)FindResource("Source");
    foreach (var status in Enum.GetNames(typeof(Status)))
    {
        collectionViewSource.AllGroupNames.Add(status);    
    }
    
  2. Use the XAML:

    <emptyGroups:CustomCollectionViewSource x:Key="Source" Source="{Binding}">
         <emptyGroups:CustomCollectionViewSource.AllGroupNames>
             <system:String>Present</system:String>
             <system:String>Sick</system:String>
             <system:String>Vacation</system:String> 
             <system:String>BusinessTrip</system:String>
         </emptyGroups:CustomCollectionViewSource.AllGroupNames>
         <emptyGroups:CustomCollectionViewSource.GroupDescriptions>
             <PropertyGroupDescription PropertyName="Status"/>
         </emptyGroups:CustomCollectionViewSource.GroupDescriptions>
    </emptyGroups:CustomCollectionViewSource>
    

One problem is the empty groups are always displayed on top of the groups with items in. This can be fixed by setting groupDescription.CustomSort in CustomCollectionViewSource inside UpdateGroupNames :

  private void UpdateGroupNames() {
    if (View == null) return;
    if (View.GroupDescriptions.Count != 1)
      throw new Exception("CustomCollectionViewSource must have exactly one GroupDescription!");

    View.CollectionChanged -= OnViewCollectionChanged;
    try {
      var groupDescription = View.GroupDescriptions.First();
      groupDescription.GroupNames.Clear();
      var names = View.Groups.Cast<CollectionViewGroup>().Select(cvg => cvg.Name.ToString());
      foreach (var s in AllGroupNames.Except(names))
        groupDescription.GroupNames.Add(s);

      groupDescription.CustomSort = new SortByGroupCount(); // Add if you want to order the groups by items.
    } finally {
      View.CollectionChanged  = OnViewCollectionChanged;
    }
  }

Here is SortByGroupCount:

public class SortByGroupCount : IComparer {
  public int Compare(object x, object y) {
    if (x is CollectionViewGroup xGroup && y is CollectionViewGroup yGroup) {
      return xGroup.Items.Count > yGroup.Items.Count ? 1 : -1;
    }
    return 0;
  }
}
  • Related