简体   繁体   中英

Opening dialogs in WPF MVVM

I have started learning MVVM for a project I'm writing, and I'm sketching out some of the more complicated parts of the project beforehand to help me get a better handle on how MVVM works. One of the biggest things I'm having trouble with though is dialogs, specifically custom dialogs and message boxes. Right now, I have a list of objects, and to add a new one, a button is pressed. This button calls a command in my ViewModel which invokes a Func that returns the object I want (Pile), then adds that to the list. Here's that function

private void OnAdd()
{
    Pile? pile = GetPileToAdd?.Invoke();
    if (pile is null) return;
    Piles.Add(pile);
}

This function is set in the view when the data context gets set (I'm implementing a Model-First architecture)

private void PileScreenView_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
    if (e.NewValue is PileScreenViewModel psvm)
    {
        psvm.GetPileToAdd = () =>
        {
            MessageBox.Show("getting pile");
            return new Pile() { Name = "Name", Length = 0 };
        };
    }
}

The Messagebox.Show call will eventually get replaced with a custom dialog that will provide the data needed. My question then is:

Is this MVVM compliant? It feels a bit gross having to wait until the DataContext is changed to add the method to it, but I'm 99% sure that having the messagebox call in the ViewModel is a big no-no. Also not sure if I'm allowed to interact with the Model like this from the View.

Thanks for the help and for helping me with my MVVM journey:)

Your gut feeling is absolutely right: dialogs are components of the View as they interact with the user as part of the UI. Therefore, dialogs of any kind must be handled in the View .

Your current problem is that your View class PileScreenView depends on a particular View Model instance in order to register the callback.
Note that the callback itself violates MVVM as it delegates dialog handling to the View Model . The View Model must never execute or actively participate in any UI logic.

You can improve your design by making the dependency on the DataContext anonymous and independent (of the particular instance). This will also eliminate the need to observe DataContext changes.

Then move the dialog trigger to the View . Simply let the PileScreenView expose an "Add New Pile" button to the user.
This button will trigger the PileScreenView to show the dialog. Ideally, you would create a dedicated dialog view model class, that will hold the input data of the dialog.
Then use the dialog result (eg, the dialog view model) and call eg CreatePile method on the PileScreenViewModel in order to pass the dialog result to the View Model .
The View Model can then create the actual Pile from the dialog view model class.

It's best if the View only knows View Model types by their interface:
IPileScreenViewModel.cs

interface IPileScreenViewModel : INotifyPropertyChanged
{
  // Create the Pile in the View Model.
  // View Model should never wait for anything. 
  // It is invoked by the View after the required data is collected.
  void CreatePile(CreatePileViewDialogModel newPileInfo);
}

Then in the View you can show the dialog eg on click of a corresponding button. Dialogs an their logic should be generally designed to be triggered by the UI ie the user:

PileScreenView.xaml.cs

partial class PileScreenView : UserControl
{
  private void OnCreatePileButtonClicked(object sender, RoutedEventArgs e)
  {
    var dialogViewModel = new CreatePileViewDialogModel();
    var createPileDialog = new CreatePileDialog() { DataContext = dialogViewModel };
    createPileDialog.ShowDialog();
   
    (this.DataContext as IPileScreenViewModel)?.CreatePile(dialogViewModel);
  }
}

The above example is a very simple to demonstrate how the interaction and data flow could look like. There are the usual options to send the data to the View Model , like data binding.
For example, PileScreenView could expose a dependency property eg, CreatedPileScreenInfo . The PileScreenView would then assign the dialog result to this property, to which the PileScreenViewModel can bind to. This way the DataContext is completely unimportant for the PileScreenView .

Very important note : your current event handler introduces a potential memory leak. Since you are registering a lambda expression as event callback, you will not be able to unregister the handler. There is a chance that the garbage collector won't be able to collect the PileScreenView instance. The old PileScreenViewModel can keep it alive. You must therefore always take care to unregister event handlers. This should be your general practice to help you to guard against accidental leaks of this kind:

private void PileScreenView_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
  // Unregister handler from old view model instance
  if (e.oldValue is PileScreenViewModel oldPsvm)
  {
    oldPsvm.GetPileToAdd -= OnGetPileToAdd; 
  }

  if (e.NewValue is PileScreenViewModel newPsvm)
  {
    newPsvm.GetPileToAdd += OnGetPileToAdd; 
  }
}

private void OnGetPileToAdd(object sender, EventArgs e)
{
  // TODO::Handle view model event
}

I suggest implementing a dialog service and injecting it into the view-model.

interface IDialogService
{
    Pile? ShowPileDialog(/* any arguments that will be needed to show the dialog */);
}

class MessageBoxDialogService
{
    public Pile? ShowPileDialog(/* arguments */)
    {
        MessageBox.Show("getting pile");
        return new Pile() { Name = "Name", Length = 0 };      
    }
} 

You need to register MessageBoxDialogService as the implementation of IDialogService in your DI container.

In the view-model:

class PileScreenViewModel
{
    private IDialogService _dialogService;

    public PileScriptViewModel(IDialogService dialogService)
    {
        _dialogService = dialogService;
    }

    private void OnAdd()
    {
        Pile? pile = _dialogService.GetPile(/* pass parameters from the view model */);
        if (pile is null) return;
        Piles.Add(pile);
    }      
}

It doesn't go against the principles of MVVM. As the view-model is decoupled from the view. You can inject a fake dialog service to test the view-model in isolation. By doing so, your code will also respect the open-closed principle , ie, if the day comes and you decide to implement your custom dialog you only need to create a new dialog service class without needing to change existing classes.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM