WPF:绑定到ListBoxItem.IsSelected对于屏幕外的项目无效

9
在我的程序中,我有一组视图模型对象来表示ListBox中的项目(允许多选)。视图模型有一个IsSelected属性,我希望将其绑定到ListBox上,以便选择状态在视图模型中管理,而不是在ListBox本身中管理。
然而,显然ListBox不会维护大多数屏幕外的项目的绑定,因此通常情况下IsSelected属性无法正确同步。以下是一些演示问题的代码。首先是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# 全选处理程序:

private void TestSelectAll_Click(object sender, RoutedEventArgs e)
{
    foreach (var item in _dataContext.Items)
        item.IsSelected = true;
}

C#视图模型:

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

可以观察到两个单独的问题。

  1. 如果您点击第一个项目,然后按Shift + End,应选择所有200个项目;然而,标题报告仅选择了21个项目。
  2. 如果您点击“全选”则确实选择了所有项目。如果您随后点击 ListBox 中的一个项目,则预期其它199个项目将被取消选择,但实际上并没有发生这种情况。相反,只有屏幕上显示的项目(以及一些其他项目)会被取消选择。除非您从头到尾滚动列表(即使使用小的滚动框进行滚动也无效),否则不会取消选择所有199个项目。

我的问题是:

  1. 有人能够准确解释为什么会出现这种情况吗?
  2. 我能够避免或解决这个问题吗?

确实是一种奇怪的行为...禁用虚拟化绝不是一个选择。 - JobaDiniz
3个回答

12

ListBox 默认情况下是 UI 虚拟化的。这意味着在任何时刻,只有可见的项 (以及一小部分 "几乎可见" 的项) 在 ItemsSource 中实际呈现。这就解释了为什么更新源会按预期工作(因为这些项始终存在),但导航 UI 不起作用(因为那些项的视觉表示是动态创建和销毁的,而且从未同时存在)。

如果您想关闭此行为,则可以在您的 ListBox 上设置 ScrollViewer.CanContentScroll=False。这将启用 "平滑" 滚动,并隐式地关闭虚拟化。要显式禁用虚拟化,可以设置 VirtualizingStackPanel.IsVirtualizing=False


1
或者您可以使用VirtualizingStackPanel.IsVirtualizing来显式禁用它 :-) - CodeNaked
@CodeNaked 当然,那也可以运行 :) 我会更新以包含它。 - dlev
2
哇,这对性能来说太糟糕了。如果我在测试列表中放入10000个项目,显示列表似乎需要花费很长时间,全选也需要很长时间,并且根据任务管理器的记录,内存使用量增加了近80 MB(这意味着WPF每个项目需要8KB,记住,这是没有DataTemplate的“琐碎”项目!有时候,微软,你让我感到恶心!)。我的应用程序只需要大约1000个项目,但是,为MVVM付出如此巨大的代价仍然不划算。 - Qwertie
哦,另外,WPF 在 10000 个项目列表中按箭头键响应需要 4 秒钟的时间。真是惊人。 - Qwertie
@Qwertie,这种性能是为什么默认情况下会打开ListBox虚拟化的原因。这真是一个悲伤的故事。 - dlev
显示剩余2条评论

2

关闭虚拟化通常不可行。因为许多项目会导致性能变得非常糟糕。

对我有效的技巧是在列表框的ItemContainerGenerator上附加一个StatusChanged监听器。当新项目被滚动到视图中时,监听器将被调用,如果绑定不存在,您可以设置绑定。

在Example.xaml.cs文件中:

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

为了改进这个答案,我们不需要使用 ViewModel;我们可以仅使用 generator,例如使用 generator.Items(Items 是绑定的集合)。 - JobaDiniz

1
有一种方法可以解决这个问题,而不需要禁用虚拟化(这会影响性能)。问题(如前面的答案中所提到的)在于您不能依赖ItemContainerStyle可靠地更新所有视图模型上的IsSelected,因为项目容器仅存在于可见元素中。但是,您可以从ListBox的SelectedItems属性中获取完整的选定项目集。
这需要来自Viewmodel到视图的通信,这通常是违反MVVM原则的。但是,有一种模式可以使其正常工作并保持您的ViewModel可单元测试。创建一个视图接口供VM进行交互:
public interface IMainView
{
    IList<MyItemViewModel> SelectedItems { get; }
}

在您的视图模型中添加一个 View 属性:
public IMainView View { get; set; }

在您的视图中订阅OnDataContextChanged,然后运行以下代码:

this.viewModel = (MainViewModel)this.DataContext;
this.viewModel.View = this;

同时实现SelectedItems属性:
public IList<MyItemViewModel> SelectedItems => this.myList.SelectedItems
    .Cast<MyItemViewModel>()
    .ToList();

然后在你的视图模型中,你可以通过 this.View.SelectedItems 获取所有选定的项目。

当你编写单元测试时,你可以设置 IMainView 做任何你想做的事情。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接