WPF ComboBox 选择取消的危险





5.00/5 (6投票s)
如何处理一个顽固的组合框,它不允许您取消无效的用户选择。
“我们本想做得更好,但结果还是一如既往。”
V. Chernomyrdin,俄罗斯前总理
摘要
在我们的应用程序中,有一个组合框用于在不同模式之间切换。任何未保存的工作将在切换时丢失。因此,如果存在未保存的更改,并且用户在组合框中选择新模式,我们希望
- 记住用户的选择,并将选择恢复到当前模式。
- 询问用户他是否确实想要切换模式并丢失更改。
- 如果答案是否定的,则假装什么都没发生。如果答案是肯定的,则更改组合框选择并切换模式。
在步骤1中通过选择来恢复是有意义的,因为在用户说“是”之前,应用程序并未正式切换模式,并且我们希望组合框能正确反映当前模式。不幸的是,事实证明,如果使用MVVM和数据绑定,在.NET 3.5中恢复组合框的选择是困难的,而在.NET 4.0中几乎是不可能的。
更多细节
如果我们使用MVVM,组合框选择有三个相关的实体
- 屏幕上显示的实际视觉状态。
ComboBox.SelectedItem
属性的值。- 通过绑定与组合框绑定的
ViewModel.SelectedItem
属性的值。
在一个理想的世界里,所有这三者应该始终同步,除了非常短暂的过渡状态。不幸的是,在.NET 3.5和.NET 4.0中,同步以不同的方式被破坏了。
.NET 3.5
在.NET 3.5中,组合框将正确同步视觉状态和ComboBox.SelectedItem
属性。然而,它将忽略在处理用户选择时对ViewModel.SelectedItem
的任何更新。这大概是为了避免更新的无限循环。最终结果是,如果视图模型尝试“更正”选定的项,ViewModel.SelectedItem
将与ComboBox.SelectedItem
和实际视觉状态不同步。

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

.NET 3.5 使用 BeginInvoke() 的解决方法
显然,组合框通常*会*监听视图模型更新,即使在.NET 3.5中也是如此,否则MVVM应用程序将永远无法以编程方式设置选择。大致来说,组合框在“选择已更改”窗口消息期间会“听不见”视图模型更新,可能是为了防止无限循环。一旦完成消息处理,组合框就愿意再次监听更新。因此,可能的解决办法是通过Dispatcher.BeginInvoke()
调用将用户选择的恢复推迟到当前窗口消息处理完毕之后。我在我的应用程序中使用了这种技术,直到它被移植到.NET 4。
这个解决方法.NET 4中不再起作用,因为组合框现在假装监听当前的视图模型值。当BeginInvoke()
被分派,并且视图模型再次发出更新信号时,组合框看到视图模型状态与其内部状态相同,因此什么也不做,仍然使视觉状态不同步。
演示应用程序
演示应用程序演示了视觉状态、组合框对象状态和视图模型状态之间的关系,这些关系因.NET版本和属性setter中的操作而异。我曾用它来研究这个问题并理解其内部工作原理。
下载 ComboBoxSelectionCancel.zip (30K)

该应用程序使用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()”,会发生什么

第一个属性更改通知(第3行)被组合框忽略,但通过BeginInvoke()
发出的第5行通知被捕获,并且选择被改回一月,正如我们所希望的那样。
.NET 4 日志
如果我们.NET 4中执行相同的操作,结果将不同。

第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)

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
绑定的行为。我真希望微软有一个更好的回归测试流程。
参考文献
- James Kovacs的《混乱的组合框案例 – WPF/MVVM 睡前故事》。
connect.microsoft.com
上的“.NET 4.0 中 OneWayToSource 损坏”。- Karl Shifflett的《WPF 4.0 数据绑定更改(很棒的功能)》。
顺便说一句,微软官方对此有何声明?我在WPF 4.0 更改列表中找不到。 - Viktor Chernomyrdin - 维基百科文章。