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

WPF 桌面应用程序中的 ContentDialog

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (14投票s)

2017年2月19日

CPOL

21分钟阅读

viewsIcon

49219

downloadIcon

2044

在 WPF 对话框中

目录

引言

本文档介绍了 WPF 对话框服务实现,该服务可以显示作为 ContentDialog 的消息框,并支持各种其他对话框(进度、登录...),这些对话框可以作为 ContentDialog 实现。ContentDialog 是一种看起来像对话框的视图,但它是窗口内容的一部分。

背景

早在 2013 年,我就曾编写过 [1],用于替代标准的 .Net MessageBox。我的替代方案使用 WPF、MVVM 和面向服务的架构。计算世界已经转向物联网(智能手机、平板电脑等),这些小工具应用程序开始影响现代桌面应用程序的设计。

这就是为什么我希望重新实现我的消息框服务 [1],并将其转换为由 ContentDialog 驱动的实现,使用与以前相同或非常相似的 API。为此,我发现只有 MahApps.Metro 项目包含了这方面的有用提示。因此,我将该项目作为参考,提取了我需要的部分,将其重构为面向服务的架构,最终得到了一组我现在称为 MLib 框架的库。

MLib 框架的一个重要组成部分是 ContentDialog 部分,它具有与 MahApps.Metro 非常相似的功能,但也包含后来添加且在其他任何地方都找不到的功能。

架构

MDemo 组件是此示例应用程序中的主可执行文件。MLib 库主要包含主题定义,例如控件定义等,而 3 个 MWindow 组件

  • MWindowLib、MWindowInterfaceLib、MWindowDialogLib,

引导我们了解定义 MetroWindow 是什么(MWindowLib)以及 ContentDialogs 如何在其中显示(参见 MWindowDialogLib 中的 ContentDialogService)的组件。

部署图由其他地方描述的设置 [2] 和 ServiceLocator [3] 组件完成。IContentDialogService 驱动此项目中的 ContentDialogs,因此接下来我们将详细介绍这些内容。

ContentDialogService

MWindowDialogLib 中的 ContentDialogService 类创建了一个实现 IContentDialogService 接口的实例。

public interface IContentDialogService</code>
{
  IMessageBoxService MsgBox { get; }

  IDialogManager Manager { get; }
  IDialogCoordinator Coordinator { get; }

  IMetroDialogFrameSettings DialogSettings { get; }
}

IContentDialogService 接口中的前 3 个属性公开了实现特定服务服务组件,而最后一个属性是一种辅助属性,可确保应用程序在其生命周期中显示多个 ContentDialog 时保持一致的行为。

IMessageBoxService 是我四年前实现的 [1] 服务,并在此处于 MWindowDialogLib.Internal.MessageBoxServiceImpl 中重新实现。IDialogManagerIDialogCoordinator 接口代表也在 MWindowDialogLib.Internal 命名空间中实现的服务。它们支持 IMessageBoxService 的异步和非异步实现,并支持 CustomDialog 实现,我们将在下面看到。

IMetroWindowService 接口描述了一个可以创建外部模态 MetroWindow 对话框的服务。相应的实例在 MDemo 项目的 ServiceInjector 类中初始化和注入。

接下来,我们将以示例的形式详细介绍这些项目。这应该有助于我们完善此项目中 100 多个示例的图片。

Using the Code

所附的 MLib.zip 代码需要 Visual Studio 2015 Community Edition 或更高版本。将 MDemo 设置为启动项目并编译。这将通过 Nuget 下载组件。因此,您可能需要启用 Nuget 才能在下载 zip 文件后编译代码。

这里展示的代码实现了一个对话框服务,该服务可以在异步上下文或正常阻塞上下文下显示对话框。它展示了如何使用相同的服务接口来支持多种类型的对话框(消息、进度、登录等)作为 ContentDialog 或标准模态对话框。

该演示包括 100 多个示例,因此请花点时间,随时回到本文。

了解 MDemo 应用程序

