如何处理遵循 MVVM 设计模式的对话框



我正在使用 WPF 的材料设计来显示一个对话框,该对话框接收来自用户的一些输入,我想在关闭时返回一个值。下面是示例代码:

打开对话框的 VM 方法

private async void OnOpenDialog()
{
var view = new TakeInputDialogView();

var result = await DialogHost.Show(view, "RootDialog", ClosingEventHandler);
}

对话框的虚拟机代码

public class TakeSomeInputDialogViewModel : ViewModelBase
{
private string _name;
public string Name
{
get => _name;
set
{
SetProperty(ref _name, value);
SaveCommand.RaiseCanExecuteChanged();
}
}
public bool IsNameInvalid => CanSave();
public DelegateCommand SaveCommand { get; }
public TakeSomeInputDialogViewModel()
{
SaveCommand = new DelegateCommand(OnSave, CanSave);
}
private void OnSave()
{
DialogHost.Close("RootDialog");
}
private bool CanSave()
{
return !string.IsNullOrEmpty(Name);
}
}

当用户单击保存时,我想返回 Name 或将根据用户输入构造的某些对象。

旁注:我也在使用 Prism 库,但决定使用材料设计对话框,因为我无法在正确的位置找到对话框,当我通过 prism 打开对话框时,我只能在屏幕中央或所有者中心打开它,但我有一个托管侧边栏的窗口, 菜单控制和内容控制,我需要在内容控制中间打开对话框,但我无法实现。

PS:我可以将对话框的DataContext绑定到打开它的 VM,但我可能有很多对话框,代码可能会变得太大。

从不显示视图模型中的对话框。这不是必需的,并且会消除 MVVM 给您带来的好处。

经验法则

MVVM 依赖关系图:

View ---> View Model ---> Model

请注意,依赖项位于应用程序级别。它们是组件依赖关系,而不是类依赖关系(尽管类依赖关系派生自组件依赖关系引入的约束).
上面的依赖关系图转换为以下规则:

在 MVVM 中,视图模型不知道视图:视图不存在。
因此,如果视图模型不存在视图,则它不知道 UI:视图模型与视图无关。
因此,视图模型对显示对话框没有兴趣。它不知道对话框是什么。
"对话框"是两个主体之间的信息交换,在本例中为用户和应用程序。
如果视图模型与视图无关,它也不知道用户要与之进行对话。
视图模型不显示对话框控件,也不处理其流。

代码隐藏是编译器功能(partial class)。
MVVM 是一种设计模式。
因为根据定义,设计模式与语言和编译器无关,所以它不依赖于或不需要语言或编译器详细信息。
这意味着编译器功能永远不会违反设计模式。
因此代码隐藏不能违反 MVVM.
因为 MVVM 是一种设计模式,只有设计选择才能违反它。

由于 XAML 不允许实现复杂的逻辑,因此我们始终必须返回到 C#(代码隐藏)来实现它。

此外,最重要的 UI 设计规则是防止应用程序收集错误的数据。您可以通过以下方式执行此操作:
a) 不显示在 UI 中生成无效输入的输入选项。例如,删除或禁用ComboBox的无效项目,以便用户只能选择有效项目。 b) 使用数据验证和 WPF 的验证反馈基础结构:实现INotifyDataErrorInfo,让 View 向用户发出信号,表明他的输入无效(例如,密码的组成)并且需要更正。 c) 使用文件选取器对话框强制用户仅提供应用程序的有效路径:用户只能选择文件系统中真正存在的路径。

遵循上述原则

  • 在 99% 的情况下,应用程序无需与用户主动交互(显示对话框)。
    在完美的应用程序中,所有对话框都应该是输入表单或操作系统控制的系统对话框。
  • 确保数据完整性(这一点更为重要)。

以下完整示例演示如何在不违反 MVVM 设计模式的情况下显示对话框。
它显示了两种情况

  • 显示用户启动的对话框("创建用户")
  • 显示应用程序启动的对话框(HTTP 连接丢失)

