Click here to Skip to main content
15,881,281 members
Articles / Desktop Programming / WPF

Adaptive WPF ICommand Implementation

Rate me:
Please Sign up or sign in to vote.
4.87/5 (16 votes)
28 Feb 2011CPOL8 min read 38.6K   232   26   14
Creating a command that can handle warnings and adapt to the way the user uses the UI.

Introduction

This article discusses an approach on how to make a WPF UI more user friendly by adapting warning dialogs to the way the user uses them. This particular way of changing the way the UI behaves depending on the user's preference is not new at all. The method suggested in this article focuses on how to achieve it using an implementation of ICommand.

Background

When I hit F5 in Visual Studio when I still have compiler errors in the code, a dialog like this appears:

screenshot.png

The highlighted option allows me to tell the IDE that I never want to run the last successful build. I find this feature very useful because the option to disable the warning is local to the action. That means that I do not have to find the setting for it in some preference page. It also tells me that the option to disable the warning even exists without me having to go look for such a setting.

This article aims to show how such behaviour can be added to almost any WPF command without too much hassle. It will also show how the responsibility of remembering the warnings across instances of the application or even across applications can be abstracted away from the logic spawning the warning in the first place.

This is important to me as I've seen a lot of examples trying to achieve the same functionality but succeeding only in messing up the inner cohesion of the view model owning the command.

Using the Code

Download the solution, unzip and open. The solution has a class library and a WPF test app showing of a very simple sample implementation. It's all been written using VS2010 Express Edition.

The Problem

Let's assume that there exists a command that will write some data to a file on the press of a button in some UI. The command will create the target file if it does not exist and it will overwrite the existing file if it does exist.