MDemo 应用程序展示了 MLib 框架支持的许多示例和不同的对话框。它包含两部分:一个“对话框 >”菜单驱动的示例列表,以及 MainWindow 内容中的 17 个按钮 x 2 的列表。

“对话框 >”菜单项下的演示等同于 MahApps.Metro 项目中可以找到的演示对话框,尽管技术实现完全不同。 “异步测试”和“同步测试”下面的双重按钮列表等同于我之前在 Codeplex 上发布的 Message Box 演示应用程序 [1],但这次我们发现支持更多的用例,例如 ContentDialog、异步和非异步、模态等等。

“对话框 >”菜单下的 MahApps.Metro 示例

源自 MahApps.Metro 项目的对话框示例可分为 5 种类型的对话框

  • 自定义对话框
  • 消息对话框
  • 输入对话框
  • 登录对话框
  • 进度对话框

这些对话框中的每一个都大致支持其名称所说的内容。但它们都基于 MLib 框架中的一个对话框。它们所基于的对话框是 MWindowDialogLib.Dialogs 命名空间中的 CustomDialog。您可能想知道这究竟是如何可能的,接下来我将告诉您。以上每个演示显然都是通过 MainWindow 启动的,但后端函数位于其各自的演示视图模型中。

  • MDemo.Demos.CustomDialogDemos
  • MDemo.Demos.MessageDialogDemos
  • MDemo.Demos.InputDialogDemos
  • MDemo.Demos.LoginDialogDemos
  • MDemo.Demos.ProgressDialogDemos

据我所知,通过菜单启动的每个演示都通过这些类进行路由——因此,为每个示例找到正确的代码应该轻而易举 :-) 现在,让我们通过详细检查 InputDialogDemo(从一个简单的示例开始)并继续使用 Progress 对话框(以一个相当复杂和复杂的示例结束)来了解这些示例是如何工作的。

输入对话框演示

输入对话框演示代码中最容易解释的方法是 async void ShowDialogFromVM(object context) 方法

internal async void ShowDialogFromVM(object context)
{
    var viewModel = new Demos.ViewModels.InputDialogViewModel()
    {
        Title = "From a VM"
        , Message = "This dialog was shown from a VM, without knowledge of Window"
        , AffirmativeButtonText = "OK"
        , DefaultResult = DialogIntResults.OK  // Return Key => OK Clicked
    };

    var customDialog = new MWindowDialogLib.Dialogs.CustomDialog(new Demos.Views.InputView(), viewModel);

    var coord = GetService<IContentDialogService>().Coordinator;

    var result = await coord.ShowMetroDialogAsync(context, customDialog);
}

此方法创建一个 InputDialogViewModel 并将其与 InputView 对象的实例一起传递给 CustomDialog 类的类构造函数。CustomDialog 类的构造函数将视图分配给其内容,并将 ViewModel 分配给其 DataContext 属性。

public CustomDialog(object contentView
                    , object viewModel = null
                    , IMetroDialogFrameSettings settings = null)
    : base(null, settings)
{
    InitializeComponent();

    // Set the display view here ...
    this.PART_Msg_Content.ChromeContent = contentView;
    this.DialogThumb = this.PART_Msg_Content.PART_DialogTitleThumb;

    // Get a view and bind datacontext to it
    this.DataContext = viewModel;

    this.Loaded += MsgBoxDialog_Loaded;
}

如果您有一些 WPF 经验,DataContext 和 viewmodel 部分就很简单。但是 ChromeContent 是什么,它到底是什么?事实证明,CustomDialog 在视觉上可以分解为 3 个主要的视觉项

  • MWinodwLib.Dialogs.DialogFrame
  • MWindowLib.Dialogs.DialogChrome
  • 和 View

上面的示意图向您展示了右侧的 3 个层是如何相互叠加,构成左侧所示的单个项目。事实证明,语句 PART_Msg_Content.ChromeContent 指的是 DialogChrome 对象及其内容,该内容是 ScrollViewer 中绑定的 ContentControl。因此,ChromeContent 实际上等同于上述示意图中的蓝色区域。我选择这种设计是因为拥有标题和关闭按钮通常很方便,并且在许多(如果不是所有)情况下,默认的对话框行为(按 Esc 取消,按 Enter 确认)似乎也很有用。

