65.9K
CodeProject 正在变化。 阅读更多。
Home

WPF ComboBox 选择取消的危险

starIconstarIconstarIconstarIconstarIcon

5.00/5 (6投票s)

2012年6月20日

CPOL

8分钟阅读

viewsIcon

37848

downloadIcon

507

如何处理一个顽固的组合框,它不允许您取消无效的用户选择。

“我们本想做得更好,但结果还是一如既往。”
V. Chernomyrdin,俄罗斯前总理

摘要

在我们的应用程序中,有一个组合框用于在不同模式之间切换。任何未保存的工作将在切换时丢失。因此,如果存在未保存的更改,并且用户在组合框中选择新模式,我们希望

  1. 记住用户的选择,并将选择恢复到当前模式。
  2. 询问用户他是否确实想要切换模式并丢失更改。
  3. 如果答案是否定的,则假装什么都没发生。如果答案是肯定的,则更改组合框选择并切换模式。

在步骤1中通过选择来恢复是有意义的,因为在用户说“是”之前,应用程序并未正式切换模式,并且我们希望组合框能正确反映当前模式。不幸的是,事实证明,如果使用MVVM和数据绑定,在.NET 3.5中恢复组合框的选择是困难的,而在.NET 4.0中几乎是不可能的。

更多细节

如果我们使用MVVM,组合框选择有三个相关的实体

  1. 屏幕上显示的实际视觉状态。
  2. ComboBox.SelectedItem 属性的值。
  3. 通过绑定与组合框绑定的 ViewModel.SelectedItem 属性的值。

在一个理想的世界里,所有这三者应该始终同步,除了非常短暂的过渡状态。不幸的是,在.NET 3.5和.NET 4.0中,同步以不同的方式被破坏了。

.NET 3.5

在.NET 3.5中,组合框将正确同步视觉状态和ComboBox.SelectedItem 属性。然而,它将忽略在处理用户选择时对ViewModel.SelectedItem 的任何更新。这大概是为了避免更新的无限循环。最终结果是,如果视图模型尝试“更正”选定的项,ViewModel.SelectedItem 将与ComboBox.SelectedItem 和实际视觉状态不同步。

Binding behavior in .NET 3.5

.NET 4.0

在.NET 4.0中,微软试图让我们的生活更轻松。现在组合框**会**监听视图模型的变化,但不幸的是,它会“忘记”更新实际的视觉状态。在我看来,这比以前更糟糕,因为通过查看值,程序无法再检测到问题。组合框会向应用程序报告一个值,而向用户显示另一个值。这可不是好事。

Binding behavior in .NET 4.0

.NET 3.5 使用 BeginInvoke() 的解决方法

显然,组合框通常*会*监听视图模型更新,即使在.NET 3.5中也是如此,否则MVVM应用程序将永远无法以编程方式设置选择。大致来说,组合框在“选择已更改”窗口消息期间会“听不见”视图模型更新,可能是为了防止无限循环。一旦完成消息处理,组合框就愿意再次监听更新。因此,可能的解决办法是通过Dispatcher.BeginInvoke() 调用将用户选择的恢复推迟到当前窗口消息处理完毕之后。我在我的应用程序中使用了这种技术,直到它被移植到.NET 4。

这个解决方法.NET 4中不再起作用,因为组合框现在假装监听当前的视图模型值。当BeginInvoke() 被分派,并且视图模型再次发出更新信号时,组合框看到视图模型状态与其内部状态相同,因此什么也不做,仍然使视觉状态不同步。

演示应用程序

演示应用程序演示了视觉状态、组合框对象状态和视图模型状态之间的关系,这些关系因.NET版本和属性setter中的操作而异。我曾用它来研究这个问题并理解其内部工作原理。

下载 ComboBoxSelectionCancel.zip (30K)

Application Screenshot

该应用程序使用MVVM方法(实际上,它只是VVM,因为“模型”类不存在)。组合框在XAML中定义如下

