WPF 快速食谱 - 对话框






4.50/5 (5投票s)
一个 Blend 行为,可用于在单独的窗口中显示 ViewModel。
引言
本文提供了一种简单的方法来打开和关闭显示 ViewModel 的隐式 DataTemplate 的对话框。当您使用 Model-View-Presenter/Model-View-ViewModel 模式构建解决方案时,打开对话框会很麻烦。当您的代码隐藏文件中有许多按钮点击处理程序时,您不会遇到此问题,但当您努力使应用程序仅通过命令和绑定驱动时,这就会成为一个问题。因为您希望分层松耦合、关注点分离等。因此,ViewModel 中的类似代码会显得非常丑陋
private ICommand displayInvoice;
public ICommand DisplayInvoice
{
get
{
return displayInvoice ?? (displayInvoice = new DelegateCommand(
a =>
{
var dialog = new Window();
var invoiceViewModel = new InvoiceViewModel();
dialog.DataContext = invoiceViewModel;
dialog.Content = invoiceViewModel;
dialog.ShowDialog();
if (invoiceViewModel.IsApproved)
{
// process invoice
}
}));
}
}
UtilityDialog
WPF 中有很多做事的方式。简单来说,UI 创建有两种主要方法:您可以通过紧耦合控件构建整个视觉树,然后为树中需要它的所有节点设置 DataContext;另一种方法是只为根控件(通常是主 Window)设置 DataContext,然后依赖于隐式 DataTemplate 来发挥作用。我倾向于使用后一种方法,并且本文更适合这种方法。这样,只要您需要显示的是特定 ViewModel 的视图,您就可以有一个用于所有“对话框需求”的单一窗口。我使用了一个 UtilityDialog 控件,它实际上是一个具有某些属性设置的 Window,因此它的样式类似于 ToolWindow(但您可以更改它,甚至可以为每种类型的对话框准备一个)。当我想要显示一个对话框时,我只需创建这个窗口的新实例,并将其 DataContext 设置为在资源范围内定义了隐式 DataTemplate 的 ViewModel。UtilityDialog 的 Content 绑定到 DataContext,因此模板将被加载和显示。这消除了为每个需要对话框的 ViewModel 都需要一个 Window 的情况。唯一需要解决的问题是如何打开/关闭此对话框。
第一种方法
我过去通过引发一个纯 C# 事件,并结合一个附加行为来处理这个问题。所有必须打开对话框的 ViewModel 都需要实现一个简单的接口
public interface IDialogOwner
{
event EventHandler<RequestOpenDialogEventArgs> RequestOpenDialog;
}
public sealed class RequestOpenDialogEventArgs : EventArgs
{
public RequestOpenDialogEventArgs(object dialogDataContext)
{
DialogDataContext = dialogDataContext;
}
public object DialogDataContext
{
get;
private set;
}
}
假设我们要向客户显示一张发票表单,并让他同意或不同意付款条款,并且我们希望通过模态对话框来完成。我们为此有一个漂亮的专用 ViewModel,带有批准和拒绝的命令
public sealed class InvoiceViewModel : IRemovable
{
public bool IsApproved
{
get;
private set;
}
private ICommand approve;
public ICommand Approve
{
get
{
return approve ?? (approve = new DelegateCommand(
a =>
{
IsApproved = true;
RequestRemove(this, EventArgs.Empty);
}));
}
}
private ICommand reject;
public ICommand Reject
{
get
{
return reject ?? (reject = new DelegateCommand(
a =>
{
IsApproved = false;
RequestRemove(this, EventArgs.Empty);
}));
}
}
public event EventHandler RequestRemove = delegate { };
}
批准或拒绝是通过 ICommand 完成的,因此我们将它们绑定到 ICommandSource 对象(如 Button)以便批准/拒绝发票。一旦调用了这些命令中的一个,我们也希望关闭对话框。为了避免代码隐藏,我们必须引入另一个接口,用于通过对话框显示并且需要关闭的 ViewModel
public interface IRemovable
{
event EventHandler RequestRemove;
}
显示发票需要引发 RequestOpenDialog 事件
public sealed class MainViewModel : IDialogOwner
{
private ICommand displayInvoice;
public ICommand DisplayInvoice
{
get
{
return displayInvoice ?? (displayInvoice = new DelegateCommand(
a =>
{
var invoice = new InvoiceViewModel();
RequestOpenDialog(this, new RequestOpenDialogEventArgs(invoice));
if (invoice.IsApproved)
{
// process invoice
}
}));
}
}
public event EventHandler RequestOpenDialog = delegate { };
}
我们在 ViewModel 中使用的两个事件 - RequestRemove 和 RequestOpenDialog 由两个附加行为监视(因为如今这是一个非常方便的功能)。它们是通过利用 Blend 的 System.Windows.Interactivity 程序集实现的,但您也可以按照模式创建者的原始方法,以“传统”方式实现,正如 John Gossman 宣传的那样。这些行为必须设置在与引发事件的 ViewModel 关联的视图上(在此例中,是主窗口和 UtilityDialog)。
public sealed class CloseWindowBehavior : Behavior<Window>
{
protected override void OnAttached()
{
base.OnAttached();
EventHandler closeWindow = (a, b) => AssociatedObject.Close();
Action<object> hookRequestRemove = (a) =>
{
var removable = a as IRemovable;
if (removable != null)
{
removable.RequestRemove += closeWindow;
}
};
Action<object> unhookRequestRemove = (a) =>
{
var removable = a as IRemovable;
if (removable != null)
{
removable.RequestRemove -= closeWindow;
}
};
hookRequestRemove(AssociatedObject.DataContext);
AssociatedObject.DataContextChanged += (a, b) =>
{
unhookRequestRemove(b.OldValue);
hookRequestRemove(b.NewValue);
};
}
}
CloseWindowBehavior 处理关联窗口的 DataContextChanged 事件。当该事件引发时,如果新的 DataContext 是 IRemovable,则会为 RequestRemove 事件添加一个处理程序。此处理程序会关闭关联的窗口。
public sealed class OpenDialogBehavior : Behavior<Window>
{
protected override void OnAttached()
{
base.OnAttached();
EventHandler<RequestOpenDialogEventArgs> openDialog = (a, b) =>
{
var dialog = new UtilityDialog();
dialog.DataContext = b.DialogDataContext;
dialog.Owner = AssociatedObject;
dialog.ShowDialog();
};
Action<object> hookRequestOpenDialog = (a) =>
{
var dialogOwner = a as IDialogOwner;
if (dialogOwner != null)
{
dialogOwner.RequestOpenDialog += openDialog;
}
};
Action<object> unhookRequestOpenDialog = (a) =>
{
var dialogOwner = a as IDialogOwner;
if (dialogOwner != null)
{
dialogOwner.RequestOpenDialog -= openDialog;
}
};
hookRequestOpenDialog(AssociatedObject.DataContext);
AssociatedObject.DataContextChanged += (a, b) =>
{
unhookRequestOpenDialog(b.OldValue);
hookRequestOpenDialog(b.NewValue);
};
}
}
OpenDialogBehavior 还监视其关联窗口的 DataContextChanged 事件。当该事件引发时,将为 RequestOpenDialog 事件添加一个处理程序(前提是 DataContext 是一个有效的 IDialogOwner 实体)。此处理程序创建 UtilityDialog 控件的新实例,将其 DataContext 设置为通过事件参数接收的 ViewModel(在此例中是 InvoiceViewModel 对象)并显示对话框。
您已经发现问题了吗?我们经历了所有这些痛苦,创建接口和附加行为,并引发事件以解耦 ViewModel 与 View。但我们并未实现这一点。我们只是将代码从一个地方移动到另一个地方(并在过程中添加了额外的代码),但 ViewModel 仍然 100% 与 View 耦合。它可能不会在命令的 execute 委托中实例化 Window 对象,但
- 它知道它会打开一个对话框,这是一个 View 的细节,不应由 ViewModel 关心:RequestOpenDialog - 名称说明了一切。
- 在这种特定情况下,它滥用了它知道对话框将作为模态打开的事实(这是我在整个项目中犯的一个错误,因为我从未需要非模态对话框),因此处理 invoice 对象状态的代码写在打开请求之后,因为对话框将以模态方式显示,方法将暂停执行并等待。
- 如果对于同一个 ViewModel 的另一个 View(也许是另一个皮肤),我们不想打开对话框怎么办?也许我们想要一个自定义控件在同一个窗口中弹出并晃动。这意味着我们需要修改 ViewModel 来处理额外的逻辑。
隐藏的瑰宝
乍一看,FrameworkElement(FrameworkElement)在 XAML 文件中似乎没什么意义。我们在代码中主要将其用于多态性,但在 XAML 中它似乎没什么用,因为它没有视觉外观。然而,我所知的 XAML 专属场景中最有趣的技巧之一就是基于FrameworkElement,而它实际上没有视觉模板的事实正是其价值所在。
- 看看 Charles Petzold 的这些例子:AllXamlClock.xaml(示例,文章)、AnimatedSpiral1.xaml(示例,文章)和WindDownPendulum.xaml(示例,文章)。这些不需要代码,您可以直接在支持 XAML 的应用程序(如 IE 或 Kaxaml)中加载它们以查看效果。在第一个例子中,他使用FrameworkElement来在其 Tag 属性中存储当前时间(XAML 加载时的系统时间)。然后,他对这个初始值应用了一些巧妙的变换,使时钟旋转。在后两个例子中,FrameworkElement承载了一些复合变换,分别用于定义对象的螺旋状运动以及模仿摆锤在空气中的摩擦,使其逐渐减速。非常巧妙!
- Josh Smith 的 VirtualBranch 利用FrameworkElement为不属于视觉树(因此无法使用绑定,因为它们不继承 DataContext)的对象提供数据。
- Dr. WPF 也不容错过这个列表。在其提供的众多 WPF 建议中,有一种“附加依赖属性的窃取”......抱歉,我是说借用 方案,当您在 XAML 专属场景中使用内置框架类型的附加依赖属性来测试某种概念验证,并且您需要一两个额外的属性时(好吧,您可以借用的有用的FrameworkElement附加属性并不多,但这种技术值得记住)。
考虑到所有这些,我们的对话框问题就变得非常简单。看看这段 XAML
<FrameworkElement DataContext="{Binding Path=CurrentInvoice}">
<i:Interaction.Behaviors>
<w:DialogBehavior DisplayModal="True"/>
</i:Interaction.Behaviors>
</FrameworkElement>
我们没有使用监视特定接口实现者引发事件的附加行为,而是监视了不可见的FrameworkElement的 DataContextChanged 事件。当它被设置为非 null 对象时,我们就显示一个对话框,其内容设置为该对象。当 DataContext 变为 null 时,我们就关闭对话框。
public sealed class DialogBehavior : Behavior<FrameworkElement>
{
private static Dictionary<object, UtilityDialog> mapping =
new Dictionary<object, UtilityDialog>();
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.DataContextChanged += (a, b) =>
{
if (b.NewValue != null)
{
var dialog = new UtilityDialog();
mapping.Add(b.NewValue, dialog);
dialog.DataContext = b.NewValue;
dialog.Owner = AssociatedObject as Window ??
Application.Current.MainWindow;
if (DisplayModal)
{
dialog.ShowDialog();
}
else
{
dialog.Show();
}
}
else if (mapping.ContainsKey(b.OldValue))
{
var dialog = mapping[b.OldValue];
mapping.Remove(b.OldValue);
dialog.Close();
dialog.DataContext = null;
dialog.Owner = null;
}
};
}
}
ViewModel 无需实现任何接口,也无需关心它打开了一个对话框。在这种情况下,它唯一的责任是管理其逻辑上从属的 ViewModel 的生命周期 - 一旦引入了该系统,UI 就会关心如何处理特定 ViewModel 的关联视图,以及以何种方式(如果根本没有)显示它。
public sealed class MainViewModel : INotifyPropertyChanged
{
private InvoiceViewModel currentInvoice;
public InvoiceViewModel CurrentInvoice
{
get
{
return currentInvoice;
}
set
{
if (currentInvoice != value)
{
currentInvoice = value;
PropertyChanged(this, new PropertyChangedEventArgs("CurrentInvoice"));
}
}
}
private ICommand displayInvoice;
public ICommand DisplayInvoice
{
get
{
return displayInvoice ?? (displayInvoice = new DelegateCommand(
a => CurrentInvoice = new InvoiceViewModel()));
}
}
}
因此,DisplayInvoice 命令只是实例化 CurrentInvoice 属性(我们不可见的FrameworkElement绑定的属性)。然后就结束了。我们不知道视图将如何显示,是对话框还是其他方式,模态还是非模态。这使得处理逻辑被放置在其他地方,这是好事。现在,我们需要一种机制让 ViewModel 进行通信 - 发票必须告诉主 ViewModel 用户已经完成了它。您仍然可以依赖普通的 CLR 事件,但是随着您的系统增长,并且每个 ViewModel 都需要了解许多其他 ViewModel,维护整个关系结构会变得非常困难。因此,您可能会考虑某种中介者(如 Josh Smith 的 MVVM Foundation,或 Prism 的 EventAggregator)。示例使用了一个非常简单的自定义 EventAggregator,您不应该在生产代码中使用它。
public sealed class MainViewModel : INotifyPropertyChanged
{
public MainViewModel()
{
EventAggregator<InvoiceReviewedEvent>.Subscribe(ProcessInvoice);
}
public void ProcessInvoice()
{
if (CurrentInvoice.IsApproved)
{
// process invoice
}
CurrentInvoice = null;
}
}
public sealed class InvoiceViewModel
{
public ICommand Approve
{
get
{
return approve ?? (approve = new DelegateCommand(
a =>
{
IsApproved = true;
EventAggregator<InvoiceReviewedEvent>.Broadcast();
}));
}
}
}
当发票 ViewModel 完成时,它会通过在一个特定频道上广播来发出信号。主 ViewModel 订阅此类消息,并在 ProcessInvoice 方法中进行处理。如果发票已被接受,它会对其进行一些重要的操作。最后,它将 CurrentInvoice 设置为 null - 这个对象已经完成了它的工作,它不再有用了。附加行为会捕获此更改并关闭对话框。有了这个设置,如果我们不想要对话框,而是希望发票显示在同一个窗口中,并停靠在左侧,我们只需这样做
<ContentControl Content="{Binding Path=CurrentInvoice}" DockPanel.Dock="Left"/>
而无需修改 ViewModel 中的任何内容。差不多就是这样。让 View 来决定!历史
- 2010 年 3 月 15 日 - 原始文章