默认对话框行为在 DialogFrame 类中实现。所有对话框,无论是 CustomDialog 还是 MsgBoxDialog,都基于 DialogFrame 类以继承其行为。DialogFrame 赋予其自然一致的外观和感觉,尽管标题和关闭按钮可能并非总是需要,但它们通常受欢迎。视图(例如 InputView)可以是此处可插入的任何 UserControl。

这种设计让我们能够自由地插入任何我们认为合适的视图,并将其与任何视图模型连接,以便作为 ContentDialog 显示在应用程序的 MainWindow 中。构造代码将视图和视图模型作为 object 处理,因此不需要这些项目上的任何特殊属性或方法。

请注意,但是,在 CustomDialog 中显示的每个视图都必须具备以下条件之一:

  • 具有可以通过异步 ShowDialog 调用等待的定时生命周期,
    参见 CustomDialogDemo.ShowCustomDialog
     
  • 一个关闭对话框的自定义事件,或
    参见 CustomDialogDemo.ShowAwaitCustomDialog
     
  • 一个实现 DialogFrame 控件支持的关闭机制的视图模型
    参见 InputDialogDemos.ShowDialogFromVM

如果上述方法均不符合您的要求,您可能需要自己发明一种方法,否则您的对话框可能永远无法关闭——这似乎也无益。与 DialogFrame 控件一致地实现标准对话框行为的基本视图模型是 MsgDemoViewModel。因此,回到本节开头的代码示例,我们现在准备注意到它确实构建了一个视图和视图模型并将它们注入到 CustomDialog 中。上面示例中的最后几行代码

var coord = GetService<IContentDialogService>().Coordinator;
var result = await coord.ShowMetroDialogAsync(context, customDialog);

通过其在 MDemo.ServiceInjector 类中注册的接口访问 ContentDialogService。Coordinator.ShowMetroDialogAsync 方法通过 MWindowDialogLib.Dialogs.DialogParticipation 类中的绑定注册表将上下文对象参数转换为 IMetroWindow 的引用。然后,该引用用于调用等效的 DialogManager.ShowMetroDialogAsync 方法,该方法将 ContentDialog 插入到 MetroActiveDialogContainer 中,等待其加载完成,并等待 WaitForButtonPressAsync() 方法完成,以便再次卸载对话框。

我上面提到的其他对话框演示类(消息、输入、登录等)在方法命名和代码功能方面都非常相似。因此,如果您花一点时间让 Visual Studio 的 CodeLense 带您浏览项目,那么根据上述解释应该可以理解它们。但还有两个相似但不同的项目:进度对话框演示和重构后的 IMessageBoxService 对话框服务,接下来我们将对此进行解释。

进度对话框演示

进度对话框与其他“普通”对话框不同,因为它确实看起来很有用

  • 进度成功完成后自动关闭的进度对话框。

一个进度对话框可能需要不止一次按钮点击交互,因为我们可能希望能够

  • 取消进度,
  • 等待结果显示结束状态,
  • 并关闭对话框。

此外,这可能很有用。

  • 以无限进度显示开始处理任务,
    也就是说,收集用于有限处理和进度显示的信息,并且
     
  • 继续进行有限进度显示,也就是说,处理步骤 1 到 n,请稍候。

ProgressDialogDemos 类中的演示展示了上述用例。接下来我们详细介绍最后一个用例,因为它最复杂,并且也部分涵盖了其他演示。所以,现在让我们看一下 Show2CancelProgressAsync 方法。

async Task<int> Show2CancelProgressAsync(IMetroWindow parentWindow
                                       , bool closeDialogOnProgressFinished = false)

这个演示是直接从 MainWindow 的代码中调用的,但是如果使用上面解释的通过 DialogParticipation 类进行注册和上下文对象的方法,我们也可以将具体的视图抽象出来。

所以,让我们看看方法本身

