In my program I have a set of view-model objects to represent items in a ListBox (multi-select is allowed). The viewmodel has an IsSelected property that I would like to bind to the ListBox so that selection state is managed in the viewmodel rather than in the listbox itself.
However, apparently the ListBox doesn't maintain bindings for most of the off-screen items, so in general the IsSelected property is not synchronized correctly. Here is some code that demonstrates the problem. First XAML:
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock>Number of selected items: </TextBlock>
<TextBlock Text="{Binding NumItemsSelected}"/>
</StackPanel>
<ListBox ItemsSource="{Binding Items}" Height="200" SelectionMode="Extended">
<ListBox.ItemContainerStyle>
<Style TargetType="{x:Type ListBoxItem}">
<Setter Property="IsSelected" Value="{Binding IsSelected}"/>
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
<Button Name="TestSelectAll" Click="TestSelectAll_Click">Select all</Button>
</StackPanel>
C# Select All handler:
private void TestSelectAll_Click(object sender, RoutedEventArgs e)
{
foreach (var item in _dataContext.Items)
item.IsSelected = true;
}
C# viewmodel:
public class TestItem : NPCHelper
{
TestDataContext _c;
string _text;
public TestItem(TestDataContext c, string text) { _c = c; _text = text; }
public override string ToString() { return _text; }
bool _isSelected;
public bool IsSelected
{
get { return _isSelected; }
set {
_isSelected = value;
FirePropertyChanged("IsSelected");
_c.FirePropertyChanged("NumItemsSelected");
}
}
}
public class TestDataContext : NPCHelper
{
public TestDataContext()
{
for (int i = 0; i < 200; i++)
_items.Add(new TestItem(this, i.ToString()));
}
ObservableCollection<TestItem> _items = new ObservableCollection<TestItem>();
public ObservableCollection<TestItem> Items { get { return _items; } }
public int NumItemsSelected { get { return _items.Where(it => it.IsSelected).Count(); } }
}
public class NPCHelper : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
public void FirePropertyChanged(string prop)
{
if (PropertyChanged != null)
PropertyChanged(this, new PropertyChangedEventArgs(prop));
}
}
Two separate problems can be observed.
My questions are:
ListBox
is, by default, UI virtualized. That means that at any given moment, only the visible items (along with a small subset of "almost visible" items) in the ItemsSource
will actually be rendered. That explains why updating the source works as expected (since those items always exist,) but just navigating the UI doesn't (since the visual representations of those items are created and destroyed on the fly, and never exist together at once.)
If you want to turn off this behaviour, one option is to set ScrollViewer.CanContentScroll=False
on your ListBox
. This will enable "smooth" scrolling, and implicitly turn off virtualization. To disable virtualization explicitly, you can set VirtualizingStackPanel.IsVirtualizing=False
.
Turning off virtualization is often not feasible. As people have noticed, the performance is terrible with lots of items.
The hack that seems to work for me is to attach a StatusChanged listener on the list box's ItemContainerGenerator. As new items are scrolled into view, the listener will be invoked, and you can set the binding if it's not there.
In the Example.xaml.cs file:
// Attach the listener in the constructor
MyListBox.ItemContainerGenerator.StatusChanged += ItemContainerGenerator_StatusChanged_FixBindingsHack;
private void ItemContainerGenerator_StatusChanged_FixBindingsHack(object sender, EventArgs e)
{
ItemContainerGenerator generator = sender as ItemContainerGenerator;
if (generator.Status == GeneratorStatus.ContainersGenerated)
{
foreach (ValueViewModel value in ViewModel.Values)
{
var listBoxItem = mValuesListBox.ItemContainerGenerator.ContainerFromItem(value) as ListBoxItem;
if (listBoxItem != null)
{
var binding = listBoxItem.GetBindingExpression(ListBoxItem.IsSelectedProperty);
if (binding == null)
{
// This is a list item that was just scrolled into view.
// Hook up the IsSelected binding.
listBoxItem.SetBinding(ListBoxItem.IsSelectedProperty,
new Binding() { Path = new PropertyPath("IsSelected"), Mode = BindingMode.TwoWay });
}
}
}
}
}
There's a way around this that doesn't require disabling virtualization (which hurts performance). The issue (as mentioned in the previous answer) is that you can't rely on an ItemContainerStyle to reliably update IsSelected
on all your viewmodels, since the item containers only exist for visible elements. However you can get the full set of selected items from the ListBox's SelectedItems
property.
This requires communication from the Viewmodel to the view, which is normally a no-no for violating MVVM principles. But there's a pattern to make it all work and keep your ViewModel unit testable. Create a view interface for the VM to talk to:
public interface IMainView
{
IList<MyItemViewModel> SelectedItems { get; }
}
In your viewmodel, add a View property:
public IMainView View { get; set; }
In your view subscribe to OnDataContextChanged, then run this:
this.viewModel = (MainViewModel)this.DataContext;
this.viewModel.View = this;
And also implement the SelectedItems property:
public IList<MyItemViewModel> SelectedItems => this.myList.SelectedItems
.Cast<MyItemViewModel>()
.ToList();
Then in your viewmodel you can get all the selected items by this.View.SelectedItems
.
When you write unit tests you can set that IMainView to do whatever you want.
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