Search code examples
c#wpfmvvmdata-bindingobservablecollection

How to sort listbox ItemSource without calling c'tor of all objects? WPF


I have the following listbox

<ListBox SelectionMode="Extended" ItemsSource="{Binding Containers}" AllowDrop="True" Margin="0,0,5,0">
<ListBox.ItemContainerStyle>
    <Style TargetType="ListBoxItem">
        <Setter Property="IsSelected" Value="{Binding Content.IsSelected, Mode=TwoWay, RelativeSource={RelativeSource Self}}"/>
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="ListBoxItem">
                    <ContentPresenter/>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
    <DataTemplate DataType="{x:Type vm:ContainerViewModel}">
        <local:ContainerUserControl DataContext="{Binding}" />
    </DataTemplate>
</ListBox.ItemTemplate>
<ListBox.ItemsPanel>
    <ItemsPanelTemplate>
        <VirtualizingStackPanel IsVirtualizing="True" VirtualizationMode="Recycling" />
    </ItemsPanelTemplate>
</ListBox.ItemsPanel>

ContainerUserControl is the user control that has an expander (with header and content). The view model is ContainerViewModel

The item source for the binding is:

    private ObservableCollection<ContainerViewModel> _containers;
    public ObservableCollection<ContainerViewModel> Containers
    {
        get => _containers;
        set
        {
            _containers = value;
            OnPropertyChanged();
        }
    }

The problem is that when i assign new collection for Containers, the constructor of each element is being called:

public partial class ContainerUserControl : UserControl
{
    public ContainerUserControl()
    {
        InitializeComponent();
        Debug.Print("in ContainerUserControl");
    }
}

and if i have thousands of items, it may take very long time. now let's say i have 10k items and i want to sort this collection using this code:

Containers = new ObservableCollection<ContainerViewModel>(Containers.OrderByDescending(i => i.Name));

i will see that the usercontrol constructor is being called 10k times. After reading some posts, i decided to implement in-place sorting by using "Move" method, but unfortunately, even if i do:

_containers.Move(0, 1);

i see that i go through userControl c'tor. and if i have thousands of move operations it's like using the orderby method and assign the sorted list. moreover, i tried to create a new sorted collection and just switch between itemsSource, and it didn't help, still entered c'tor 10k times.

public ObservableCollection<ContainerViewModel> SortedContainers { get; set; } // already sorted
public ListBox ContainerListBox { get; set; } // the listbox from xaml

ContainerListBox.ItemsSource = null;
ContainerListBox.ItemsSource = SortedContainers;

No matter what i tried, i could not avoid from the c'tor to be called thousands of time and make terrible performance issues. How can i avoid the c'tor calling? why this c'tor is being called no matter what?

Any help will be appreciated :)

EDIT: i made some time measurements for 500 objects: i see that each c'tor usually takes 1-2 ms but sometimes more than 20ms. moreover, the total time for constructing the 500 objects collection is greater than sum all c'tor time together. here are the start and the end of my measurements: \nstart: enter image description here end: enter image description here

you can see that the sum of all c'tor times is 1169ms but the total time is 18:52:25.835 - 18:52:29.153 which is 3.318 SECONDS.(usually it takes about 4 seconds for 500 objects so thing about 10k and even more) why is that?

EDIT2: I just noticed that not all my listbox code was pasted in the code section, the last part was missing... the rest is:

    <ListBox.Template>
        <ControlTemplate TargetType="ListBox">
            <Border x:Name="border" BorderThickness="{TemplateBinding BorderThickness}" AllowDrop="True"
                    BorderBrush="{TemplateBinding BorderBrush}"
                    Background="{TemplateBinding Background}">
                <ScrollViewer CanContentScroll="False" Padding="{TemplateBinding Padding}" Focusable="False">
                    <ItemsPresenter />
                </ScrollViewer>
            </Border>
        </ControlTemplate>
    </ListBox.Template>
</ListBox>

after long long investigation, i tried to make a small demo and started step by step building the control to see where the virtualization is messed. long story short - after lots of playing around with the controls, i found out that the CanContentScroll="False" is block the virtualization. so the solution is to set it to true and add to listbox VirtualizingPanel.ScrollUnit="Pixel" to achieve the same behavior and keep the virtualization.

thanks to @BionicCode for the instructions that helped me find the problem and the solution.


Solution

  • The ListBox uses UI virtualization. It does not load all items. Only those that are within the virtualization viewport. Because virtualization is enabled by default the overriding of the ListBox.ItemPanel is redundant.
    Same applies to the DataCOntext binding inside the DataTemplate: the DataContext of the DataTemplate (or of the parent element in general) is implicitly inherited. No need to set it explicitly.

    In your case all items are loaded because your ListBox has no height constraint. It will automatically stretch to make all items fit. To enable UI virtualization assign the ListBox a Height so that the ScrollViewer can work. The ScrollViewer is essential for UI virtualization.
    The height can be set explicitly, for example by setting the ListBox.Height property or implicitly, for example by adding the ListBox to a Grid where the row height is set to anything but Auto.

    In addition you should not replace the source collection. It's not efficient at all. This seriously compromises performance. Filtering, sorting and grouping is done by modifying the CollectionView that is bound to the ItemsControl. In WPF the binding engine will automatically use the default ColectionView to display the items. This allows you to modify the displayed items without modifying the original underlying collection. For example, sorting a CollectionView will not sort the underlying collection.

    You use the static CollectionViewSource.GetDefaultView method to obtain the default CollectionView:

    <ListBox ItemsSource="{Binding Containers}"
             VirtualizingPanel.VirtualizationMode="Recycling"
             Height="300">
      <ListBox.ItemTemplate>
        <DataTemplate DataType="ContainerViewModel">
          <ContainerUserControl />
        </DataTemplate>
      </ListBox.ItemTemplate>
    </ListBox>
    
    private ObservableCollection<ContainerViewModel> _containers;
    public ObservableCollection<ContainerViewModel> Containers
    {
      get => _containers;
      set
      {
        _containers = value;
        OnPropertyChanged();
      }
    }
    
    private void SortContainersByName()
    {
      var sortDescription = new SortDescription(nameof(ContainerViewModel.Name), ListSortDirection.Ascending);
      ICollectionView containersView = CollectionViewSource.GetDefaultView(this.Containers);
    
      // Apply sorting criteria
      containersView.SortDescriptions.Add(sortDescription);
    }
    
    private void ClearSortContainersByName()
    {
      SortDescription sortDescriptionToRemove = this.DataGridItemsView.SortDescriptions
        .FirstOrDefault(sortDescription => sortDescription.PropertyName.Equals(nameof(ContainerViewModel.Name), StringComparison.Ordinal));
      ICollectionView containersView = CollectionViewSource.GetDefaultView(this.Containers);
    
      // Clear sorting criteria
      containersView.SortDescriptions.Remove(sortDescriptionToRemove);
    }