由于大多数对话框都有一个"确定"和"取消"按钮,或者只有一个"确定"按钮,所以我们可以轻松创建一个可重用的对话框.
此对话框在示例中命名为OkDialog并扩展Window

该示例还演示如何实现一种方法,以允许应用程序主动与用户通信,而不会违反 MVVM 设计规则。
该示例通过让视图模型公开视图可以处理的相关事件来实现此目的。例如,视图可以决定显示对话框(例如,消息框)。在此示例中,View 将处理由视图模型类引发的ConnectionLost事件。视图通过向用户显示通知来处理此事件。

由于Window是一个ContentControl所以我们使用ContentContrl.ContentTemplate属性:
当我们将数据模型分配给Window.Content属性并将相应的DataTemplate分配给Window.ContentTemplate属性时,我们可以通过使用单个对话框类型 (OkDialog) 作为内容主机来创建单独设计的对话框。

此解决方案符合 MVVM,因为视图和视图模型在职责方面仍然很好地分开。
每个解决方案都很好,遵循 WPF 用于显示验证错误(事件、异常或基于绑定)或进度条(通常基于绑定)的模式。

请务必确保 UI 逻辑不会渗入视图模型.
视图模型不应等待用户响应。就像数据验证不会使视图模型等待有效输入一样。

该示例易于转换以支持依赖关系注入模式。

模式的可重用键类

对话框 Id.cs

public enum DialogId
{
Default = 0,
CreateUserDialog,
HttpConnectionLostDialog
}

IOkDialogViewModel.cs

// Optional interface. To be implemented by a dialog view model class
interface IOkDialogViewModel : INotifyPropertyChanged
{
// The title of the dialog
string Title { get; }
// Use this to validate the current view model state/data.
// Return 'false' to disable the "Ok" button.
// This method is invoked by the OkDialog before executing the OkCommand.
bool CanExecuteOkCommand();
// Called after the dialog was successfully closed
void ExecuteOkCommand();
}

OkDialog.xaml.cs

public partial class OkDialog : Window
{
public static RoutedCommand OkCommand { get; } = new RoutedCommand("OkCommand", typeof(MainWindow));
public OkDialog(object contentViewModel)
{
InitializeComponent();
var okCommandBinding = new CommandBinding(OkDialog.OkCommand, ExecuteOkCommand, CanExecuteOkCommand);
_ = this.CommandBindings.Add(okCommandBinding);
this.DataContext = contentViewModel;
this.Content = contentViewModel;
this.DataContextChanged += OnDataContextChanged;
}
// If there is no explicit Content, use the DataContext
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e) => this.Content ??= e.NewValue;
// If the content view model doesn't implement the optional IOkDialogViewModel just enable the command source.
private void CanExecuteOkCommand(object sender, CanExecuteRoutedEventArgs e)
=> e.CanExecute = (this.Content as IOkDialogViewModel)?.CanExecuteOkCommand() ?? true;
private void ExecuteOkCommand(object sender, ExecutedRoutedEventArgs e)
=> this.DialogResult = true;
}

OkDialog.xaml

Window Height="450" Width="800"
Title="{Binding Title}">
<Window.Template>
<ControlTemplate TargetType="Window">
<Grid>
<Grid.RowDefinitions>
<RowDefinition /> <!-- Content row (dynamic) -->          
<RowDefinition Height="Auto" /> <!-- Dialog button row (static) -->          
</Grid.RowDefinitions>
<!-- Dynamic content -->
<ContentPresenter Grid.Row="0" />
<StackPanel Grid.Row="1"
Orientation="Horizontal"
HorizontalAlignment="Right">
<Button Content="Ok"
IsDefault="True"
Command="{x:Static local:OkDialog.OkCommand}" />
<Button Content="Cancel"
IsCancel="True" /> <!-- Setting 'IsCancel' to 'true'  will automaitcally close the dialog on click -->
</StackPanel>
</Grid>
</ControlTemplate>
</Window.Template>
</Window>