// Configure 1 progress display with its basic settings
progressColl[0] = new ProgressSettings(0, 1, 0, true // IsInfinite
                                        , progressText
                                        , false        // isCancelable
                                        , isVisible
                                        , closeDialogOnProgressFinished)
{
    Title = "Please wait...",
    Message = "We are baking some cupcakes!",
    ExecAction = GenCancelableSampleProcess()
};

// Configure 2nd progress display with its basic settings
progressColl[1] = new ProgressSettings(0, 1, 0, false // IsInfinite
                                        , progressText
                                        , true        // isCancelable
                                        , isVisible
                                        , closeDialogOnProgressFinished)
{
    Title = "Please wait... some more",
    Message = "We are baking some cupcakes!",
    ExecAction = GenCancelableSampleProcess()
};

上述代码初始化了一个包含 2 个进度配置对象的数组,第一个是无限且不可取消的进度,第二个是有限且用户可以取消的进度。如果确定处理范围的第一阶段很快,而第二阶段可能需要很长时间但用户可以详细观察(例如步骤 1-10),则此用例可能有效。

接下来的三行代码初始化视图模型和视图,其中视图模型通过构造函数分配给 DataContext 属性。我们上面创建的设置对象数组被传递给 StartProcess 方法,该方法以即发即弃的方式启动处理任务。也就是说,控制立即返回到 GetService() 行,该行查找 IContentDialogService 以显示并等待进度对话框结果。

var viewModel = new Demos.ViewModels.ProgressDialogViewModel();
var customDialog = CreateProgressDialog(viewModel);

// Start Task in ProgressViewModel and wait for result in Dialog below
viewModel.StartProcess(progressColl);

var dlg = GetService<IContentDialogService>();
var manager = dlg.Manager;

var result = await manager.ShowMetroDialogAsync(parentWindow, customDialog);

Console.WriteLine("Process Result: '{0}'", viewModel.Progress.ProcessResult);

return result;

进度对话框可以通过多种方式关闭,但其中大多数都通过以下方式路由:

  • 当用户单击关闭按钮或对话框右上角的 (X) 按钮时调用的 CloseCommand,或者
     
  • 如果代码在进度结束时调用视图模型的 OnExecuteCloseDialog() 方法。

正在发生什么取决于上面提到的配置数组。但是,这在 StartProcess 方法的即发即弃方式下是如何工作的呢?我们也来看看这个

进度设置对象数组的核心是这个属性

public Action<CancellationToken, IProgress> ExecAction { get; set; }

它是一个接受 2 个参数的 void 方法的抽象表示

  • CancellationToken 和
  • 实现 IProgress 接口的对象。

实际调用的 void 方法不一定在编译时已知,但可以在运行时分配并通过上述属性调用。因此,.Net 框架调用分配的方法(更确切地说,Action),在我们的例子中,它在以下位置生成:

  • private Action<CancellationToken, IProgress> GenSampleNonCancelableProocess() 或
  • private Action<CancellationToken, IProgress> GenCancelableSampleProcess()

调用基本上发生在下面的代码示例的 foreach 循环中。在这里,ProgressViewModel 根据当前设置重置自身,检查是否有任何取消请求

  • _CancelToken.ThrowIfCancellationRequested(); // 如果是,则抛出异常)

并启动分配的 void 方法

  • item.ExecAction(_CancelToken, Progress);

使其能够访问取消令牌和 IProgress 接口,以响应取消请求或在对话框中显示进度的当前状态。

internal void StartProcess(ProgressSettings[] settings)
{
    _CancelTokenSource = new CancellationTokenSource();
    _CancelToken = _CancelTokenSource.Token;
    Progress.Aborted(false, false);
    IsEnabledClose = false;
    SetProgressing(true);

    Task taskToProcess = Task.Factory.StartNew(stateObj =>
    {
        try
        {
            foreach (var item in settings)
            {
                this.ResetSettings(item);
                _CancelToken.ThrowIfCancellationRequested();

                item.ExecAction(_CancelToken, Progress);
            }
        }
        catch (OperationCanceledException)
        {
            Progress.Aborted(true, true);
        }
        catch (Exception)
        {
        }
        finally
        {
            SetProgressing(false);
            IsEnabledClose = true;

            if (CloseDialogOnProgressFinished == true &&
                Progress.AbortedWithCancel == false && Progress.AbortedWithError == false)
            {
                OnExecuteCloseDialog();
            }
        }
    });
}