<ComboBox SelectedItem="{Binding SelectedItem, Mode=TwoWay}" ... />

视图模型有一个SelectedItem 属性,该属性绑定到组合框的SelectedItem 属性

class MainViewModel
{
    public string SelectedItem 
    {
        get { ... }
        set { ... }
    }
}

控件,从上到下是

Control注释
当前CLR版本只读
组合框 
ComboBox.SelectedItem 的当前值只读
MainViewModel.SelectedItem 的当前值只读
“在setter中忽略值更新”复选框选中时,MainViewModel.SelectedItem 属性的setter将忽略更改值的请求。
“使用 BeginInvoke()”复选框选中时,setter将开始调用一个延迟的“视图模型选定项已更改”通知。
“在setter中抛出异常”复选框选中时,setter将抛出异常。有些人声称这可能会取消组合框更新。事实并非如此。
“将 SelectedItem 设置为”按钮使用相邻文本框的值调用MainViewModel.SelectedItem setter。
日志窗口显示应用程序中发生的某些有趣事件。
“清除日志”按钮清除日志窗口。

.NET 3.5 日志

如果我们尝试在.NET 3.5下将选择从一月改为二月,同时勾选“在setter中忽略值更新”和“使用 BeginInvoke()”,会发生什么

.NET 3.5 switch log

第一个属性更改通知(第3行)被组合框忽略,但通过BeginInvoke() 发出的第5行通知被捕获,并且选择被改回一月,正如我们所希望的那样。

.NET 4 日志

如果我们.NET 4中执行相同的操作,结果将不同。

.NET 4 switch log

第3行的属性更改通知不再被忽略,之后是第4行的get_SelectedItem() 调用:组合框读回选定的项属性,并将其自己的SelectedItem 值设置为一月。这是在第5行和第6行的BeginInvoke() 调用后重复的。因此,视图模型和组合框控件现在是完美同步的,但实际的视觉状态,正如你所看到的,仍然是“二月”。简单地说,这是WPF的一个bug。

演示应用程序内部

演示应用程序的大部分内容都比较直接。最复杂的部分是MainViewModel 类的SelectedItem 属性的setter,它考虑了我们指定的所有选项

set 
{
    Log.Write("MainViewModel.set_SelectedItem('" + value + "')");
 
    if (ThrowExceptionOnUpdate)
    {
        Log.Write("Throwing exception");
        throw new InvalidOperationException();
    }
 
    if (AreUpdatesIgnored)
    {
        Log.Write("Passed value ignored, MainViewModel.SelectedItem is still '" + _SelectedItem + "'");
    }
    else
    {
        _SelectedItem = value;
        Log.Write("MainViewModel.SelectedItem has been set to '" + _SelectedItem + "'");
    }
 
    if (UseBeginInvoke)
    {
        Action deferred = () => { RaisePropetryChanged("SelectedItem", true); };
        Dispatcher.CurrentDispatcher.BeginInvoke(deferred);
    }
 
    RaisePropetryChanged("SelectedItem", true);
    RaisePropetryChanged("SelectedItemForTextBlockDisplay", false);
}

另一个小技巧是,我们使用SelectedItemForTextBlockDisplay 属性而不是简单的SelectedItem 来显示视图模型选择状态。这两个属性总是返回相同的值。通过拥有两个属性而不是一个,我们可以区分组合框对属性的读取(前往SelectedItem)和辅助“ViewModel.SelectedItem is”文本块(前往SelectedItemForTextBlockDisplay)的读取。

如何解决.NET 4中组合框选择问题

我几乎放弃了*取消*选择,因为旧的技巧不再奏效。另一方面,如果我们任其发展,将对应用程序的其余部分产生不良影响。解决方案是创建一个“双缓冲”属性,它有两个“头部”:一个面向UI,另一个面向应用程序的其余部分。这会使应用程序逻辑变得有些复杂,但至少可以解决问题。我为此又创建了一个示例

