I am developing an application that is supposed to display a fairly large amount of items that are loaded from elsewhere (say, a database) in a list/grid-like thing.
As having all the items in memory all the time seems like a waste, I am looking into ways to virtualize a part of my list. VirtualizingStackPanel
seems just like what I need - however, while it seems to do a good job virtualizing the UI of items, I am not sure how to virtualize parts of the underlying item list itself.
As a small sample, consider a WPF application with this as its main window:
<Window x:Class="VSPTest.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="VSPTest" Height="300" Width="300">
<Window.Resources>
<DataTemplate x:Key="itemTpl">
<Border BorderBrush="Blue" BorderThickness="2" CornerRadius="5" Margin="2" Padding="4" Background="Chocolate">
<Border BorderBrush="Red" BorderThickness="1" CornerRadius="4" Padding="3" Background="Yellow">
<TextBlock Text="{Binding Index}"/>
</Border>
</Border>
</DataTemplate>
</Window.Resources>
<Border Padding="5">
<ListBox VirtualizingStackPanel.IsVirtualizing="True" ItemsSource="{Binding .}" ItemTemplate="{StaticResource itemTpl}" VirtualizingStackPanel.CleanUpVirtualizedItem="ListBox_CleanUpVirtualizedItem">
<ListBox.ItemContainerStyle>
<Style TargetType="{x:Type ListBoxItem}">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
</Border>
</Window>
The code-behind that supplies a list should look like this:
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
namespace VSPTest
{
public partial class Window1 : Window
{
private class DataItem
{
public DataItem(int index)
{
this.index = index;
}
private readonly int index;
public int Index {
get {
return index;
}
}
public override string ToString()
{
return index.ToString();
}
}
private class MyTestCollection : IList<DataItem>
{
public MyTestCollection(int count)
{
this.count = count;
}
private readonly int count;
public DataItem this[int index] {
get {
var result = new DataItem(index);
System.Diagnostics.Debug.WriteLine("ADD " + result.ToString());
return result;
}
set {
throw new NotImplementedException();
}
}
public int Count {
get {
return count;
}
}
public bool IsReadOnly {
get {
throw new NotImplementedException();
}
}
public int IndexOf(Window1.DataItem item)
{
throw new NotImplementedException();
}
public void Insert(int index, Window1.DataItem item)
{
throw new NotImplementedException();
}
public void RemoveAt(int index)
{
throw new NotImplementedException();
}
public void Add(Window1.DataItem item)
{
throw new NotImplementedException();
}
public void Clear()
{
throw new NotImplementedException();
}
public bool Contains(Window1.DataItem item)
{
throw new NotImplementedException();
}
public void CopyTo(Window1.DataItem[] array, int arrayIndex)
{
throw new NotImplementedException();
}
public bool Remove(Window1.DataItem item)
{
throw new NotImplementedException();
}
public IEnumerator<Window1.DataItem> GetEnumerator()
{
for (int i = 0; i < count; i++) {
yield return this[i];
}
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
{
return this.GetEnumerator();
}
}
public Window1()
{
InitializeComponent();
DataContext = new MyTestCollection(10000);
}
void ListBox_CleanUpVirtualizedItem(object sender, CleanUpVirtualizedItemEventArgs e)
{
System.Diagnostics.Debug.WriteLine("DEL " + e.Value.ToString());
}
}
}
So, this displays an application with a ListBox
, which is forced to virtualize its items with the IsVirtualizing
attached property. It takes its items from the data context, for which a custom IList<T>
implementation is supplied that creates 10000 data items on the fly (when they are retrieved via the indexer).
For debugging purposes, the text ADD #
(where #
equals the item index) is output whenever an item is created, and the CleanUpVirtualizedItem
event is used to output DEL #
when an item goes out of view and its UI is released by the virtualizing stack panel.
Now, my wish is that my custom list implementation supplies items upon request - in this minimal sample, by creating them on the fly, and in the real project by loading them from the database. Unfortunately, VirtualizingStackPanel
does not seem to behave this way - instead, it invokes the enumerator of the list upon program start and first retrieves all 10000 items!
Thus, my question is: How can I use VirtualizingStackPanel for actual virtualization of data (as in, not loading all the data) rather than just reducing the number of GUI elements?
EDIT: Following dev hedgehog's advice, I have created a custom ICollectionView
implementation. Some of its methods are still implemented to throw NotImplementedException
s, but the ones that get called when the window is opened do not.
However, it seems that about the first thing that is called for that collection view is the GetEnumerator
method, enumerating all 10000 elements again (as evidenced by the debug output, where I print a message for every 1000th item), which is what I was trying to avoid.
Here is an example to reproduce the issue:
Window1.xaml
<Window x:Class="CollectionViewTest.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="CollectionViewTest" Height="300" Width="300"
>
<Border Padding="5">
<ListBox VirtualizingStackPanel.IsVirtualizing="True" ItemsSource="{Binding .}">
<ListBox.ItemTemplate>
<DataTemplate>
<Border BorderBrush="Blue" BorderThickness="2" CornerRadius="5" Margin="2" Padding="4" Background="Chocolate">
<Border BorderBrush="Red" BorderThickness="1" CornerRadius="4" Padding="3" Background="Yellow">
<TextBlock Text="{Binding Index}"/>
</Border>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
<ListBox.ItemContainerStyle>
<Style TargetType="{x:Type ListBoxItem}">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
</Border>
</Window>
Window1.xaml.cs
using System;
using System.ComponentModel;
using System.Collections;
using System.Collections.Specialized;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Windows;
namespace CollectionViewTest
{
public partial class Window1 : Window
{
private class DataItem
{
public DataItem(int index)
{
this.index = index;
}
private readonly int index;
public int Index {
get {
return index;
}
}
public override string ToString()
{
return index.ToString();
}
}
private class MyTestCollection : IList<DataItem>
{
public MyTestCollection(int count)
{
this.count = count;
}
private readonly int count;
public DataItem this[int index] {
get {
var result = new DataItem(index);
if (index % 1000 == 0) {
System.Diagnostics.Debug.WriteLine("ADD " + result.ToString());
}
return result;
}
set {
throw new NotImplementedException();
}
}
public int Count {
get {
return count;
}
}
public bool IsReadOnly {
get {
throw new NotImplementedException();
}
}
public int IndexOf(Window1.DataItem item)
{
throw new NotImplementedException();
}
public void Insert(int index, Window1.DataItem item)
{
throw new NotImplementedException();
}
public void RemoveAt(int index)
{
throw new NotImplementedException();
}
public void Add(Window1.DataItem item)
{
throw new NotImplementedException();
}
public void Clear()
{
throw new NotImplementedException();
}
public bool Contains(Window1.DataItem item)
{
throw new NotImplementedException();
}
public void CopyTo(Window1.DataItem[] array, int arrayIndex)
{
throw new NotImplementedException();
}
public bool Remove(Window1.DataItem item)
{
throw new NotImplementedException();
}
public IEnumerator<Window1.DataItem> GetEnumerator()
{
for (int i = 0; i < count; i++) {
yield return this[i];
}
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
{
return this.GetEnumerator();
}
}
private class MyCollectionView : ICollectionView
{
public MyCollectionView(int count)
{
this.list = new MyTestCollection(count);
}
private readonly MyTestCollection list;
public event CurrentChangingEventHandler CurrentChanging;
public event EventHandler CurrentChanged;
public event NotifyCollectionChangedEventHandler CollectionChanged;
public System.Globalization.CultureInfo Culture {
get {
return System.Globalization.CultureInfo.InvariantCulture;
}
set {
throw new NotImplementedException();
}
}
public IEnumerable SourceCollection {
get {
return list;
}
}
public Predicate<object> Filter {
get {
throw new NotImplementedException();
}
set {
throw new NotImplementedException();
}
}
public bool CanFilter {
get {
return false;
}
}
public SortDescriptionCollection SortDescriptions {
get {
return new SortDescriptionCollection();
}
}
public bool CanSort {
get {
throw new NotImplementedException();
}
}
public bool CanGroup {
get {
throw new NotImplementedException();
}
}
public ObservableCollection<GroupDescription> GroupDescriptions {
get {
return new ObservableCollection<GroupDescription>();
}
}
public ReadOnlyObservableCollection<object> Groups {
get {
throw new NotImplementedException();
}
}
public bool IsEmpty {
get {
throw new NotImplementedException();
}
}
public object CurrentItem {
get {
return null;
}
}
public int CurrentPosition {
get {
throw new NotImplementedException();
}
}
public bool IsCurrentAfterLast {
get {
throw new NotImplementedException();
}
}
public bool IsCurrentBeforeFirst {
get {
throw new NotImplementedException();
}
}
public bool Contains(object item)
{
throw new NotImplementedException();
}
public void Refresh()
{
throw new NotImplementedException();
}
private class DeferRefreshObject : IDisposable
{
public void Dispose()
{
}
}
public IDisposable DeferRefresh()
{
return new DeferRefreshObject();
}
public bool MoveCurrentToFirst()
{
throw new NotImplementedException();
}
public bool MoveCurrentToLast()
{
throw new NotImplementedException();
}
public bool MoveCurrentToNext()
{
throw new NotImplementedException();
}
public bool MoveCurrentToPrevious()
{
throw new NotImplementedException();
}
public bool MoveCurrentTo(object item)
{
throw new NotImplementedException();
}
public bool MoveCurrentToPosition(int position)
{
throw new NotImplementedException();
}
public IEnumerator GetEnumerator()
{
return list.GetEnumerator();
}
}
public Window1()
{
InitializeComponent();
this.DataContext = new MyCollectionView(10000);
}
}
}
You want Data Virtualization
, you have UI Virtualization
right now.
You can take a look more about data virtualization here
To get around the issue where the VirtualizingStackPanel
attempts to enumerate over its entire data source, I stepped through the source code on http://referencesource.microsoft.com (https://referencesource.microsoft.com/#PresentationFramework/src/Framework/System/Windows/Controls/VirtualizingStackPanel.cs)
I'll provide the TLDR here:
If you specified VirtualizingStackPanel.ScrollUnit="Pixel"
you need to make sure all the items displayed/virtualized from its ItemTemplate
are the same size (height). Even if you are a pixel different, all bets are off and you'll most likely trigger a loading of the whole list.
If the items being displayed are not exactly the same height, you must specify VirtualizingStackPanel.ScrollUnit="Item"
.
My Findings:
There are several 'landmines' in the VirtualizingStackPanel
source that trigger an attempt to iterate over the entire collection via the index operator []
. One of these is during the Measurement cycle of which it attempts to update the virtualized container size to make the scrollviewer accurate. If any new items being added during this cycle aren't the same size when in Pixel
mode, it iterates over the whole list to adjust and you are hosed.
Another 'landmine' has something to do with selection and triggering a hard refresh. This is applicable more for grids - but under the hood, its using a DataGridRowPresenter
which derives from VirtualizingStackPanel
. Because it wants to keep selections in sync between refreshing, it attempts to enumerate all. This means we need to disable selection (keep in mind that clicking a row triggers a selection).
I solved this by deriving my own grid and overriding OnSelectionChanged
:
protected override void OnSelectionChanged(SelectionChangedEventArgs e)
{
if(SelectedItems.Count > 0)
{
UnselectAll();
}
e.Handled = true;
}
There seems to be other gotchas, but I haven't been able to reliably trigger them yet. The real 'fix' would be to roll our own VirtualizingStackPanel
with looser constraints for generating the containersize. After all, for large datasets (million+), the accuracy of scrollbar matters much less. If I have time to do this, I'll update my answer with the gist/github repo.
In my tests I used a data virtualization solution available here: https://github.com/anagram4wander/VirtualizingObservableCollection.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With