WPF:如何取消数据绑定ListBox中的用户选择?

31

如何在绑定数据的WPF ListBox中取消用户选择?源属性已经正确设置,但ListBox的选择状态不同步。

我有一个MVVM应用程序,在WPF ListBox中如果某些验证条件失败,就需要取消用户选择。验证是由ListBox的选择触发的,而不是由提交按钮触发的。

ListBox.SelectedItem属性绑定到ViewModel.CurrentDocument属性。如果验证失败,视图模型属性的setter会退出而不更改该属性。因此,绑定到ListBox.SelectedItem的属性不会更改。

如果发生这种情况,则视图模型属性的setter在退出之前确实会引发PropertyChanged事件,我原以为这足以将ListBox重置为旧选择。但是,这并不起作用--ListBox仍然显示新的用户选择。我需要覆盖该选择并使其与源属性同步。

为避免不清楚,这里有一个示例:ListBox有两个项目,Document1和Document2;选择了Document1。用户选择Document2,但Document1未能通过验证。虽然ViewModel.CurrentDocument属性仍设置为Document1,但ListBox显示选择了Document2。我需要将ListBox的选择重置为Document1。

以下是我的ListBox绑定:

<ListBox 
    ItemsSource="{Binding Path=SearchResults, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" 
    SelectedItem="{Binding Path=CurrentDocument, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />

我尝试从ViewModel中使用回调函数(作为事件)到View(订阅该事件),以将SelectedItem属性强制恢复到旧选择。 我使用事件传递旧Document,它是正确的(旧选择),但ListBox选择不会更改回来。

那么,如何使ListBox选择与其绑定的ViewModel属性再次同步? 谢谢您的帮助。


“SearchResults” 集合在控件创建后是否会发生变化?我认为,当 ItemsSource 绑定的集合在任何时候发生更改或者 SelectedItem 对象来自不同的集合时,可能会出现问题。 - Ben Collier
这是 https://dev59.com/HnE85IYBdhLWcg3wzWzM 的副本,该网页有更多的答案,包括链接到 http://blog.alner.net/archive/2010/04/25/cancelling-selection-change-in-a-bound-wpf-combo-box.aspx 的答案。 - splintor
请看我在.NET 4.5+中提供的简单XAML解决方案。 - bwing
8个回答

47
对于未来遇到这个问题的人,这个页面最终对我有用: http://blog.alner.net/archive/2010/04/25/cancelling-selection-change-in-a-bound-wpf-combo-box.aspx 它是为组合框而设计的,但对于列表框也可以正常工作,因为在MVVM中,您不需要关心调用setter的控件类型。正如作者提到的,关键秘密是< strong > < em >实际更改基础值,然后再将其更改回来。 在单独的调度程序操作上运行此“撤消”也很重要。
private Person _CurrentPersonCancellable;
public Person CurrentPersonCancellable
{
    get
    {
        Debug.WriteLine("Getting CurrentPersonCancellable.");
        return _CurrentPersonCancellable;
    }
    set
    {
        // Store the current value so that we can 
        // change it back if needed.
        var origValue = _CurrentPersonCancellable;

        // If the value hasn't changed, don't do anything.
        if (value == _CurrentPersonCancellable)
            return;

        // Note that we actually change the value for now.
        // This is necessary because WPF seems to query the 
        //  value after the change. The combo box
        // likes to know that the value did change.
        _CurrentPersonCancellable = value;

        if (
            MessageBox.Show(
                "Allow change of selected item?", 
                "Continue", 
                MessageBoxButton.YesNo
            ) != MessageBoxResult.Yes
        )
        {
            Debug.WriteLine("Selection Cancelled.");

            // change the value back, but do so after the 
            // UI has finished it's current context operation.
            Application.Current.Dispatcher.BeginInvoke(
                    new Action(() =>
                    {
                        Debug.WriteLine(
                            "Dispatcher BeginInvoke " + 
                            "Setting CurrentPersonCancellable."
                        );

                        // Do this against the underlying value so 
                        //  that we don't invoke the cancellation question again.
                        _CurrentPersonCancellable = origValue;
                        OnPropertyChanged("CurrentPersonCancellable");
                    }),
                    DispatcherPriority.ContextIdle,
                    null
                );

            // Exit early. 
            return;
        }

        // Normal path. Selection applied. 
        // Raise PropertyChanged on the field.
        Debug.WriteLine("Selection applied.");
        OnPropertyChanged("CurrentPersonCancellable");
    }
}