用于完成示例的帮助程序类

MainWindow.xaml.cs
对话框始终从视图的组件显示。

partial class MainWindow : Window
{
// By creating a RoutedCommand, we conveniently enable every child control of this view to invoke the command.
// Based on the CommandParameter, this view will decide which dialog or dialog content to load.
public static RoutedCommand ShowDialogCommand { get; } = new RoutedCommand("ShowDialogCommand", typeof(MainWindow));
// Map dialog IDs to a view model class type
private Dictionary<DialogId, Type> DialogIdToViewModelMap { get; }
public MainWindow()
{
InitializeComponent();
var mainViewModel = new MainViewModel();
// Show a notification dialog to the user when the HTTP connection is down
mainViewModel.ConnectionLost += OnConnectionLost;
this.DataContext = new MainViewModel();

this.DialogIdToViewModelMap = new Dictionary<DialogId, Type>()
{
{ DialogId.CreateUserDialog, typeof(CreateUserViewModel) }
{ DialogId.HttpConnectionLostDialog, typeof(MainViewModel) }
};
// Register the routed command
var showDialogCommandBinding = new CommandBinding(
MainWindow.ShowDialogCommand, 
ExecuteShowDialogCommand, 
CanExecuteShowDialogCommand);
_ = CommandBindings.Add(showDialogCommandBinding);
}
private void CanExecuteShowDialogCommand(object sender, CanExecuteRoutedEventArgs e)
=> e.CanExecute = e.Parameter is DialogId;
private void ExecuteShowDialogCommand(object sender, ExecutedRoutedEventArgs e)
=> ShowDialog((DialogId)e.Parameter);
private void ShowDialog(DialogId parameter)
{
if (!this.DialogIdToViewModelMap.TryGetValue(parameter, out Type viewModelType)
|| !this.MainViewModel.TryGetViewModel(viewModelType, out object viewModel))
{
return;
}
var dialog = new OkDialog(viewModel);
bool isDialogClosedSuccessfully = dialog.ShowDialog().GetValueOrDefault();
if (isDialogClosedSuccessfully && viewModel is IOkDialogViewModel okDialogViewModel)
{
// Because of data bindng the collected data is already inside the view model.
// We can now notify it that the dialog has closed and the data is ready to process.
// Implementing IOkDialogViewModel is optional. At this point the view model could have already handled
// the collected data via the PropertyChanged notification or property setter.
okDialogViewModel.ExecuteOkCommand();
}
}
private void OnConnectionLost(object sender, EventArgs e)
=> ShowDialog(DialogId.HttpConnectionLostDialog);
}

MainWindow.xaml

<Window>
<Button Content="Create User"
Command="{x:Static local:MainWindow.ShowDialogCommand}"
CommandParameter="{x:Static local:DialogId.CreateUserDialog}"/>
</Window>

App.xaml
OkDialog内容的隐式DataTemplate

<ResourceDictionary>
<!-- The client area of the dialog content. 
"Ok" and "Cancel" button are fixed and not part of the client area. 
This enforces a homogeneous look and feel for all dialogs -->
<DataTemplate DataType="{x:Type local:CreateUserViewModel}">
<TextBox Text="{Binding UserName}" />
</DataTemplate>
<DataTemplate DataType="{x:Type local:MainViewModel}">
<TextBox Text="HTTP connection lost." />
</DataTemplate>

UserCreatedEventArgs.cs

public class UserCreatedEventArgs : EventArgs
{
public UserCreatedEventArgs(User createdUser) => this.CreatedUser = createdUser;
public User CreatedUser { get; }
}

创建用户视图模型.cs

