6
\$\begingroup\$

I haven't worked with WPF/MVVM for a while, but now I'm back fixing some issues with one of my old apps and remembering the struggles I always had when dealing with asking for user input (eg. showing a dialog) from a view model.

I've put some thought into it and decided to try routing user input requests through a service interface so that it can be mocked/tested, while keeping the view logic out of my view model.

public interface IUserInputService
{
    UserRegistration PromptUserRegistration();
}

I then wrote a generic method that creates a new instance of a view and view model, connects them together, shows the dialog and then returns the dialog view model and result (eg. accepted, cancelled) back to the caller. Finally, the caller can extract information from the returned view model and return the actual desired result. The view models have events which are called when the view model is "done", eg: Accepted and Cancelled. These are hooked up using the generic method.

public class UserInputService : IUserInputService
{
    // Implementation of the service interface using the generic method below
    public UserRegistration PromptUserRegistration()
    {
        var result = ShowDialog<UserRegistrationDialog, UserRegistrationViewModel>((v, vm) =>
        {
            vm.Accepted += (s, e) => v.DialogResult = true;
            vm.Cancelled += (s, e) => v.DialogResult = false;
        });

        if (result.State != DialogState.Accepted)
        {
            return null;
        }

        return new UserRegistration
        {
            FirstName = result.ViewModel.FirstName,
            LastName = result.ViewModel.LastName
        };
    }

    // The generic method
    private DialogResult<TViewModel> ShowDialog<TView, TViewModel>(
        Action<TView, TViewModel> viewModelConnected
    )
        where TView : Window, new()
        where TViewModel : ViewModelBase, new()
    {
        TViewModel viewModel = new TViewModel();
        TView view = new TView();
        view.DataContext = viewModel;

        viewModelConnected(view, viewModel);

        view.ShowDialog();

        DialogState state;
        switch (view.DialogResult)
        {
            case true:
                state = DialogState.Accepted;
                break;
            case false:
                state = DialogState.Declined;
                break;
            default:
                state = DialogState.Cancelled;
                break;
        }

        return new DialogResult<TViewModel>(state, viewModel);
    }
}

Now I can pass around the service interface using DI/IoC and view models can ask for user input without caring how (eg. via dialog or mocking).

The other types (DialogResult and DialogState) are these:

public enum DialogState
{
    Accepted,
    Declined,
    Cancelled
}

public class DialogResult<TViewModel>
{
    public DialogResult(DialogState state, TViewModel viewModel)
    {
        State = state;
        ViewModel = viewModel;
    }

    public DialogState State { get; }
    public TViewModel ViewModel { get; }
}

So my questions are...

  1. Is this a good approach?
  2. What would you do differently and why?
  3. Any other suggestions regarding code quality?

I want to stick to the principles of MVVM as much as possible (though within reason) while keeping things testable... Let me know what you think.

\$\endgroup\$
3
  • \$\begingroup\$ Why do you have accepted and declined events? Is it not enough, that you have DialogResult? Shouldn't DialogResult be in ViewModel? Can you separate in your question what belongs to view and what to viewmodel? \$\endgroup\$
    – Rekshino
    Mar 8, 2018 at 10:52
  • \$\begingroup\$ edit: sorry, talking nonsense \$\endgroup\$
    – Pj-
    Mar 8, 2018 at 15:48
  • \$\begingroup\$ @Rekshino The Accepted/Declined events allow the VM to send a message to a subscriber (ie. the code hooking up the V/VM) that the VM is "done". Doing this means the VM doesn't need to be aware that it is attached to a dialog window, so it's reusable with other types of views. For that same reason, the VM is also not aware of the DialogResult type. \$\endgroup\$
    – user162465
    Mar 8, 2018 at 23:09

2 Answers 2

1
\$\begingroup\$

I have worked in a project where such an IUserInputService was used. After few years, the implementation was a big class containing the dialog calling logic for all modules.

Today, I would prefer a generic service which is able to connect a view with the view model without implementing the logic for each concrete dialog.


Code Review

  • Your code looks clean and tidy
  • The communication between view model and the view via events may be OK in your case, because the view model is just an internal structure not known outside the method. However, in generell I would prefer a way where the view is not references by the view model (wich is indirect the case because the delegates contain a reference to the view). Usually, I use a view model property "bool Close" and bind it to the view so that the view can react on changes and close itself.
  • Your approach does not allow creation of view models via DI container. If the VM has dependencies, they must be passed to the method PromptUserRegistration.
  • Configuring the VM with default values requires to parameterize the PromptUserRegistration as well.

All things considered, I would prefer an approach where the view model is created / configured by the calling logic and shown via an generic service because it is more flexible and less distributed code.


Note that there are also frameworks available that adress that problem. E.g. https://github.com/FantasticFiasco/mvvm-dialogs

\$\endgroup\$
1
  • 1
    \$\begingroup\$ No problem, I complemented the answer with the actuall review - I was just too tired last night ;). \$\endgroup\$
    – JanDotNet
    Mar 11, 2018 at 6:27
0
\$\begingroup\$

I really like the way that ReactiveUI handles these cases. There is a class called Interaction. You put it as public property in your VM and register handlers in your View.

public class ViewModel : ReactiveObject
{
    private readonly Interaction<string, bool> confirm;

    public ViewModel()
    {
        this.confirm = new Interaction<string, bool>();
    }

    public Interaction<string, bool> Confirm => this.confirm;

    public async Task DeleteFileAsync()
    {
        var fileName = ...;

        // this will throw an exception if nothing handles the interaction
        var delete = await this.confirm.Handle(fileName);

        if (delete)
        {
            // delete the file
        }
    }
}

public class View
{
    public View()
    {
        this.WhenActivated(
            d =>
            {
                d(this
                    .ViewModel
                    .Confirm
                    .RegisterHandler(
                        async interaction =>
                        {
                            var deleteIt = await this.DisplayAlert(
                                "Confirm Delete",
                                $"Are you sure you want to delete '{interaction.Input}'?",
                                "YES",
                                "NO");

                            interaction.SetOutput(deleteIt);
                        }));
            });
    }
}

In View layer you can of course use a service. On plus side, you can easily UnitTest various scenarios (by registering different handlers) wihtout a need for actually binding ViewModel to UI control.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.