注意:作者在撤销更改的操作中使用了ContextIdle作为DispatcherPriority。虽然可以,但这比Render的优先级要低,这意味着更改将在UI中显示为所选项短暂地更改并返回。使用Normal甚至是Send(最高优先级)的调度程序优先级会抢占更改的显示。这就是我最终所做的。有关DispatcherPriority枚举的详细信息,请参见此处。

4
我曾经是一个常常跌倒的人,而这正是我正在寻找的东西。唯一需要补充的是,在单元测试中你需要检查Application.Current是否为空,并进行相应的处理。 - Paul Walls
1
正确 - 在正常操作中,Application.Current 永远不会为空,因为如果 Application() 没有被实例化,绑定引擎就不会调用 setter - 但是你提出了一个很好的单元测试问题。 - Aphex
2
在某些类型的项目中,Application.Current.Dispatcher可能为null... 此时应改用Dispatcher.CurrentDispatcher。 - Mark Pearl
1
如果可以的话,我会再次点赞这个解决方案。在需要特定一组条件才能恢复组合框选择的解决方案中,它非常有效。使用“正常”优先级似乎是更为简洁的方法。 - Xcalibur37
2
添加延迟似乎可以让UI跟上字段设置被绕过的事实。SelectedItem = "{Binding employees_vm.current_employee,Mode = TwoWay,UpdateSourceTrigger = PropertyChanged,Delay = 1}" - joe blogs
正常优先级仍会导致界面更改所选项目,然后再改回来。因为当消息框弹出时,界面已经在发生变化了。 - CodingNinja

14

.NET 4.5 中添加了 Binding 的 Delay 字段。如果设置了延迟,它将自动等待更新,因此 ViewModel 中不需要 Dispatcher。这适用于所有选择器元素的验证,例如 ListBox 和 ComboBox 的 SelectedItem 属性。延迟以毫秒为单位。

<ListBox 
ItemsSource="{Binding Path=SearchResults, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" 
SelectedItem="{Binding Path=CurrentDocument, Mode=TwoWay, Delay=10}" />

它(ListBox)有效,并且比被接受的答案简单得多! - Mios
你能解释得更清楚一点吗?变化会在用户界面上显示为选定项短暂变化并再次变回来吗? - CodingNinja
延迟是在绑定上设置值之前等待的时间长度。任何非零值都会使绑定更新异步,这样可以确保验证正常工作。附注:添加此功能是为了在列表中使用箭头键时,在用户停止更改选择一段指定的“延迟”之后再更改所选项目。 - bwing
延迟是指在绑定上设置值之前等待的时间长度。任何非零值都会使绑定更新异步,从而使验证正常工作。附注:此功能的添加是为了在列表中使用箭头键时,等待在用户停止更改选择之前更改所选项目的指定“延迟”时间。 - undefined

9

-snip-

把我上面写的忘了吧。

我刚做了一个实验,确实当你在setter中做更多高级操作时,SelectedItem会失去同步。我想你需要等待setter返回后,异步地在你的ViewModel中再次更改属性。

快速而简单的工作解决方案(在我的简单项目中测试过),使用MVVM Light帮助程序:

在你的setter中,恢复为之前的CurrentDocument值

                var dp = DispatcherHelper.UIDispatcher;
                if (dp != null)
                    dp.BeginInvoke(
                    (new Action(() => {
                        currentDocument = previousDocument;
                        RaisePropertyChanged("CurrentDocument");
                    })), DispatcherPriority.ContextIdle);

这段内容与IT技术有关,大致意思是将属性更改排队到UI线程上,使用ContextIdle优先级可以确保它等待UI处于一致状态。在WPF中,似乎无法在事件处理程序中自由更改依赖属性。

不幸的是,这会导致视图模型和视图之间产生耦合,而且是一个丑陋的hack。

要让DispatcherHelper.UIDispatcher正常工作,您需要首先执行DispatcherHelper.Initialize()。


2
更优雅的解决方案是在视图模型中添加一个IsCurrentDocumentValid属性或者只是一个Validate()方法,并在视图中使用它来允许或禁止选择更改。 - majocha

7

明白了!我将接受majocha的回答,因为他在回答下面的评论中引导我找到了解决方案。

这是我做的:我在代码后台为ListBox创建了一个SelectionChanged事件处理程序。是的,它很丑陋,但它起作用。代码后台还包含一个模块级变量m_OldSelectedIndex,它被初始化为-1。 SelectionChanged处理程序调用ViewModel的Validate()方法,并返回一个布尔值,指示文档是否有效。如果文档有效,则处理程序将m_OldSelectedIndex设置为当前的ListBox.SelectedIndex并退出。如果文档无效,则处理程序将ListBox.SelectedIndex重置为m_OldSelectedIndex。以下是事件处理程序的代码:

private void OnSearchResultsBoxSelectionChanged(object sender, SelectionChangedEventArgs e)
{
    var viewModel = (MainViewModel) this.DataContext;
    if (viewModel.Validate() == null)
    {
        m_OldSelectedIndex = SearchResultsBox.SelectedIndex;
    }
    else
    {
        SearchResultsBox.SelectedIndex = m_OldSelectedIndex;
    }
}

请注意,此解决方案有一个技巧:您必须使用SelectedIndex属性;不能使用SelectedItem属性。感谢您的帮助majocha,希望这能帮助其他人走过难关。就像我,在六个月后,当我忘记了这个解决方案时...

5
如果你严肃地遵循MVVM并且不想使用任何代码后台,也不喜欢使用Dispatcher,那么以下解决方案适合我,并且比这里提供的大多数解决方案更加优雅。它基于这样一个概念,在代码后台中,您可以使用SelectionChanged事件停止选择。既然如此,为什么不为其创建一种行为,并将命令与SelectionChanged事件相关联呢?在视图模型中,您可以轻松记住先前选择的索引和当前选择的索引。诀窍在于在SelectedIndex上绑定到您的视图模型,并且每当选择更改时,只需让它发生变化即可。但是,在选择实际更改之后立即触发SelectionChanged事件,该事件现在通过命令通知到您的视图模型。因为您记得先前选择的索引,所以您可以验证它,如果不正确,则将选定的索引移回原始值。行为的代码如下:
public class ListBoxSelectionChangedBehavior : Behavior<ListBox>
{
    public static readonly DependencyProperty CommandProperty 
        = DependencyProperty.Register("Command",
                                     typeof(ICommand),
                                     typeof(ListBoxSelectionChangedBehavior), 
                                     new PropertyMetadata());

    public static DependencyProperty CommandParameterProperty
        = DependencyProperty.Register("CommandParameter",
                                      typeof(object), 
                                      typeof(ListBoxSelectionChangedBehavior),
                                      new PropertyMetadata(null));

    public ICommand Command
    {
        get { return (ICommand)GetValue(CommandProperty); }
        set { SetValue(CommandProperty, value); }
    }

    public object CommandParameter
    {
        get { return GetValue(CommandParameterProperty); }
        set { SetValue(CommandParameterProperty, value); }
    }

    protected override void OnAttached()
    {
        AssociatedObject.SelectionChanged += ListBoxOnSelectionChanged;
    }

    protected override void OnDetaching()
    {
        AssociatedObject.SelectionChanged -= ListBoxOnSelectionChanged;
    }

    private void ListBoxOnSelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        Command.Execute(CommandParameter);
    }
}

在XAML中使用它:

<ListBox x:Name="ListBox"
         Margin="2,0,2,2"
         ItemsSource="{Binding Taken}"
         ItemContainerStyle="{StaticResource ContainerStyle}"
         ScrollViewer.HorizontalScrollBarVisibility="Disabled"
         HorizontalContentAlignment="Stretch"
         SelectedIndex="{Binding SelectedTaskIndex, Mode=TwoWay}">
    <i:Interaction.Behaviors>
        <b:ListBoxSelectionChangedBehavior Command="{Binding SelectionChangedCommand}"/>
    </i:Interaction.Behaviors>
</ListBox>

适用于视图模型的代码如下所示:
public int SelectedTaskIndex
{
    get { return _SelectedTaskIndex; }
    set { SetProperty(ref _SelectedTaskIndex, value); }
}

private void SelectionChanged()
{
    if (_OldSelectedTaskIndex >= 0 && _SelectedTaskIndex != _OldSelectedTaskIndex)
    {
        if (Taken[_OldSelectedTaskIndex].IsDirty)
        {
            SelectedTaskIndex = _OldSelectedTaskIndex;
        }
    }
    else
    {
        _OldSelectedTaskIndex = _SelectedTaskIndex;
    }
}

public RelayCommand SelectionChangedCommand { get; private set; }

在视图模型的构造函数中:
SelectionChangedCommand = new RelayCommand(SelectionChanged);

RelayCommand是MVVM Light的一部分。如果你不知道它,请搜索一下谷歌。 你需要参考。

xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"

因此,您需要引用 System.Windows.Interactivity