// Because this view model wants to be explicitly notified by the dialog when it closes,
// it implements the optional IOkDialogViewModel interface
public class CreateUserViewModel : 
IOkDialogViewModel,
INotifyPropertyChanged, 
INotifyDataErrorInfo
{
// UserName binds to a TextBox in the dialog's DataTemplate. (that targets CreateUserViewModel)
private string userName;
public string UserName
{
get => this.userName;
set
{
this.userName = value;
OnPropertyChanged();
}
}
public string Title => "Create User";
private DatabaseRepository Repository { get; } = new DatabaseRepository();
bool IOkDialogViewModel.CanExecuteOkCommand() => this.UserName?.StartsWith("@") ?? false;
void IOkDialogViewModel.ExecuteOkCommand()
{
var newUser = new User() { UserName = this.UserName };
// Assume that e.g. the MainViewModel observes the Repository
// and gets notified when a User was created or updated
this.Repository.SaveUser(newUser);
OnUserCreated(newUser);
}
public event EventHandler<UserCreatedEventArgs> UserCreated;
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnUserCreated(User newUser)
=> this.UserCreated?.Invoke(this, new UserCreatedEventArgs(newUser));
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = "")
=> this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

主视图模型.cs

class MainViewModel : INotifyPropertyChanged
{
public CreateUserViewModel CreateUserViewModel { get; }
public event EventHandler ConnectionLost;
private Dictionary<Type, object> ViewModels { get; }
private HttpService HttpService { get; } = new HttpService();
public MainViewModel()
{
this.CreateUserViewModel = new CreateUserViewModel();
// Handle the created User (optional)
this.CreateUserViewModel.UserCreated += OnUserCreated;
this.ViewModels = new Dictionary<Type, object> 
{ 
{ typeof(CreateUserViewModel), this.CreateUserViewModel }, 
{ typeof(MainViewModel), this }, 
};
}

public bool TryGetViewModel(Type viewModelType, out object viewModel)
=> this.ViewModels.TryGetValue(viewModelType, out viewModel);
private void OnUserCreated(object? sender, UserCreatedEventArgs e)
{
User newUser = e.CreatedUser;
}
private void SendHttpRequest(Uri url)
{
this.HttpService.ConnectionTimedOut += OnConnectionTimedOut;
this.HttpService.Send(url);
this.HttpService.ConnectionTimedOut -= OnConnectionTimedOut;
}
private void OnConnectionTimedOut(object sender, EventArgs e)
=> OnConnectionLost();
private void OnConnectionLost()
=> this.ConnectionLost?.Invoke(this, EventArgs.Empt8y);
}

用户.cs

class User
{
public string UserName { get; set; }
}

数据库存储库.cs

class DatabaseRepository
{}

HttpService.cs

class HttpService
{
public event EventHandler ConnectionTimedOut;
}

在 wpf 应用程序中实际上只需要一个窗口,因为窗口是内容控件。

您可以使用称为视图模型优先和数据模板化的方法模板化整个内容。

该窗口如下所示:

<Window ....
Title="{Binding Title}" 
Content="{Binding}"
>
</Window>

然后,您将拥有一个公开标题属性的基本窗口视图模型。

当您向此窗口的实例显示视图模型时,默认情况下,您只会看到 .该视图模型的 ToString() 显示为内容。若要使其提供一些控件,您需要一个数据模板。

您可以使用数据类型将视图模型类型与控件类型相关联。

将所有标记放在用户控件中。这包括主窗口的标记。在启动时,您可以显示TheOnlyWindowIneed的空实例,然后异步设置数据上下文。 然后去获取任何数据或做任何你需要的昂贵的事情。 一旦骨架主窗口启动并可见。显示忙指示器。

您的数据模板将全部放入合并到 app.xaml 中的资源字典中。

一个例子

<DataTemplate DataType="{x:Type local:MyBlueViewModel}">
<local:MyBlueUserControl/>
</DataTemplate>

如果你现在这样做

var win = new TheOnlyWindowIneed { Content = new MyBlueViewModel() };
win.Owner=this;
win.ShowDialog();

然后,您将看到一个对话框,其中包含代码作为父级所在的窗口。数据上下文是您的 MyBlueViewModel,您的整个对话框都填充了一个 MyBlueUserControl。

您可能需要"是/否"按钮,并且可能需要一些标准化的 UI。

确认用户控件可能如下所示:

