如何在可导航应用程序中使用MVVM支持ListBox SelectedItems绑定

46

我正在制作一个WPF应用程序,可以通过自定义的“下一步”和“上一步”按钮和命令进行导航(即不使用NavigationWindow)。在一个屏幕上,我有一个需要支持多选(使用Extended模式)的ListBox。我为这个屏幕创建了一个视图模型,并将所选项存储为属性,因为它们需要被保留。

然而,我知道ListBoxSelectedItems属性是只读的。我一直在尝试使用这里的解决方案解决这个问题,但我无法将其纳入我的实现。我发现我无法区分何时取消选择了一个或多个元素,还是当我在屏幕之间导航时(NotifyCollectionChangedAction.Remove在两种情况下都会被引发,因为在导航离开屏幕时,技术上所有所选项目都将被取消选择)。我的导航命令位于一个单独的视图模型中,该视图模型管理每个屏幕的视图模型,因此我不能将与ListBox的视图模型相关的任何实现放在那里。

我发现了其他几个不太优雅的解决方案,但这些解决方案似乎都没有强制执行视图模型和视图之间的双向绑定。

如果有帮助理解我的问题,我可以提供一些源代码。任何帮助都将不胜感激。


1
啊,我明白了,你已经尝试使用行为了。使用BindableCollection来选择项目,它应该可以工作。如果你有更多的问题,就让我知道。描述一下它们,我们会看看的。 - Mare Infinitus
2
请展示一些代码,特别是SelectedItems和XAML。SelectedItems是一个属性吗?怀疑当SelectedItems只是BindableCollection的公共成员而不是属性时会出现这种行为。 - Mare Infinitus
2
啊,我没有意识到属性必须明确地被称为“SelectedItems”(我的被称为“SelectedLanguages”)。现在当我点击“Back”按钮时,在调用RaisePropertyChangedEventHandler的分派程序处,BindableCollection构造函数会抛出一个InvalidOperationException。我尝试在catch块中使用Dispatcher.BeginInvoke放置一个try/catch块,但是当页面导航回来时,列表项不会重新选择。 - kck
这个回答解决了你的问题吗?在MVVM中从DataGrid或ListBox绑定到SelectedItems - StayOnTarget
11个回答

62

尝试在您的数据项上创建一个IsSelected属性,并将ListBoxItem.IsSelected绑定到该属性

<Style TargetType="{x:Type ListBoxItem}">
    <Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
</Style>

3
@Rachel - 你对给模型对象添加像"IsSelected"这样的属性有什么看法?我这样做时遇到了一些困扰,特别是当同一个模型对象实例出现在不止一个地方(比如树形结构)时。当选中其中一个时,所有实例都会被选择。这并不总是期望的结果。而且我认为,模型被视图相关的内容“污染”了。你有类似的经验吗? - paul
3
如果您的“IsSelected”值存储在“DataContext”上,那么这就不重要了。 - Rachel
2
@hypehuman 这可能与虚拟化有关,其中UI仅呈现可见对象(以及一些额外的滚动缓冲区),并只更改项目后面的DataContext。因此,在您的SelectAll中,您需要确保选择所有数据项,因为循环遍历所有UI项不准确,因为只有可见项才会被创建。 - Rachel
2
@Rachel 谢谢。很有道理,但我想我会禁用虚拟化而不是这样做。还有 Ctrl+A、Ctrl+Shift+End,可能还有其他快捷键我不知道。如果我们不能将 SelectedItems 绑定为双向,那就很遗憾了,否则所有的问题都可以自动解决。 - hypehuman
2
@hypehuman 这总是一个选择,但要注意如果您有大量数据集可能会有性能问题。 - Rachel
显示剩余5条评论

24

Rachel的解决方案非常好!但我遇到了一个问题——如果你重写ListBoxItem的样式,就会失去原来应用于它的样式(在我的情况下,这是负责突出显示所选项目等的样式)。您可以通过继承原始样式来避免此问题:

雷切尔的解决方案很好!但我遇到了一个问题——如果你覆盖了 ListBoxItem 的样式,你将失去原先应用于该元素的样式(在我的情况下,这些样式用于突出显示选中项等)。您可以通过继承原始样式来避免这个问题:

<Style TargetType="{x:Type ListBoxItem}" BasedOn="{StaticResource {x:Type ListBoxItem}}">
    <Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
</Style>

注意设置BasedOn(参见此答案)。


这个可以运行,但是如何在ViewModel中获得更改通知?在我的情况下,我只在Model中收到通知。 - Lucy82

13

我无法使 Rachel 的解决方案按照我的期望工作,但我发现 Sandesh 的答案通过创建自定义依赖属性(dependency property)完美地解决了我的问题。我只需要为 ListBox 编写类似的代码:

public class ListBoxCustom : ListBox
{
    public ListBoxCustom()
    {
        SelectionChanged += ListBoxCustom_SelectionChanged;
    }

    void ListBoxCustom_SelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        SelectedItemsList = SelectedItems;
    }

    public IList SelectedItemsList
    {
        get { return (IList)GetValue(SelectedItemsListProperty); }
        set { SetValue(SelectedItemsListProperty, value); }
    }

    public static readonly DependencyProperty SelectedItemsListProperty =
       DependencyProperty.Register(nameof(SelectedItemsList), typeof(IList), typeof(ListBoxCustom), new PropertyMetadata(null));

}
在我的视图模型中,我只是引用了该属性来获取我的选定列表。

我喜欢这个答案,但我可能会稍微调整代码,如下所示: https://pastebin.com/YTccwmxG - maxp
在视图模型中,未引用属性...你能否也分享一下 XAML 代码? - Moumit
1
@Moumit 看起来对于单向绑定来说还不错,但它能否适用于 ObservableCollection<T> 并实现双向绑定呢? - Wobbles

6

我一直在寻找一个简单的解决方案,但没有成功。

如果您已经在ItemsSource中的对象上拥有Selected属性,则Rachel提供的解决方案很好。 如果没有,您需要为该业务模型创建一个模型。

我选择了不同的路线。 很快,但不完美。

在您的ListBox上创建SelectionChanged事件。

<ListBox ItemsSource="{Binding SomeItemsSource}"
         SelectionMode="Multiple"
         SelectionChanged="lstBox_OnSelectionChanged" />

现在,在您的XAML页面的代码后台实现该事件。
private void lstBox_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
    var listSelectedItems = ((ListBox) sender).SelectedItems;
    ViewModel.YourListThatNeedsBinding = listSelectedItems.Cast<ObjectType>().ToList();
}

完成了。这是通过将SelectedItemCollection转换为List来完成的。


你如何将listSelectedItems传递到你的ViewModel中? - Tyler Wilson
如果我没记错的话,你正在创建一个独立的ViewModel实例。因此,绑定到“ListThatNeedsBinding”的将是一个与我现有的ViewModel不同的独立实例。这样理解对吗? - Tyler Wilson
你的 ViewModel 必须是一个独立的类,因此它确实是一个单独的“实例”。一个视图将包含一个 XAML 和一个类。所以在你的视图类中,你需要一个 ViewModel 的实例(它是一个独立的类)。在那个 ViewModel 中有一个叫做“YourListThatNeedsBinding”的对象。提示:“ViewModel.YourListThatNeedsBinding =”希望这可以帮助你。 - AzzamAziz

4

这里有另一个解决方案。它与Ben的答案类似,但绑定工作是双向的。诀窍在于当绑定的数据项更改时更新ListBox的选定项。

public class MultipleSelectionListBox : ListBox
{
    public static readonly DependencyProperty BindableSelectedItemsProperty =
        DependencyProperty.Register("BindableSelectedItems",
            typeof(IEnumerable<string>), typeof(MultipleSelectionListBox),
            new FrameworkPropertyMetadata(default(IEnumerable<string>),
                FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnBindableSelectedItemsChanged));

    public IEnumerable<string> BindableSelectedItems
    {
        get => (IEnumerable<string>)GetValue(BindableSelectedItemsProperty);
        set => SetValue(BindableSelectedItemsProperty, value);
    }

    protected override void OnSelectionChanged(SelectionChangedEventArgs e)
    {
        base.OnSelectionChanged(e);
        BindableSelectedItems = SelectedItems.Cast<string>();
    }

    private static void OnBindableSelectedItemsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is MultipleSelectionListBox listBox)
            listBox.SetSelectedItems(listBox.BindableSelectedItems);
    }
}

很遗憾,我无法将IList用作BindableSelectedItems类型。 这样做会向我的视图模型属性发送null,该属性的类型为IEnumerable<string>

以下是XAML代码:

<v:MultipleSelectionListBox
    ItemsSource="{Binding AllMyItems}"
    BindableSelectedItems="{Binding MySelectedItems}"
    SelectionMode="Multiple"
    />

有一件事需要注意。在我的情况下,ListBox可能会从视图中删除。由于某种原因,这会导致SelectedItems属性更改为空列表。这反过来又导致视图模型的属性被更改为空列表。根据您的使用情况,这可能不是理想的。


2

由于我不是专家,实现绑定/使用SelectedItems花费了我一些时间,因此我想分享我的解决方案,如果有人觉得有用的话。别忘了从Nuget下载Microsoft.Xaml.Behaviors.Wpf以获取此解决方案。

我受益于访问WPF ListBox SelectedItems

视图:

Window x:Class="WpfAppSelectedItems.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:i="http://schemas.microsoft.com/xaml/behaviors" 
        xmlns:local="clr-namespace:WpfAppSelectedItems"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    
    <Grid>
        <ListBox Height="250" Width="300"
            ItemsSource="{Binding Items}" SelectionMode="Extended"
            >
            <ListBox.ItemContainerStyle>
                <Style TargetType="ListBoxItem">
                    <Setter Property="IsSelected" Value="{Binding IsSelected}" />
                </Style>
            </ListBox.ItemContainerStyle>

            <ListBox.InputBindings>
                <KeyBinding Gesture="Ctrl+A" Command="{Binding SelectAllCommand}" />
            </ListBox.InputBindings>

            <i:Interaction.Triggers>
                <i:EventTrigger EventName="SelectionChanged" >
                    <i:CallMethodAction TargetObject="{Binding}" MethodName="ListBox_SelectionChanged"/>
                </i:EventTrigger>
            </i:Interaction.Triggers>
        </ListBox>

    </Grid>
</Window>

`

代码后台:

namespace WpfAppSelectedItems
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
            this.DataContext = new ViewModel(); //connecting window to VM
        }
    }
}

视图模型:

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Input;
using Microsoft.Xaml.Behaviors;
using System.Windows;

namespace WpfAppSelectedItems
{
    internal class ViewModel: Presenter
    {
        //Creating ItemPresenter class. IsSelected binded to Style in the view
        public class ItemPresenter : Presenter
        {
            private readonly string _value;

            public ItemPresenter(string value)
            {
                _value = value;
            }

            public override string ToString()
            {
                return _value;
            }

            private bool _isSelected;
            public bool IsSelected
            {
                get { return _isSelected; }
                set
                {
                    _isSelected = value;
                    OnPropertyChanged();
                }
            }
        }

        //Placing items to the Items which is binded to the ListBox 
        public ObservableCollection<ItemPresenter> Items { get; } = new ObservableCollection<ItemPresenter>
        {
            new ItemPresenter("A"),
            new ItemPresenter("B"),
            new ItemPresenter("C"),
            new ItemPresenter("D")
        };

        //Do something when selection changed including detecting SelectedItems
        public void ListBox_SelectionChanged()
        {
            foreach (var item in Items)
            {
                if (item.IsSelected)
                    MessageBox.Show(fufuitem.ToString());
                    
            }
        }
    };

    //Notify View if a property changes
    public abstract class Presenter : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        }
    }

}

2

使用命令和Interactivities EventTrigger很容易完成此操作。如果您想显示更新后的计数,请将ItemsCount绑定到您的XAML上。

XAML:

     <ListBox ItemsSource="{Binding SomeItemsSource}"
                 SelectionMode="Multiple">
        <i:Interaction.Triggers>
         <i:EventTrigger EventName="SelectionChanged">
            <i:InvokeCommandAction Command="{Binding SelectionChangedCommand}" 
                                   CommandParameter="{Binding ElementName=MyView, Path=SelectedItems.Count}" />
         </i:EventTrigger>
        </Interaction.Triggers>    
    </ListView>

<Label Content="{Binding ItemsCount}" />

ViewModel:

    private int _itemsCount;
    private RelayCommand<int> _selectionChangedCommand;

    public ICommand SelectionChangedCommand
    {
       get {
                return _selectionChangedCommand ?? (_selectionChangedCommand = 
             new RelayCommand<int>((itemsCount) => { ItemsCount = itemsCount; }));
           }
    }

        public int ItemsCount
        {
            get { return _itemsCount; }
            set { 
              _itemsCount = value;
              OnPropertyChanged("ItemsCount");
             }
        }

0
这对我来说是一个重大问题,我看到的一些答案要么太过于hackish,要么需要重置SelectedItems属性值,从而破坏了与属性OnCollectionChanged事件相关联的任何代码。但是,我通过直接修改集合来获得可行的解决方案,并且作为奖励,它甚至支持对象集合的SelectedValuePath。
public class MultipleSelectionListBox : ListBox
{
    internal bool processSelectionChanges = false;

    public static readonly DependencyProperty BindableSelectedItemsProperty =
        DependencyProperty.Register("BindableSelectedItems",
            typeof(object), typeof(MultipleSelectionListBox),
            new FrameworkPropertyMetadata(default(ICollection<object>),
                FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnBindableSelectedItemsChanged));

    public dynamic BindableSelectedItems
    {
        get => GetValue(BindableSelectedItemsProperty);
        set => SetValue(BindableSelectedItemsProperty, value);
    }


    protected override void OnSelectionChanged(SelectionChangedEventArgs e)
    {
        base.OnSelectionChanged(e);

        if (BindableSelectedItems == null || !this.IsInitialized) return; //Handle pre initilized calls

        if (e.AddedItems.Count > 0)
            if (!string.IsNullOrWhiteSpace(SelectedValuePath))
            {
                foreach (var item in e.AddedItems)
                    if (!BindableSelectedItems.Contains((dynamic)item.GetType().GetProperty(SelectedValuePath).GetValue(item, null)))
                        BindableSelectedItems.Add((dynamic)item.GetType().GetProperty(SelectedValuePath).GetValue(item, null));
            }
            else
            {
                foreach (var item in e.AddedItems)
                    if (!BindableSelectedItems.Contains((dynamic)item))
                        BindableSelectedItems.Add((dynamic)item);
            }

        if (e.RemovedItems.Count > 0)
            if (!string.IsNullOrWhiteSpace(SelectedValuePath))
            {
                foreach (var item in e.RemovedItems)
                    if (BindableSelectedItems.Contains((dynamic)item.GetType().GetProperty(SelectedValuePath).GetValue(item, null)))
                        BindableSelectedItems.Remove((dynamic)item.GetType().GetProperty(SelectedValuePath).GetValue(item, null));
            }
            else
            {
                foreach (var item in e.RemovedItems)
                    if (BindableSelectedItems.Contains((dynamic)item))
                        BindableSelectedItems.Remove((dynamic)item);
            }
    }

    private static void OnBindableSelectedItemsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is MultipleSelectionListBox listBox)
        {
            List<dynamic> newSelection = new List<dynamic>();
            if (!string.IsNullOrWhiteSpace(listBox.SelectedValuePath))
                foreach (var item in listBox.BindableSelectedItems)
                {
                    foreach (var lbItem in listBox.Items)
                    {
                        var lbItemValue = lbItem.GetType().GetProperty(listBox.SelectedValuePath).GetValue(lbItem, null);
                        if ((dynamic)lbItemValue == (dynamic)item)
                            newSelection.Add(lbItem);
                    }
                }
            else
                newSelection = listBox.BindableSelectedItems as List<dynamic>;

            listBox.SetSelectedItems(newSelection);
        }
    }
}

绑定工作方式就像您期望微软自己完成的那样:

<uc:MultipleSelectionListBox 
    ItemsSource="{Binding Items}" 
    SelectionMode="Extended" 
    SelectedValuePath="id" 
    BindableSelectedItems="{Binding mySelection}"
/>

目前尚未经过全面测试,但已经通过了初步检查。我尝试使用集合的动态类型来使其可重复使用。


我真的非常希望这个能够工作,但它没有。它只能做到某个程度。当我的表单打开时,应该被选中的值在列表框中没有被选中。然而,如果我点击一个值,它会被添加。如果我添加并删除一个应该显示的值,它将被删除。 - kenny
@kenny 我的示例没有设置初始值,但这很容易添加,我只是不需要在我的使用中。 - Wobbles
我发现绑定控件的依赖属性可能会变得棘手。您可以尝试设置绑定属性“notifyon...”,Updatetrigger等等... Mode = Twoway在这里也是适当的。 - dba

0

对于给出的答案不满意,我试图自己找到一个解决方案...... 结果更像是一种黑客方式而不是解决方案,但对我来说这很好用。这个解决方案以一种特殊的方式使用了MultiBindings。 首先它可能看起来像是一堆代码,但你可以用非常少的努力重复使用它。

首先我实现了一个“IMultiValueConverter”

public class SelectedItemsMerger : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
    {
        SelectedItemsContainer sic = values[1] as SelectedItemsContainer;

        if (sic != null)
            sic.SelectedItems = values[0];

        return values[0];
    }

    public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
    {
        return new[] { value };
    }
}

还有一个SelectedItems容器/包装:

public class SelectedItemsContainer
{
    /// Nothing special here...
    public object SelectedItems { get; set; }
}

现在我们为我们的ListBox.SelectedItem(单数)创建绑定。注意:您必须为“转换器”创建静态资源。这可以在每个应用程序中完成一次,并可重复使用于所有需要转换器的ListBox。
<ListBox.SelectedItem>
 <MultiBinding Converter="{StaticResource SelectedItemsMerger}">
  <Binding Mode="OneWay" RelativeSource="{RelativeSource Self}" Path="SelectedItems"/>
  <Binding Path="SelectionContainer"/>
 </MultiBinding>
</ListBox.SelectedItem>

在ViewModel中,我创建了一个容器,可以进行绑定。重要的是要使用new()来初始化它,以便将其填充到值中。
    SelectedItemsContainer selectionContainer = new SelectedItemsContainer();
    public SelectedItemsContainer SelectionContainer
    {
        get { return this.selectionContainer; }
        set
        {
            if (this.selectionContainer != value)
            {
                this.selectionContainer = value;
                this.OnPropertyChanged("SelectionContainer");
            }
        }
    }

就是这样。也许有人看到了一些改进的地方?你对此有什么想法吗?


0
原来将复选框绑定到IsSelected属性,并将文本块和复选框放在堆栈面板中就可以解决问题了!

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