I have a large collection of items bound to a ListBox
, with a VirtualizingStackPanel
set as its ItemsPanel
. As the user scrolls and item containers are created, I do some work to populate the item with data (using a database query). If the user scrolls very rapidly, it builds up a large number of requests that tend to bog things down. What I would like to do is detect when the item is scrolled outside of the viewport, so I can cancel its corresponding request.
Here are the approaches I've tried thus far, and why they have not worked:
Override VirtualizingStackPanel.OnCleanUpVirtualizedItem
. The
problem is that this method seems to be called sometime much later
than when the item actually goes off-screen. Cancelling my request
within this method doesn't do much good because it occurs so late.
Turn on container recycling with
VirtualizationMode.Recycling
. This event causes the item
container's DataContext
to change but the item container itself is
reused. The DataContextChanged
event occurs immediately as one
item goes outside of view, so it is good in that regard. The problem
is that container recycling creates a lot of side-effects and, in my
testing, is a little buggy overall. I would prefer not to use it.
Is there are a good lower-level approach, such as hooking into layout events, that can give me a deterministic answer on when an item goes outside of view? Perhaps at the ScrollViewer
level?
Here's a rough solution that I think accomplishes what you're looking for. I'm getting the virtualizing stack panel by listening to the loaded event in the XAML. If I were doing this in production code, I might factor this into a reusable attached behavior rather than throwing a bunch of code in the code-behind.
public partial class MainWindow
{
private VirtualizingStackPanel _panel;
public MainWindow()
{
InitializeComponent();
DataContext = new MyViewModel();
}
private IList<ChildViewModel> _snapshot = new List<ChildViewModel>();
private void OnPanelLoaded(object sender, RoutedEventArgs eventArgs)
{
_panel = (VirtualizingStackPanel)sender;
UpdateSnapshot();
_panel.ScrollOwner.ScrollChanged += (s,e) => UpdateSnapshot();
}
private void UpdateSnapshot()
{
var layoutBounds = LayoutInformation.GetLayoutSlot(_panel);
var onScreenChildren =
(from visualChild in _panel.GetChildren()
let childBounds = LayoutInformation.GetLayoutSlot(visualChild)
where layoutBounds.Contains(childBounds) || layoutBounds.IntersectsWith(childBounds)
select visualChild.DataContext).Cast<ChildViewModel>().ToList();
foreach (var removed in _snapshot.Except(onScreenChildren))
{
// TODO: Cancel pending calculations.
Console.WriteLine("{0} was removed.", removed.Value);
}
_snapshot = onScreenChildren;
}
}
Notice that there isn't really a property we can use here to find the on-screen children, so we look at the layout bounds of the parent compared to the children to determine which children are on screen.
The code uses an extension method for getting the visual children of an item in the visual tree, included below:
public static class MyVisualTreeHelpers
{
public static IEnumerable<FrameworkElement> GetChildren(this DependencyObject dependencyObject)
{
var numberOfChildren = VisualTreeHelper.GetChildrenCount(dependencyObject);
return (from index in Enumerable.Range(0, numberOfChildren)
select VisualTreeHelper.GetChild(dependencyObject, index)).Cast<FrameworkElement>();
}
}
This code is using a very basic view model hierarchy I created for the purposes of testing this out. I'll include it just in case it's helpful in understanding the other code:
public class MyViewModel
{
public MyViewModel()
{
Children = new ObservableCollection<ChildViewModel>(GenerateChildren());
}
public ObservableCollection<ChildViewModel> Children { get; set; }
private static IEnumerable<ChildViewModel> GenerateChildren()
{
return from value in Enumerable.Range(1, 1000)
select new ChildViewModel {Value = value};
}
}
public class ChildViewModel
{
public int Value { get; set; }
}
XAML:
<Window x:Class="WpfTest.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:wpfTest="clr-namespace:WpfTest"
Title="MainWindow" Height="500" Width="500">
<ListBox ItemsSource="{Binding Children}">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel Loaded="OnPanelLoaded" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate DataType="wpfTest:ChildViewModel">
<TextBlock Text="{Binding Value}" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Window>
On the viewmodel side you could watch attach and detach from INotifyPropertyChanged event:
public event PropertyChangedEventHandler PropertyChanged
{
add
{
if(this.InternalPropertyChanged == null)
Console.WriteLine("COMING INTO VIEW");
this.InternalPropertyChanged += value;
}
remove
{
this.InternalPropertyChanged -= value;
if(this.InternalPropertyChanged == null)
Console.WriteLine("OUT OF VIEW");
}
}
private event PropertyChangedEventHandler InternalPropertyChanged;
Note: Without VirtualizationMode.Recycling
a ListBox may defer the container destruction (and hence the detach) until the user stops scrolling. This can increase memory consumption extensivly, especially if the ItemTemplate is complex (and also won't cancel your DB queries).
You can possibly try to add.
scrollviewer.veriticalscroll = "auto"
That will cause it to only scroll for the amount of items already in the listbox