WPF: Binding to ListBoxItem.IsSelected doesn't

2019-03-19 20:06发布

问题:

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.

  1. If you click the first item and then press Shift+End, all 200 items should be selected; however, the heading reports that only 21 items are selected.
  2. If you click "Select all" then all items are indeed selected. If you then click an item in the ListBox you would expect the other 199 items to be deselected, but this does not happen. Instead, only the items that are on the screen (and a few others) are deselected. All 199 items will not be deselected unless you first scroll through the list from beginning to end (and even then, oddly enough, it doesn't work if you perform scrolling with the little scroll box).

My questions are:

  1. Can someone explain precisely why this occurs?
  2. Can I avoid or work around the problem?

回答1:

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.



回答2:

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 });
                }
            }
        }
    }
}


回答3:

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.