<UserControl.....
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="32"/>
</Grid.RowDefinitions>
<ContentPresenter Content="{Binding ConfirmationMessage}"
Grid.Row="1"/>
<Button HorizontalAlignment="Left"
Content="Yes"
Command="{Binding YesCommand}"
/>
<Button HorizontalAlignment="Left"
Content="No"
Command="{Binding NoCommand}"
/>
</Grid>
</UserControl>

确认视图模型将公开通常的标题,确认消息可以是另一个视图模型(和用户控件对)。然后以类似的方式将数据模板化到 UI 中。

YesCommand将是一个公共iCommand,它是从显示对话框的任何内容设置的。这将传入用户单击"是"时将要发生的任何逻辑。如果它正在删除,则它具有代码调用删除方法。它可以使用 lambda 并捕获拥有视图模型的上下文,也可以传入类的显式实例。后者对单元测试更友好。

对此对话框的调用位于视图模型中某些代码的末尾。

没有代码等待结果。

代码在 yescommand 中。

NoCommand 可能只是关闭父窗口。 通过将操作结果的代码放在 yes 命令中,您可能不需要在调用代码中"知道"用户选择了什么。它已经被处理了。

因此,您可能会决定 NoCommand 使用一些通用的窗口关闭方法:

public class GenericCommands
{
public static readonly ICommand CloseCommand =
new RelayCommand<Window>(o =>
{   
if(o == null)
{
return;
}
if(o is Window)
{
((Window)o).Close();
}
}
);

这需要对通过参数传入的窗口的引用

Command="{x:Static ui:GenericCommands.CloseCommand}"
CommandParameter="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type Window}}}"

视图模型仍然需要以某种方式启动显示窗口。

不过,您只有一个窗口。 您可以将代码放入其中,然后从唯一的窗口开始显示窗口。

您至少需要一个依赖项属性和一个回调。

public partial class TheOnlyWindowIneed : Window
{
public object? ShowAdialogue
{
get
{
return (object?)GetValue(ShowAdialogueProperty);
}
set
{
SetValue(ShowAdialogueProperty, value);
}
}
public static readonly DependencyProperty ShowAdialogueProperty =
DependencyProperty.Register("ShowAdialogue",
typeof(object),
typeof(TheOnlyWindowIneed),
new FrameworkPropertyMetadata(null
, new PropertyChangedCallback(ShowAdialogueChanged)
)
);
private static void ShowAdialogueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var win = new TheOnlyWindowIneed { Content = e.NewValue };
win.Owner = d as TheOnlyWindowIneed;
win.ShowDialog();
}

在这里,当您将 ShowAdialogue 绑定到视图模型中的属性时,当您将该属性设置为对话视图模型的实例时,它应该显示一个对话框,该视图模型作为数据上下文,并按照说明将其模板化到 UI 中。

处理此问题的一种更优雅的方法是使用发布/订阅模式和社区 mvvm 工具包信使。您可以通过信使发送要在对话中显示的视图模型,在主窗口中订阅并在该处理程序中操作。

如果您只想显示一些信息,那么还有另一种选择需要考虑。如果所有用户都使用 win10+,则可以轻松使用 Toast。 弹出的 Toast 可以包含按钮,但通常仅用于显示消息。

添加 nuget 包通知.wpf

您可以在主窗口中或通常在 win10 中弹出通知的位置显示 Toast。在主窗口中:

<toast:NotificationArea x:Name="MainWindowNotificationsArea"
Position="BottomRight" 
MaxItems="3"
Grid.Column="1"/>

在视图模型中:

var notificationManager = new NotificationManager();
notificationManager.Show(
new NotificationContent { 
Title = "Transaction Saved", 
Message = $"Transaction id {TransactionId}" },
areaName: "MainWindowNotificationsArea");

视图和视图模型是完全分离的,您不需要任何引用。 通知内容可能比此处显示的丰富得多。

我认为您的应用程序可能还必须将 win10 作为最低 O/S。

最新更新