Home > Back-end >  How to handle dialogs following the MVVM design pattern
How to handle dialogs following the MVVM design pattern

Time:01-08

I'm using Material Design for WPF to show a dialog which receives some inputs from the user, and I would like to return a value when it's closed. Here is the sample code:

VM's method that opens the dialog

private async void OnOpenDialog()
{
    var view = new TakeInputDialogView();
    
    var result = await DialogHost.Show(view, "RootDialog", ClosingEventHandler);
}

Dialog's VM code

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);
    }
}

When the user clicks save I would like to return Name or some object that will be constructed depending on the user's input.

Side note: I'm also using Prism library, but decided to go with Material Design dialog because I can't locate the dialog in a correct place, when I open the dialog via prism I could only open it in the center of screen or in the center of owner, but I have a single window which hosts sidebar, menu control and content control, and I need to open the dialog in the middle of content control which I wasn't able to achieve.

P.S: I could bind the DataContext of the Dialog to the VM that opens it, but I might have many dialogs and the code might grow too big.

CodePudding user response:

Never show a dialog from the View Model. This is not necessary and will eliminate the benefits MVVM gives you.

Rule of thumb

The MVVM dependency graph:

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

Note that the dependencies are on application level. They are component dependencies and not class dependencies (although class dependencies derive from the constraints introduced by the component dependencies).
The above dependency graph translates to the following rules:

In MVVM the View Model does not know the View: the View is nonexistent.
Therefore, if the View is nonexistent for the View Model it is not aware of UI: the View Model is View agnostic.
As a consequence, the View Model has no interest in displaying dialogs. It doesn't know what a dialog is.
A "dialog" is an information exchange between two subjects, in this case the user and the application.
If the View Model is View agnostic it also has no idea of the user to have a dialog with.
The View Model doesn't show dialog controls nor does it handle their flow.

Code-behind is a compiler feature (partial class).
MVVM is a design pattern.
Because by definition a design pattern is language and compiler agnostic, it doesn't rely on or require language or compiler details.
This means a compiler feature can never violate a design pattern.
Therefore code-behind can't violate MVVM.
Since MVVM is a design pattern only design choices can violate it.

Because XAML doesn't allow to implement complex logic we will always have to come back to C# (code-behind) to implement it.

Also the most important UI design rule is to prevent the application from collecting wrong data. You do this by:
a) don't show input options that produce an invalid input in the UI. For example remove or disable the invalid items of a ComboBox, so taht the user can only select valid items. b) use data validation and the validation feedback infrastructure of WPF: implement INotifyDataErrorInfo to let the View signal the user that his input is invalid (e.g. composition of a password) and requires correction. c) use file picker dialogs to force the user to provide only valid paths to the application: the user can only pick what really exists in the filesystem.

Following the above principles

  • will eliminate the need of your application to actively interact with the user (to show dialogs) for 99% of all cases.
    In a perfect application all dialogs should be input forms or OS controlled system dialogs.
  • ensure data integrity (which is even more important).

Example

The following complete example shows how to show dialogs without violating the MVVM design pattern.
It shows two cases

  • Show a user initiated dialog ("Create User")
  • Show an application initiated dialog (HTTP connection lost)

Because most dialogs have an "OK" and a "Cancel" button or only a "OK" button, we can easily create a single and reusable dialog.
This dialog is named OkDialog in the example and extends Window.

The example also shows how to implement a way to allow the application to actively communicate with the user, without violating the MVVM design rules.
The example achieves this by having the View Model expose related events that the View can handle. For example, the View can decide to show a dialog (e.g., a message box). In this example, the View will handle a ConnectionLost event raised by a View Model class. The View handles this event by showing a notification to the user.

Because Window is a ContentControl we make use of the ContentContrl.ContentTemplate property:
when we assign a data model to the Window.Content property and a corresponding DataTemplate to the Window.ContentTemplate property, we can create individually designed dialogs by using a single dialog type (OkDialog) as content host.

This solution is MVVM conform because View and View Model are still well separated in term of responsibilities.
Every solution is fine that follows the pattern WPF uses to display validation errors (event, exception or Binding based) or progress bars (usually Binding based).

It's important to ensure that the UI logic does not bleed into the View Model.
The View Model should never wait for a user response. Just like data validation doesn't make the View Model wait for valid input.

The example is easy to convert to support the Dependency Injection pattern.

Reusable key classes of the pattern

DialogId.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>

Helper classes to complete the example

MainWindow.xaml.cs
The dialog is always displayed from a component of the View.

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
The implicit DataTemplate for the content of the OkDialog.

<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; }
}

CreateUserViewModel.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));
}

MainViewModel.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);
}

User.cs

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

DatabaseRepository.cs

class DatabaseRepository
{}

HttpService.cs

class HttpService
{
  public event EventHandler ConnectionTimedOut;
}
  • Related