In such a scenario, it is reasonable to warn the user about the file being created (although it's not that important as the user should expect this), and also (more importantly) that the file will be overwritten. Other warnings may also apply such as a bad or weird filename being used.
The implementation of such a command in a view model might look something like this:

C#
public class ViewModel
{
  public ICommand SaveFileCommand { get; private set; }

  public ViewModel()
  {
    SaveFileCommand = new SomeCommand(x => true, SaveFile);
  }

  private void SaveFile(object parameter)
  {
    if (File.Exists(Filename))
    {
      MessageBoxResult result = MessageBox.Show(
        "This will overwrite the file, are you sure you want to do this?", #
        "Warning", 
        MessageBoxButton.YesNo);
                
      if (result == MessageBoxResult.No)
        return;
    }
    else
    {
      MessageBoxResult result = MessageBox.Show(
        "This will create a new file, are you sure you want to do this?", 
        "Warning", 
        MessageBoxButton.YesNo);

      if (result == MessageBoxResult.No)
        return;
    }

    File.WriteAllText(Filename, "Go do that voodoo that you do so well.");
  }
}

Assuming SomeCommand is a class implementing ICommand in a reasonable way.

The problem with this approach, as I see it, is that it adds very UI specific elements to the view model. Sure, the creation of the dialogs could be, and should be, abstracted using some interface to allow the view model to be unit tested, but that doesn't change the fact that the creation of and checking result of the dialog is the responsibility of the view model. I think that breaks a fundamental pillar of good design; high inner cohesion.

Also, notice that I haven't even taken into account that the user should be able to permanently ignore the warnings, and that such a decision needs to be remembered even if the application is closed and restarted. That implies that the preference needs to be stored somewhere, which means it has to also be loaded from somewhere at start up. If all that code has to go in to the view model, then another fundamental pillar of good design is broken; low coupling.

My Approach

The approach I've gone for moves the responsibility of loading and persisting, as well as ignoring new warnings to the ICommand implementation. This means that the view model is left with the responsibility of notifying the ICommand implementation that a warning has triggered, something that is business logic central and should be placed in the view model. I call my class implementing ICommand a TolerantCommand.
In my approach, the view model notifies the TolerantCommand using exceptions.

This means that the showing of the dialog is the responsibility of the TolerantCommand as well, but since this can be done in very different ways depending on the application, I decided to abstract that away using an interface called IDialogDisplayer.

The responsibility of persisting and loading the preferences is also abstracted in the same manner using the IWarningRepository interface.

Flowchart.png

If you think this flowchart looks weird, it's because Google doesn't support angled connectors yet in docs.

It's obvious from this flowchart that no state whatsoever can be changed by a command that aborts early due to a warning or an error, since the command may be executed any number of times for any one invocation of the ICommand.Execute. Essentially, this means that the structure of the command implementation has to be something like this:

C#
if HasWarning_A && IsNotIgnored(A)
  throw Warning("A");

if HasWarning_B && IsNotIgnored(B)
  throw Warning("B");

// The above is repeated for any number of applicable warnings

// Execute command logic here, nothing may mutate any state
// before this point
SomeLogic();

The Implementation

IWarningRepository

The definition of IWarningRepository is fairly simple since it needs to be able to do only three things:

  • Provide a list of currently ignored warnings
  • Ignore a warning
  • Acknowledge a warning (un-ignore it)

To acknowledge a warning isn't something that is done using the dialog, it's something that should be available through some preference page. I've left that implementation out in this sample because it's very application specific.

C#
namespace Bornander.UI.Commands
{
  public interface IWarningRepository<T>
  {
    IEnumerable<T> Ignored { get; }

    void Ignore(T warning);

    void Acknowledge(T warning);
  }
} 

Notice that this is a generic interface taking a type parameter T. This is because I don't think warning definitions are necessarily always the same for all applications. In some cases, an int will make sense, in another a string or an enum. It all depends on the application being written. In the sample app, I've gone for an enumeration but the implementation caters for any type.

IDialogDisplayer

The IDialogDisplayer is responsible for displaying the dialog (duh!), and returning back a result indicating what the user pressed. Much like the standard MessageBox and MessageBoxResult.

Typically, an implementation of this interface would be very minimal, pretty much only creating some sort of dialog window and depending on what the user selects, just return the value to the TolerantCommand. Regardless of what the user selects, the result is just returned because the responsibility of retrying or requesting the preference to be persisted is down to the command.

C#
namespace Bornander.UI.Commands.Tolerant
{
  public enum DialogResult
  {
    Yes,
    YesAndRememberMyDecision,
    No
  }

  public interface IDialogDisplayer
  {
    DialogResult ShowWarning(CommandWarningException warning);

    DialogResult ShowError(CommandRetryableErrorException error);
  }
}

The interface exposes three methods for the two different scenarios that require a dialog to be shown:

  • ShowWarning(CommandWarningException) for when a warning triggers.
  • ShowError(CommandRetryableErrorException) for when an transient error triggers that might work if the user tries again (like a file being locked by someone else).

In the sample application bundled with this article, the dialog looks like this:

Dialog.png

But that look is not in any way dictated by the command implementation or the supporting types, the IDialogDisplayer can delegate the actual showing of the dialog to any type of visualisation. I think this is an important aspect because the logic that drives retry-able commands should be completely decoupled from any UI specific code.

TolerantCommand

The TolerantCommand is essentially the same as the RelayCommand from this article but with added logic to the ICommand.Execute implementation. The Execute method is responsible to inspect any exceptions thrown by the execute delegate and to spawn a dialog by delegating to the IDialogDisplayer while also employing the IWarningRepository to figure out which warnings to ignore or persist.

Silent Execution

Since I sometimes run into the need to execute one or several commands programmatically (i.e. not as the result of a user action), the TolerantCommand supports a Silent execution mode where any non-ignored warnings cause the command to abort without spawning any dialogs. Granted, this kind of behaviour is for the most part overkill but I've decided to include it in this article anyway.

When constructing the TolerantCommand, both a IDialogDisplayer and a IWarningrepository instance has to be passed as well as the execute delegate and can execute predicate:

C#
public TolerantCommand(IDialogDisplayer dialogDisplayer,
                       IWarningRepository<T> repository,
                       Predicate<object> canExecute,
                       Action<object, IEnumerable<T>> execute)
{
  if (execute == null)
    throw new ArgumentNullException("execute");

  this.dialogDisplayer = dialogDisplayer;
  this.repository = repository;

  CanExecutePredicate = canExecute;
  ExecuteAction = execute;
}

The execute delegate takes not one (as in the RelayCommand implementation), but two parameters; one which is the actual command parameter, and one list of ignored warnings.

The Execute method is essentially going into a loop, trying to execute the execute delegate until successful or until the user aborts due to a warning.

C#
public void Execute(object parameter)
{
  bool isSilent = parameter is ExecuteSilent;
  object actualParameter = isSilent ? ((ExecuteSilent)parameter).Parameter : parameter;
  IList<T> localIgnorableWarnings = new List<T>(
    repository != null ?
      repository.Ignored : new T[0]);

  while (true)
  {
    try
    {
      // Execute the command
      ExecuteAction(actualParameter,
        isSilent && ((ExecuteSilent)parameter).IgnoreAllWarnings ?
          null :
            localIgnorableWarnings);
        
        return;
      }
      catch (CommandWarningException warning)
      {
        if (isSilent)
          return;
        // If a warning is thrown, show a dialog.
        // If the user accepts the warning the
        // command is executed again by letting the loop run
        // another iteration, this time with the
        // warning ignored
        switch (dialogDisplayer.ShowWarning(warning))
        {
          case DialogResult.No:
            return;
          case DialogResult.YesAndRememberMyDecision:
            // Persist the preference
            if (repository != null)
              repository.Ignore((T)warning.Warning);
          break;
        }
        localIgnorableWarnings.Add((T)warning.Warning);
      }
      catch (CommandRetryableErrorException error)
      {
        if (isSilent)
          return;

        if (dialogDisplayer.ShowError(error) == DialogResult.No)
          return;
        // If the result is Yes, then the command runs again
        // to cater for transient errors
    }
  }
}

The ExecuteSilent class is a wrapper class that wraps a command parameter allowing a parameter to be passed while still indicating that this execution is silent. Since the ICommand.Execute method takes only a single parameter, a construct like this is required to cope with the silent execution.

The Result

The resulting command implementation for the example given at the beginning of this article using TolerantCommands then becomes:

C#
private void SaveFile(object parameter, IEnumerable<Warnings> ignorableWarnings)
{
  if (File.Exists(Filename) && 
    !TolerantCommand<Warnings>.IsWarningIgnored
		(Warnings.OverwriteFile, ignorableWarnings))
  {
    throw new CommandWarningException(
      String.Format("This will overwrite the file \"{0}\", 
		are you sure you want to do that?", Filename), 
      Warnings.OverwriteFile);
  }

  if (!Filename.Contains('.') && 
    !TolerantCommand<Warnings>.IsWarningIgnored(Warnings.NoFileSuffix, ignorableWarnings))
  {
    throw new CommandWarningException(
    String.Format("The filename 
      \"{0}\" has no file suffix, are you sure you want keep it like that?", Filename), 
    Warnings.NoFileSuffix);
  }
  
  File.WriteAllText(Filename, "Go do that voodoo that you do so well.");
}

Admittedly, it doesn't look like it's that much less code, but that is mainly because the initial example didn't take persisting the preferences into account. Also, the intention was to create a sensible separation of concern where the command implementation is responsible for executing the command logic, not handling UI elements or storing user preferences, and I think this approach achieves that nicely.

Points of Interest

The sample project includes implementations of the IDialogDisplayer and IWarningRepository interfaces, but note that they're just that; sample implementations. The whole point in abstracting these elements using interfaces is that there's no way to create implementation generic enough to cater to all applications. 

The IWarningRepository sample implementation for example, EnumRepository, handles warnings defined as enums and persists them local to the user and application. But this might not be what suits your application where warnings might be defined as ints or strings, and the preferences might need to be stored in a different way.

Thanks

Thanks to George Barbu for reviewing and commenting on this article, he had some very valid points around the exception handling.

History

  • 2011-02-27: First version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior)
Sweden Sweden
Article videos
Oakmead Apps Android Games

21 Feb 2014: Best VB.NET Article of January 2014 - Second Prize
18 Oct 2013: Best VB.NET article of September 2013
23 Jun 2012: Best C++ article of May 2012
20 Apr 2012: Best VB.NET article of March 2012
22 Feb 2010: Best overall article of January 2010
22 Feb 2010: Best C# article of January 2010

Comments and Discussions

 
Questionpossibly overengineered? Pin
John Adams15-Mar-11 22:11
John Adams15-Mar-11 22:11 
AnswerRe: possibly overengineered? Pin
Fredrik Bornander15-Mar-11 23:26
professionalFredrik Bornander15-Mar-11 23:26 
GeneralRe: possibly overengineered? Pin
John Adams16-Mar-11 14:43
John Adams16-Mar-11 14:43 
GeneralThe other View Models ... Pin
ibidem1-Mar-11 1:37
ibidem1-Mar-11 1:37 
GeneralRe: The other View Models ... Pin
Fredrik Bornander1-Mar-11 1:53
professionalFredrik Bornander1-Mar-11 1:53 
GeneralVery nice Frederik Pin
Pete O'Hanlon1-Mar-11 0:18
mvePete O'Hanlon1-Mar-11 0:18 
GeneralRe: Very nice Frederik Pin
Fredrik Bornander1-Mar-11 0:57
professionalFredrik Bornander1-Mar-11 0:57 
GeneralRe: Very nice Frederik Pin
Dan Mos2-Mar-11 7:14
Dan Mos2-Mar-11 7:14 
GeneralRe: Very nice Frederik Pin
Fredrik Bornander15-Mar-11 23:28
professionalFredrik Bornander15-Mar-11 23:28 
GeneralMy vote of 5 Pin
George Barbu28-Feb-11 10:40
George Barbu28-Feb-11 10:40 
GeneralRe: My vote of 5 Pin
Sacha Barber28-Feb-11 21:43
Sacha Barber28-Feb-11 21:43 
GeneralMy vote of 5 Pin
Marcelo Ricardo de Oliveira28-Feb-11 7:48
Marcelo Ricardo de Oliveira28-Feb-11 7:48 
GeneralRe: My vote of 5 Pin
Fredrik Bornander1-Mar-11 1:55
professionalFredrik Bornander1-Mar-11 1:55 
GeneralPretty neat, and quite handy actually Pin
Sacha Barber27-Feb-11 22:17
Sacha Barber27-Feb-11 22:17 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.