显然,异常处理程序应该更完善,例如,添加 throw 语句,以便用户有机会了解为什么某些事情在不起作用时可能不起作用。最起码应该做的是显示一个带有异常的消息框和/或使用 Log4Net 之类的东西记录异常。OperationCanceledException 通过 Progress.Aborted() 方法调用处理。在更复杂的场景中,这还可以涉及一个清理/处置方法,该方法也可以通过另一个 Action() 属性参数定义来定义。

上述代码中的最后一个 if 块执行关闭对话框方法,如果进度配置如此,并且没有涉及取消或错误,则该方法将自动关闭对话框。

MDemo 摘要

下图总结了我们在前几节中看到的项目。MWindowInterfaceLIb 中的 IBaseMetroDialigFrameViewModel 接口指示了对话框视图模型中应实现的基本项目,以成功实现另一种 CustomDialog。此接口在 MsgDemoViewModel 中实现,后者是 MDemo 程序集中所有对话框的基础。

如果您决定构建一个基于 IBaseMetroDialigFrameViewModel 类型接口的对话框,那么 DialogIntResult 和 DialogStatChangedEventArgs 类是很有用的基类,就像我在这里做的那样。

应该指出的是,生成的对话框值不限于任何数量的按钮或特定数据类型,因为基类使用 IBaseMetroDialigFrameViewModel 灵活定义。

呼,我想这就是我现在能想到的进度对话框演示的所有内容。

我们应该意识到,MDemo 应用程序的 Demos 命名空间中的所有代码通常应该隐藏在一个单独的程序集中。因此,让我们看看 IMessageBoxService 的实现,以确切了解如何在下面的下一节中完成此操作。

IMessageBoxService 对话框服务

内置消息框服务基于我几年前设计和实现的接口。MWindowInterfacesLib.MsgBox.Enums 命名空间中配置 MsgBoxDialog 的枚举是相同的,除了 StaticMsgBoxModes,这将在下面进一步详细介绍。

MsgBoxResult 枚举配置可获得的结果,而 MsgButtons 和 MsgBoxImage 枚举配置对话框中显示的按钮和图像(另请参见 IMessageBoxService.cs on CodePlex)。您应该能够轻松重用 ContentDialog 版本,因为我确保旧 API 大部分仍然可用,并且通过新设置进行了扩展以利用新功能。

向后兼容性得到保证,因为我能够重用大部分代码,只进行了少量修改,而且我还实现了带有 多年前实现的 17 个预定义测试 的测试页面。这是一个消息框显示的示例

var msg = GetService<IContentDialogService>().MsgBox;
var result = await msg.ShowAsync("Displays a message box", "WPF MessageBox");

此服务为您提供了查看消息对话框的相同选项,但这次您可以选择消息框是否应支持

  1. 异步 - await 场景(仅限 ContentDialog)或
  2. 具有 3 种显示选项的正常模态阻塞场景
    • 一个 ContentDialog,或
    • 一个显示在主窗口上的模态固定对话框,或
    • 一个显示在主窗口上但可以拖动的模态可拖动对话框

第一个异步场景由名为 ShowAsync 的 IMessageBoxService 方法调用覆盖,而第二个场景由与之前实现中相同的 Show 方法调用覆盖。

ContentDialog 是一个 UserControl,它作为 MainWi 的一部分显示

场景

ndow 的内容。模态固定对话框是传统意义上的模态 MetroWindow 对话框,但它显示在 MainWindow 上,就像 ContentDialog 一样。因此,如果实际的 ContentDialog 不可能,模态固定对话框是 ContentDialog 的一个很好的近似。

可移动或可拖动模态对话框是一个模态对话框,它显示在主窗口上方,但可以通过标题栏拖动。

