30 September 2010

Notifying the viewmodel of input errors using MVVMLight in Silverlight

I was creating a little application that had some input fields and a submit button. I wanted the submit button only to be enabled when the user input was valid. No a very unusual use case. So I set out to create a viewmodel the usual MVVMLight way, extending ViewModelBase and making properties using the snippets Laurent Bugnion provides with the MVVMLight toolkit. To validate them, I decorated them with attributes from System.ComponentModel.DataAnnotations, and validated them with using the Validator. A typical property looks like this:

/// 
/// The  property's name.
/// 
public const string SomecodePropertyName = "Somecode";

private int _somecode = 0;

[Range(0, 2000)]
public int Somecode
{
  get
  {
    return _someCode;
  }

  set
  {
    if (_someCode == value)
    {
      return;
    }
    Validator.ValidateProperty(value,
      new ValidationContext(this, null, null) 
      { MemberName = SomecodePropertyName });

    _someCode = value;

    // Update bindings, no broadcast
    RaisePropertyChanged(SomecodePropertyName);
  }
}

Then I thought I was smart, so I added the following code:

private void RaisePropertyChangedAndRevalidate(string propertyName)
{
  RaisePropertyChanged(propertyName);
  RaisePropertyChanged("IsValid");
}

public bool IsValid
{
  get
  {
    return Validator.TryValidateObject(this, 
                       new ValidationContext(this, null, null),
                       new List<ValidationResult>());
  }
}

and changed every other call to RaisePropertyChanged to RaisePropertyChangedAndRevalidate. Then I bound my button’s IsEnabled property to IsValid

<Button Content="subMit" x:Name="btnSave" 
  IsEnabled="{Binding IsValid,ValidatesOnExceptions=True,NotifyOnValidationError=True}">

and I turned out not be so very smart after all, for it did not work. When the user entered a value that was outside the range of 0-2000 or even plain nonsense – like “thisisnonumber”, the UI gave an error message, I even got the message in my ValidationSummary, but for some reason the viewmodel still found itself valid.

This puzzled me for a while, but it turned out to be perfectly logical. If the user inputs nonsense this throws an exception, and so does a call to Validator.ValidateProperty. So the invalid input never makes to the viewmodel property, and the viewmodel, if it was initially valid, stays valid en thus my button stays enabled. How to get around this?

Actually, it’s pretty simple: notify your viewmodel of input errors, so it can keep track of whatever nonsense the user has tried to cram down its throat. The way I chose to do it was like this:

  • Add a field int _fieldErrors;
  • Bind the event BindingValidationError to a command in the viewmodel, passing it’s parameters along
  • Add to or subtract 1 from_fieldErrors for every time the command was called depending on the parameter
  • Modify the IsValid property so it checks the _fieldErrors field to.

In code:

private void RaisePropertyChangedAndRevalidate(string propertyName)
{
  RaisePropertyChanged(propertyName);
  if (_fieldBindingErrors == 0)
  {
    RaisePropertyChanged("IsValid");
  }           
}
private int _fieldBindingErrors = 0;

public ICommand RegisterFieldBindingErrors
{
  get
  {
    return new RelayCommand<ValidationErrorEventArgs>(e =>
    {
      if (e.Action == ValidationErrorEventAction.Added)
      {
        _fieldBindingErrors++;
      }
      else
      {
        _fieldBindingErrors--;
      }
      RaisePropertyChanged("IsValid");
    });
  }
}

public bool IsValid
{
  get
  {
    return Validator.TryValidateObject(this, 
                       new ValidationContext(this, null, null),
                       new List<ValidationResult>()) 
                       && _fieldBindingErrors == 0;
  }
}

Binding the stuff in XAML goes like this:

<StackPanel
  DataContext="{Binding MyModel,Mode=TwoWay,ValidatesOnExceptions=True,NotifyOnValidationError=True}">
  <i:Interaction.Triggers>
    <i:EventTrigger EventName="BindingValidationError">
          <cmd:EventToCommand Command="{Binding RegisterFieldBindingErrors}" 
            PassEventArgsToCommand="true"/>
        </i:EventTrigger>
      </i:Interaction.Triggers>
</StackPanel>

And thus the viewmodel maintains its own integrity, is notified by the UI of all problems playing around there, and can decide based upon the sum of this information whether or not the user can actually save.

Thanks to Mike Taulty for helping me out on the Validator in real-time on Twitter.

No comments: