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

WPF 快速食谱 - 对话框

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.50/5 (5投票s)

2010年3月15日

BSD

8分钟阅读

viewsIcon

29246

downloadIcon

306

一个 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 对象,但

  1. 知道它会打开一个对话框,这是一个 View 的细节,不应由 ViewModel 关心:RequestOpenDialog - 名称说明了一切。
  2. 在这种特定情况下,它滥用了它知道对话框将作为模态打开的事实(这是我在整个项目中犯的一个错误,因为我从未需要非模态对话框),因此处理 invoice 对象状态的代码写在打开请求之后,因为对话框将以模态方式显示,方法将暂停执行并等待。
  3. 如果对于同一个 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 日 - 原始文章
© . All rights reserved.