上述第二种情况的 3 个选项可以使用以下方式进行配置:

  • IMetroDialogFrameSettings DialogSettings { get; }

IContentDialogService 接口中的属性。此 DialogSetting 通过以下方式进行评估

  • protected StaticMsgBoxModes MsgBoxModes { get; }

IMessageBoxService 服务的属性,用于确定应该构建 UserControl 还是 MetroWindow。MetroWindow 通过将 MetroThump 控件附加到窗口中的相应事件处理程序(请参见 DialogManager.ShowModalDialogExternal() 方法)来使其可拖动。

...

            if (settings.MsgBoxMode == StaticMsgBoxModes.ExternalMoveable)
            {
                // Relay drag event from thumb to outer window to let user drag the dialog
                if (dlgControl.DialogThumb != null && dlgWindow is IMetroWindow)
                    ((IMetroWindow)dlgWindow).SetWindowEvents(dlgControl.DialogThumb);
            }
...

最后这个调整是必要的,因为 MsgBoxView (UserControl) 完全覆盖了原始对话框,使得其 DialogChrome 中的缩略图无法被鼠标光标访问。因此,模态对话框实际上是 4 个主要图层项的堆栈:MetroWindow (在底部) 及其上方 3 个图层 (DialogFrame, DialogChrome, 和 View,如上述示意图所示)。

IMessageBoxService 摘要

下图展示了另一种基于 IBaseMetroDialogFrame 实现 ContentDialog 视图的方法。我没有为上面的 CustumDialog 实现此接口,因为我希望 CustomDialog 更灵活、更简单,因此我省略了 SetZIndex 之类的东西,因为我觉得在那里没有必要。

由此产生的接口 IMsgDialogFrame 在可以报告回调用方结果方面也具有灵活性。我显然实现了我以前使用的东西,但您可以自由地使用完全不同的 TResult 枚举或数据类型(int 等)来实现自己的实现。

关注点

一路异步

“一路异步”的说法是指您应该用异步代码调用异步代码,等等。也就是说,如果您开始使用异步语句,您应该将其一直用到调用根,以确保您的代码行为一致,否则您将遇到奇怪的行为,这些行为难以查找或修复。

在这个项目中,我对 async 和 await 有了更深入的理解。虽然许多人告诉我,我不应该人为地阻塞异步代码 [4],但有时这可能是必要的。在这个项目中,为了支持旧 API 的同时提供新 UI,阻塞异步代码是必要的。我很幸运地发现了 Stephen Toub 的 WPF 技巧,并且我在 MWindowDialogLib 项目的 MessageBoxServiceImpl 类中实现它从未失败过。

public static void WaitWithPumping(this Task task)
{
    if (task == null) throw new ArgumentNullException(“task”);

    var nestedFrame = new DispatcherFrame();

    task.ContinueWith(_ => nestedFrame.Continue = false);

   Dispatcher.PushFrame(nestedFrame);
   task.Wait();
}

上面的代码很难找到,因为许多来源正确地指出,您不应该阻塞异步代码 [4]。但决定权应该留给那些做出决定的人,而不是那些在论坛中发布答案的人......

可选绑定

在 WPF 中设计 XAML 并不总是清晰明了,因为按钮在某些用例中可能很有用,但在其他用例中则不必要或不需要。我过去常常在视图模型上使用 `Visibility` 属性来隐藏这些情况下的元素。一种等效但更优雅的解决方案是在绑定不可用时隐藏元素。我将此类绑定称为可选绑定,因为绑定不是必需的,如果不存在,也不会出现错误或警告。以下是前面讨论的 `DialogFrame` 控件的示例代码:

<TextBlock Text="{Binding Title}"
           TextWrapping="Wrap" >
    <TextBlock.Visibility>
        <PriorityBinding>
            <Binding Path="Title" Converter="{StaticResource nullToVisConv}" />
            <Binding Source="{x:Static Visibility.Collapsed}" Mode="OneWay" />
        </PriorityBinding>
    </TextBlock.Visibility>
</TextBlock>

上述列表显示了在 DialogFrame 控件内显示对话框“标题”的 TextBlock。但是,有些对话框不需要标题。因此,PriorityBinding 会判断标题是否可以绑定(通过 nullToVisConv 转换器返回可见性建议)。如果第一个选项无法绑定,PriorityBinding 将评估第二个选项。第二个选项不会被错过,并且始终会评估为 Visibility.Collapsed - 隐藏 TextBlock 并为其他元素提供可用空间。

 

下面的列表展示了 DialogFrame 控件的 Close 按钮的类似但更高级的方法。在这里,IsEnabledClose 属性也可以是可选的,如果对话框在所有情况下都应该可以关闭(例如 MessageBoxDialog),但如果对话框处于关闭它可能导致灾难的状态(例如 ProgressDialog),则该属性应该存在且有用。

<Button Command="{Binding CloseCommand}"
    ToolTip="close"
    Style="{DynamicResource {x:Static reskeys:ResourceKeys.WindowButtonStyleKey}}">
<Button.Visibility>
    <PriorityBinding>
        <Binding Path="CloseWindowButtonVisibility" Converter="{StaticResource BoolToVisConverter}" Mode="OneWay" UpdateSourceTrigger="PropertyChanged"/>
        <Binding Source="{x:Static Visibility.Collapsed}" Mode="OneWay" />
    </PriorityBinding>
</Button.Visibility>
<Button.IsEnabled>
    <PriorityBinding>
        <Binding Path="IsEnabledClose" Mode="OneWay" UpdateSourceTrigger="PropertyChanged"/>
        <Binding>
            <Binding.Source>
                <sys:Boolean>True</sys:Boolean>
            </Binding.Source>
        </Binding>
    </PriorityBinding>
</Button.IsEnabled>
<Button.Content>
    <Grid>
        <TextBlock Text="r" FontFamily="Marlett" FontSize="14" VerticalAlignment="Center" HorizontalAlignment="Center" Padding="0,0,0,1" />
    </Grid>
</Button.Content>
</Button>

可选绑定的效果是,视图可以优雅地处理绑定不可用的情况。相同的视图可以更灵活,因为它可以在没有视图模型的额外支持下处理更多情况。

聚焦于

使 ContentDialog 成为模态有点令人头疼 [5]。我曾遇到问题,无法确保用户无法激活 ContentDialog 之外的控件。这尤其困难,因为 WPF 似乎有几种方法让用户激活其他控件(例如:光标键、Tab 等)。我在这里找到的最佳解决方案是完全禁用 MainWindow 并将焦点设置在被不可聚焦区域包围的对话框区域中。

因此,任务的第一部分——禁用 MainWindow 中的所有内容——通过在 MetroWindow 控件中添加一个新的 bool **IsContentDialogVisible** 依赖属性来实现。此属性在显示或隐藏对话框的每个方法中设置/取消设置。如果当前显示一个或多个对话框,则 **IsContentDialogVisible** 属性为 true,否则为 false。MetroWindow.xaml 的 XAML 中有一个触发器,当显示 ContentDialog 时,它会使窗口按钮无法聚焦。

<Trigger Property="IsContentDialogVisible" Value="true">
    <Setter TargetName="Restore" Property="Focusable" Value="false" />
    <Setter TargetName="Maximize" Property="Focusable" Value="false" />
    <Setter TargetName="Minimize" Property="Focusable" Value="false" />
    <Setter TargetName="Close" Property="Focusable" Value="false" />
</Trigger>

...在 MainWindow.xaml 中有一个条目,通过转换器在相同条件下禁用主菜单。

<Menu IsEnabled="{Binding Path=IsContentDialogVisible, RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type MWindow:MetroWindow}}, Converter={StaticResource InverseBooleanConverter}}">

任务的第二部分——设置焦点并确保 ContentDialog 无法逃逸——是通过一个设计决策实现的,我将对话框的内部边框设为不可聚焦。