1
唯一有效的解决方案!无法感谢你的足够,我花了比应该更长的时间来解决这个问题。 - Zerratar
1
在Behavior类的Command.Execute上必须添加一个null检查,但除此之外是个很好的解决方案。非常感谢。 :-) - Geoff Scott

1
我最近遇到了这个问题,并想出了一个适用于我的MVVM的解决方案,无需写任何后端代码。
我在我的模型中创建了一个SelectedIndex属性,并将listbox的SelectedIndex与其绑定。
在View CurrentChanging事件中,我进行验证,如果验证失败,我只需使用以下代码。
e.cancel = true;

//UserView is my ICollectionView that's bound to the listbox, that is currently changing
SelectedIndex = UserView.CurrentPosition;  

//Use whatever similar notification method you use
NotifyPropertyChanged("SelectedIndex"); 

目前看起来它完美地运作。可能存在一些边缘情况导致它不能正常工作,但就目前而言,它恰好符合我的要求。


0

我遇到了一个非常类似的问题,不同之处在于我使用的是绑定到ICollectionViewListView,并且使用IsSynchronizedWithCurrentItem而不是绑定ListViewSelectedItem属性。这对我很有效,直到我想要取消底层ICollectionViewCurrentItemChanged事件,这导致ListView.SelectedItemICollectionView.CurrentItem不同步。

这里的根本问题是保持视图与视图模型同步。显然,在视图模型中取消选择更改请求是微不足道的。因此,我认为我们真正需要的是一个更具响应性的视图。我宁愿避免在我的ViewModel中添加修补程序以解决ListView同步的限制。另一方面,我很乐意在我的视图代码后台中添加一些特定于视图的逻辑。

因此,我的解决方案是在代码后台中为ListView选择自己的同步。就我而言,这完全符合MVVM,并且比带有IsSynchronizedWithCurrentItemListView默认设置更加健壮。

这是我的代码后台...它允许从ViewModel更改当前项。如果用户单击列表视图并更改选择,它将立即更改,然后在下游取消更改时再次更改(这是我期望的行为)。请注意,我在ListView上将IsSynchronizedWithCurrentItem设置为false。还请注意,我在这里使用了async/await,它很好地发挥作用,但需要仔细检查,以确保当await返回时,我们仍然处于相同的数据上下文中。
void DataContextChangedHandler(object sender, DependencyPropertyChangedEventArgs e)
{
    vm = DataContext as ViewModel;
    if (vm != null)
        vm.Items.CurrentChanged += Items_CurrentChanged;
}

private async void myListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
    var vm = DataContext as ViewModel; //for closure before await
    if (vm != null)
    {
        if (myListView.SelectedIndex != vm.Items.CurrentPosition)
        {
            var changed = await vm.TrySetCurrentItemAsync(myListView.SelectedIndex);
            if (!changed && vm == DataContext)
            {
                myListView.SelectedIndex = vm.Items.CurrentPosition; //reset index
            }
        }
    }
}

void Items_CurrentChanged(object sender, EventArgs e)
{
    var vm = DataContext as ViewModel; 
    if (vm != null)
        myListView.SelectedIndex = vm.Items.CurrentPosition;
}

然后在我的ViewModel类中,我有一个名为ItemsICollectionView和这个方法(简化版本如下所示)。

public async Task<bool> TrySetCurrentItemAsync(int newIndex)
{
    DataModels.BatchItem newCurrentItem = null;
    if (newIndex >= 0 && newIndex < Items.Count)
    {
        newCurrentItem = Items.GetItemAt(newIndex) as DataModels.BatchItem;
    }

    var closingItem = Items.CurrentItem as DataModels.BatchItem;
    if (closingItem != null)
    {
        if (newCurrentItem != null && closingItem == newCurrentItem)
            return true; //no-op change complete

        var closed = await closingItem.TryCloseAsync();

        if (!closed)
            return false; //user said don't change
    }

    Items.MoveCurrentTo(newCurrentItem);
    return true; 
}

TryCloseAsync 的实现可以使用某种对话框服务来从用户那里获取关闭确认。


-1

绑定 ListBox 的属性:IsEnabled="{Binding Path=Valid, Mode=OneWay}",其中Valid是视图模型属性,具有验证算法。在我看来,其他解决方案看起来过于牵强。

当不允许禁用外观时,样式可以提供帮助,但可能禁用样式已经足够,因为不允许更改选择。

也许在.NET版本4.5中,INotifyDataErrorInfo会有所帮助,我不知道。


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