下载 ComboBoxSelectionDoubleBuffer.zip (23K)

Double Buffer Sample

DoubleBuffer<T>

这个示例的关键部分是DoubleBuffer<T> class。它包含可见值的两个“侧面”。UI绑定到UIValue 属性,而应用程序的其余部分则关注Value 属性。大多数时候,这两个属性是相同的,除了用户决定是否继续进行新的选择之外的过渡期。

class DoubleBuffer<T> : NotifyPropertyChangedImpl
{
    /// <summary>
    /// UI-facing side of the buffer. Typically of no interest to the rest of the application
    /// </summary>
    public T UIValue { get; set; }
 
    /// <summary>
    /// Application facing side of the buffer
    /// </summary>
    /// <remarks>Use Assign() to assign values to this side</remarks>
    public T Value { get; set; }
 
    public event Action<T> UIValueChanged;
    public event Action<T> ValueChanged;
 
    /// <summary>
    /// Forces specific value into Value and UIValue
    /// </summary>
    public void Assign(T value);
 
    public void ConfirmUIChange();
    public void CancelUIChange();
}

请注意,我们实际上并没有取消用户选择:我们只是不让它深入应用程序。在上面的截图中,选定的月份是五月,但我们还没有将日历切换到五月,如果用户说不,我们会将选择恢复到一月。

使用双缓冲属性

主组合框在MainWindow.xaml 中定义为

<ComboBox ItemsSource="{Binding Months}" SelectedItem="{Binding SelectedMonth.UIValue}" />

MainViewModel 类中的相应属性定义为

public DoubleBuffer<string> SelectedMonth { get; private set; } 

并初始化为

SelectedMonth = new DoubleBuffer<string>();
SelectedMonth.UIValueChanged += OnSelectedMonthChanging;
SelectedMonth.ValueChanged += OnSelectedMonthChanged;
SelectedMonth.Assign("January");

“更改中”处理程序显示用户确认对话框,而“已更改”处理程序实际应用更改

private void OnSelectedMonthChanging(string toWhat)
{
    ConfirmationDialog.Show(
        "Are you sure you want to switch to " + toWhat + "?",
        SelectedMonth.ConfirmUIChange,
        SelectedMonth.CancelUIChange);
}
 
private void OnSelectedMonthChanged(string toWhat)
{
    Calendar = "Calendar for " + toWhat + " goes here";
}

关于风格的说明

Combo Box Selection Double Buffer 示例是一个纯粹的MVVM应用程序。然而,它仍然只是一个示例。它使用了一种简化的DelegateCommand(存在于许多MVVM工具包中),以及一个分层的ConfirmationDialog。在实际生活中,我可能会使用一个更复杂的命令解决方案,来自某个工具包,或者System.Interactions 库的交互支持,但我希望应用程序是独立的。ConfirmationDialog 的版本也比其真实版本有所简化,以便执行命令,并且,在更多程度上

结论

处理像组合框这样基本的东西不应该需要物理学博士学位。组合框控件一直是Windows GUI框架的阿喀琉斯之踵,自旧的Win32时代起就存在bug。.NET 3.5中的工作方式并不理想,但.NET 4.0尽管出发点是好的,却让它变得更糟。“读回”绑定值似乎是匆忙实现的:它不仅破坏了组合框的行为,还破坏了OneWayToSource 绑定的行为。我真希望微软有一个更好的回归测试流程。

参考文献

  1. James Kovacs的《混乱的组合框案例 – WPF/MVVM 睡前故事》
  2. connect.microsoft.com 上的“.NET 4.0 中 OneWayToSource 损坏”
  3. Karl Shifflett的《WPF 4.0 数据绑定更改(很棒的功能)》
    顺便说一句,微软官方对此有何声明?我在WPF 4.0 更改列表中找不到。
  4. Viktor Chernomyrdin - 维基百科文章。
© . All rights reserved.