<ControlTemplate TargetType="{x:Type Dialogs:DialogFrame}">
    <ControlTemplate.Resources>
        <Storyboard x:Key="DialogShownStoryboard">
            <DoubleAnimation AccelerationRatio=".9"
                                BeginTime="0:0:0"
                                Duration="0:0:0.2"
                                Storyboard.TargetProperty="Opacity"
                                To="1" />
        </Storyboard>
    </ControlTemplate.Resources>
    <Grid Background="{TemplateBinding Background}">
        <Border FocusVisualStyle="{x:Null}"
                Focusable="False"
                BorderBrush="{DynamicResource {x:Static reskeys:ResourceKeys.DialogFrameBrushKey}}"
                BorderThickness="1"
                >
            <ContentPresenter Grid.Row="1" Content="{TemplateBinding Content}" />
        </Border>
    </Grid>
    <ControlTemplate.Triggers>
        <EventTrigger RoutedEvent="Loaded">
            <EventTrigger.Actions>
                <BeginStoryboard Storyboard="{StaticResource DialogShownStoryboard}" />
            </EventTrigger.Actions>
        </EventTrigger>
    </ControlTemplate.Triggers>
</ControlTemplate>

...并通过键盘导航循环设置,赋予了放置在上述对话框(代替 ContentPresenter)内部的 DialogChrome 聚焦和保持焦点的能力。

<UserControl x:Class="MWindowDialogLib.Dialogs.DialogChrome"
...
             Focusable="True"
             FocusVisualStyle="{StaticResource {x:Static SystemParameters.FocusVisualStyleKey}}"
             KeyboardNavigation.DirectionalNavigation="Cycle"
             KeyboardNavigation.TabNavigation="Cycle"
             KeyboardNavigation.ControlTabNavigation="Cycle"
            > ...

上述关于可聚焦性的 XAML 是在 CustomDialogs 加载事件中执行代码的先决条件;

private void MsgBoxDialog_Loaded(object sender, RoutedEventArgs e)
{
    Dispatcher.BeginInvoke(new Action(() =>
    {
        bool bForceFocus = true;
        var vm = this.DataContext as IBaseMetroDialogFrameViewModel<int>;

        if (vm != null)
        {
            // Lets set a focus only if there is no default button, otherwise
            // the button will be focused via binding and behaviour in xaml...
            // But the focus should be gotten for sure since users can otherwise
            // tab or cursor navigate the focus outside of the content dialog :-(
            if ((int)vm.DefaultCloseResult > 1)
            {
                bForceFocus = false;
            }
        }

        if (bForceFocus == true)
        {
            this.Focus();

            if (this.PART_Msg_Content != null)
                this.PART_Msg_Content.Focus();
        }

    }));
}

此代码尝试将焦点设置在我们上面讨论的 DialogChrome 上,或者如果 ViewModel 指示我们应该有默认按钮,则让按钮获取焦点。XAML 可以使用 SetKeyboardFocusWhenIsDefault 行为在加载时将焦点设置到默认按钮上,如果它被标记为默认(通过绑定或静态 IsDefault 属性)。

我绝不是焦点问题的专家,但我通过研究和组合理论尝试了不同的设置和情况。这是我能想到的最佳解决方案。欢迎对此提出任何意见。

结论

本文介绍的 ContentDialogService 展示了 WPF 控件库的灵活性,因为我采用了相当一部分经过测试和可用的源代码——这些代码已有 大约 4 年历史 [1] ——并且在与原始实现类似但又截然不同的上下文中使用它们时没有遇到太多麻烦。因此,我坚信 WPF 与 MVVM 确实是迈向软件可重用性和以用户为中心的 UI 设计的里程碑。

我们可以通过这个项目验证,像面向服务接口这样的经典软件架构模式仍然是构建软件的绝佳基础。而 WPF 绑定技术使最终的 UI 更加灵活。

软件工程不仅仅关乎接口、算法和结构。WPF 还要求对 UI 元素进行视觉设计和分解(即框架、边框、视图),以提供最佳和最灵活的解决方案。我们可以通过 4 种不同的对话框(进度、消息框、输入和登录)验证 MVVM 的灵活性,所有这些对话框都只基于一个 CustomDialog(视图)实现。

参考文献

© . All rights reserved.