Xamarin MvvmCross ViewModel Validation
Asked Answered
E

2

11

I'm building my first Xamarin MvvmCross application at the moment and I'm currently looking at validating user input to the view models.

Doing a lot of searching around everything (including the MvvmCross team) link to this plugin:

MVVMCross.Plugins.Validation

This plugin makes use of a very old version of MvvmCross v3. I have tried taking the code from this plugin and building it directly into my application Core project until I came across the Bindings breaking change. I then came to the conclusion that this plugin would actually require a complete re-write due to this in order to use the latest version of MvvmCross.

So I'm now a little stuck.

What is the currently recommended best approach for performing input validation in a view model?

Erie answered 11/6, 2015 at 10:54 Comment(0)
A
28

EDIT: Add sample project on GitHub https://github.com/kiliman/mvx-samples/tree/master/MvxSamples.Validation

I use MVVM Validation Helpers http://www.nuget.org/packages/MvvmValidation/

It's a simple validation library that's easy to use. It's not tied to MvvmCross.

Here's how I use it, for example, in my SigninViewModel:

private async void DoSignin()
{
    try
    {
        if (!Validate())
        {
            return;
        }

        IsBusy = true;
        Result = "";
        var success = await SigninService.SigninAsync(Email, Password);

        if (success)
        {
            Result = "";
            ShowViewModel<HomeViewModel>();
            Close();
            return;
        }

        Result = "Invalid email/password. Please try again.";
    }
    catch (Exception ex)
    {
        Result = "Error occured during sign in.";
        Mvx.Error(ex.ToString());
    }
    finally
    {
        IsBusy = false;
    }
}

private bool Validate()
{
    var validator = new ValidationHelper();
    validator.AddRequiredRule(() => Email, "Email is required.");
    validator.AddRequiredRule(() => Password, "Password is required.");

    var result = validator.ValidateAll();

    Errors = result.AsObservableDictionary();

    return result.IsValid;
}

The nice part of it is that you can get Errors as a collection and bind them in your view. For Android, I set the Error property to the keyed Error message.

<EditText
    android:minHeight="40dp"
    android:layout_margin="4dp"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    android:inputType="textEmailAddress"
    android:hint="Email"
    local:MvxBind="Text Email; Error Errors['Email']"
    android:id="@+id/EmailEditText" />
<EditText
    android:minHeight="40dp"
    android:layout_margin="4dp"
    android:inputType="textPassword"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    android:hint="Password"
    local:MvxBind="Text Password; Error Errors['Password']"
    android:id="@+id/PasswordEditText" />

And here's what the validation looks like:

Validation message

EDIT: show helper code

public static class ValidationResultExtension
{
    public static ObservableDictionary<string, string> AsObservableDictionary(this ValidationResult result)
    {
        var dictionary = new ObservableDictionary<string, string>();
        foreach (var item in result.ErrorList)
        {
            var key = item.Target.ToString();
            var text = item.ErrorText;
            if (dictionary.ContainsKey(key))
            {
                dictionary[key] = dictionary.Keys + Environment.NewLine + text;
            }
            else
            {
                dictionary[key] = text;
            }
        }
        return dictionary;
    }
}

public class ObservableDictionary<TKey, TValue> : IDictionary<TKey, TValue>, INotifyCollectionChanged, INotifyPropertyChanged
{
    private const string CountString = "Count";
    private const string IndexerName = "Item[]";
    private const string KeysName = "Keys";
    private const string ValuesName = "Values";

    private IDictionary<TKey, TValue> _dictionary;

    protected IDictionary<TKey, TValue> Dictionary
    {
        get { return _dictionary; }
    }

    public ObservableDictionary()
    {
        _dictionary = new Dictionary<TKey, TValue>();
    }

    public ObservableDictionary(IDictionary<TKey, TValue> dictionary)
    {
        _dictionary = new Dictionary<TKey, TValue>(dictionary);
    }

    public ObservableDictionary(IEqualityComparer<TKey> comparer)
    {
        _dictionary = new Dictionary<TKey, TValue>(comparer);
    }

    public ObservableDictionary(int capacity)
    {
        _dictionary = new Dictionary<TKey, TValue>(capacity);
    }

    public ObservableDictionary(IDictionary<TKey, TValue> dictionary, IEqualityComparer<TKey> comparer)
    {
        _dictionary = new Dictionary<TKey, TValue>(dictionary, comparer);
    }

    public ObservableDictionary(int capacity, IEqualityComparer<TKey> comparer)
    {
        _dictionary = new Dictionary<TKey, TValue>(capacity, comparer);
    }

    #region IDictionary<TKey,TValue> Members

    public void Add(TKey key, TValue value)
    {
        Insert(key, value, true);
    }

    public bool ContainsKey(TKey key)
    {
        return Dictionary.ContainsKey(key);
    }

    public ICollection<TKey> Keys
    {
        get { return Dictionary.Keys; }
    }

    public bool Remove(TKey key)
    {
        if (key == null)
        {
            throw new ArgumentNullException("key");
        }

        TValue value;
        Dictionary.TryGetValue(key, out value);
        var removed = Dictionary.Remove(key);
        if (removed)
        {
            OnCollectionChanged(NotifyCollectionChangedAction.Remove, new KeyValuePair<TKey, TValue>(key, value));
        }
        return removed;
    }

    public bool TryGetValue(TKey key, out TValue value)
    {
        return Dictionary.TryGetValue(key, out value);
    }

    public ICollection<TValue> Values
    {
        get { return Dictionary.Values; }
    }

    public TValue this[TKey key]
    {
        get
        {
            return Dictionary.ContainsKey(key) ? Dictionary[key] : default(TValue);
        }
        set
        {
            Insert(key, value, false);
        }
    }

    #endregion IDictionary<TKey,TValue> Members

    public void Add(KeyValuePair<TKey, TValue> item)
    {
        Insert(item.Key, item.Value, true);
    }

    public void Clear()
    {
        if (Dictionary.Count > 0)
        {
            Dictionary.Clear();
            OnCollectionChanged();
        }
    }

    public bool Contains(KeyValuePair<TKey, TValue> item)
    {
        return Dictionary.Contains(item);
    }

    public void CopyTo(KeyValuePair<TKey, TValue>[] array, int arrayIndex)
    {
        Dictionary.CopyTo(array, arrayIndex);
    }

    public int Count
    {
        get { return Dictionary.Count; }
    }

    public bool IsReadOnly
    {
        get { return Dictionary.IsReadOnly; }
    }

    public bool Remove(KeyValuePair<TKey, TValue> item)
    {
        return Remove(item.Key);
    }

    public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator()
    {
        return Dictionary.GetEnumerator();
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return ((IEnumerable)Dictionary).GetEnumerator();
    }

    public event NotifyCollectionChangedEventHandler CollectionChanged;

    public event PropertyChangedEventHandler PropertyChanged;

    public void AddRange(IDictionary<TKey, TValue> items)
    {
        if (items == null)
        {
            throw new ArgumentNullException("items");
        }

        if (items.Count > 0)
        {
            if (Dictionary.Count > 0)
            {
                if (items.Keys.Any((k) => Dictionary.ContainsKey(k)))
                {
                    throw new ArgumentException("An item with the same key has already been added.");
                } 
                else
                {
                    foreach (var item in items)
                    {
                        Dictionary.Add(item);
                    }
                }
            }
            else
            {
                _dictionary = new Dictionary<TKey, TValue>(items);
            }

            OnCollectionChanged(NotifyCollectionChangedAction.Add, items.ToArray());
        }
    }

    private void Insert(TKey key, TValue value, bool add)
    {
        if (key == null)
        {
            throw new ArgumentNullException("key");
        }

        TValue item;
        if (Dictionary.TryGetValue(key, out item))
        {
            if (add)
            {
                throw new ArgumentException("An item with the same key has already been added.");
            }
            if (Equals(item, value))
            {
                return;
            }
            Dictionary[key] = value;

            OnCollectionChanged(NotifyCollectionChangedAction.Replace, new KeyValuePair<TKey, TValue>(key, value), new KeyValuePair<TKey, TValue>(key, item));
        }
        else
        {
            Dictionary[key] = value;

            OnCollectionChanged(NotifyCollectionChangedAction.Add, new KeyValuePair<TKey, TValue>(key, value));
        }
    }

    private void OnPropertyChanged()
    {
        OnPropertyChanged(CountString);
        OnPropertyChanged(IndexerName);
        OnPropertyChanged(KeysName);
        OnPropertyChanged(ValuesName);
    }

    protected virtual void OnPropertyChanged(string propertyName)
    {
        if (PropertyChanged != null)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }

    private void OnCollectionChanged()
    {
        OnPropertyChanged();
        if (CollectionChanged != null)
        {
            CollectionChanged(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
        }
    }

    private void OnCollectionChanged(NotifyCollectionChangedAction action, KeyValuePair<TKey, TValue> changedItem)
    {
        OnPropertyChanged();
        if (CollectionChanged != null)
        {
            CollectionChanged(this, new NotifyCollectionChangedEventArgs(action, changedItem));
        }
    }

    private void OnCollectionChanged(NotifyCollectionChangedAction action, KeyValuePair<TKey, TValue> newItem, KeyValuePair<TKey, TValue> oldItem)
    {
        OnPropertyChanged();
        if (CollectionChanged != null)
        {
            CollectionChanged(this, new NotifyCollectionChangedEventArgs(action, newItem, oldItem));
        }
    }

    private void OnCollectionChanged(NotifyCollectionChangedAction action, IList newItems)
    {
        OnPropertyChanged();
        if (CollectionChanged != null)
        {
            CollectionChanged(this, new NotifyCollectionChangedEventArgs(action, newItems));
        }
    }
}
Appendix answered 11/6, 2015 at 14:5 Comment(12)
Thank you VERY much for posting this. Much appreciated indeed. I had actually thought of doing something similar with the FluentValidation library as I've used that one before but didn't have an idea about the actual UI binding yet. Have yourself a spanking 10 points there sir :)Erie
Could i see more of the view model please? having issues with AsObservableDictionary, to i need to create this class?Cloudcapped
Sorry. I added the helper code. AsObservableDictionary() is an extension method. And ObservableDictionary is a new class.Appendix
@kiliman thanks so much for this! Been bugging me all day!! Nice to have it working before I finish up for the day!Cloudcapped
Hi @kiliman , any idea how i can use this with the new TextInputLayout to show the error? TextInputLayout has a public method setError(String error) but im not sure how i would bind to this method in the XMLCloudcapped
Remember Xamarin bindings treat getters/setters as properties. So you should just be able to bind to the Error property of the TextInputLayout.Appendix
Errors = result.AsObservableDictionary(); this did not work for me, I had to fill the original Errors dictionary, slightly modifying extension method, so it takes an existing dictionary as a parameter. Otherwise thanks a lot.Mockery
Indeed @BanditoBunny, the code doesn't show how the Errors setter is. I think it populates an existing dictionary and so, doesn't fully replace the existing value. Also, the original dictionary may be cleared just before or some errors may remain. Kiliman could you confirm this? Thanks!Behoove
@Behoove basically the ValidationResultsExtension method takes the ValidationResult and creates a NEW dictionary and populates it from the results. It takes the property name as the key and error text as the value. I then set that to the Errors property of my ViewModel. I then use MvxBind to bind the Error property of the EditText to Error["PropertyName"]. Since AsObservableDictionary() always returns a new dictionary, you shouldn't have to clear it. Let me know if I still haven't answered your question.Appendix
@Appendix could you please provide link with the full visual studio project of this example? It would be better if you had latest version of input validation, since I can see you commented 2 years ago...Floatage
@NikasŽalias I went ahead and created a sample project. You can find it here github.com/kiliman/mvx-samples/tree/master/…Appendix
Great! How about iOS? UI controls looks like don't have 'Error' property to apply bindCensure
L
1

There's no set recommendation really, it's what you're most comfortable with. I find a lot of the options to be particularly verbose (i.e. require a lot of boiler plate code, even with some of the helper libraries).

The library that I landed on was FluentValidation for writing the rules (and they have a lot of common ones built-in and great ways for re-use/customization, including context-specific rules), and to reduce a lot of the complication and lines of code required, wrote a little helper library of my own which can be seen here (complete with example): FluentValidation MVVM Plugin

The example there uses Prism but it is not at all reliant on any MVVM framework.

Here's a peek at the example:

Class to build/validate:

public class Email
{
    public string RecipientEmailAddress { get; set; }
    public string RecipientName { get; set; }
}

Properties in your ViewModel using the Validatable object provided in my library, and Fody.PropertyChanged (which will also save you a lot of boiler-plate code for INPC):

public Validatable<string> RecipientName { get; set; } = new Validatable<string>(nameof(Email.RecipientName));
public Validatable<string> EmailAddress { get; set; } = new Validatable<string>(nameof(Email.RecipientEmailAddress));

Creating a FluentValidation AbstractValidator for the class:

public class EmailValidator : AbstractValidator<Email>
{
    public EmailValidator()
    {
        RuleFor(e => e.RecipientEmailAddress)
            .Cascade(CascadeMode.StopOnFirstFailure)
            .NotEmpty()
            .EmailAddress();

        RuleFor(e => e.RecipientName)
            .NotEmpty();

        When(e => e.RecipientName != null, () =>
        {
            RuleFor(e => e.RecipientName)
                .MinimumLength(3).WithMessage("How you bout to enter a FULL 'name' with less than 3 chars!?")
                .Must(name => name.Contains(" ")).WithMessage("Expecting at least first and last name separated by a space!");
        });
    }
}

Implementing IValidate in your ViewModel:

public void SetupForValidation() // to be called from your ViewModel's constructor
{
    // set validators and prop groups
    _emailValidator = new EmailValidator();
    _emailValidatables = new Validatables(RecipientName, EmailAddress);

    // maybe even set some defaults
    RecipientName.Value = "Fred Fredovich";
}

public OverallValidationResult Validate(Email email)
{
    return _emailValidator.Validate(email).ApplyResultsTo(_emailValidatables);
}

public void ClearValidation(string clearOptions = "")
{
    _emailValidatables.Clear(clearOptions);
}

Implementing Commands (the example below uses Prism's DelegateCommand but obviously that is not a requirement) to use those methods:

private DelegateCommand<string> _clearValidationCommand;
private DelegateCommand _validateEmailCommand;

public DelegateCommand<string> ClearValidationCommand =>
    _clearValidationCommand ?? (_clearValidationCommand = new DelegateCommand<string>(ClearValidation)); // already defined above in step 4 as part of the interface requirements

public DelegateCommand ValidateEmailCommand =>
    _validateEmailCommand ?? (_validateEmailCommand = new DelegateCommand(ExecuteValidateEmailCommand));

public void ExecuteValidateEmailCommand()
{
    var email = _emailValidatables.Populate<Email>(); // this conveniently creates a new Email instance with the values from our Validatable objects (populated by the user via the View)
    var overallValidationResult = Validate(email); // remember, this will also populate each individual Validatable's IsValid status and Errors list.

    if (overallValidationResult.IsValidOverall)
    {
        // do something with the validated email instance
    }
    else
    {
        // do something else
    }

    if (overallValidationResult.NonSplitErrors.Any())
    {
        // do something with errors that don't pertain to any of our Validatables (which is not possible in our little example here)
    }
}

Finally, the View (in XAML in this example):

<Entry
    Placeholder="Email"
    Text="{Binding EmailAddress.Value}">
    <Entry.Behaviors>
        <!-- Note this behavior is included in the Prism Library -->
        <behaviors:EventToCommandBehavior
            Command="{Binding ClearValidationCommand}"
            CommandParameter="RecipientEmailAddress"
            EventName="Focused" />
    </Entry.Behaviors>
</Entry>
<Label
    Style="{StaticResource ErrorLabelStyle}"
    Text="{Binding EmailAddress.FirstError}" />

<Button
    Command="{Binding ValidateEmailCommand}"
    Text="Validate" />

This is probably the most common use case - we have:

  • an entry to take in our uer's input (only showing 1 instead of both for each property
  • for brevity)
  • a button that will perform the validation
  • a label showing the first of potential many errors under the entry, or none of course
  • if validation succeeded
  • a behavior for clearing the validation error label once the user activates the entry again (presumably to fix the error)

But you could also a button to clear all the validation at once, or even along with the actual values (clear the whole form), etc. - just give the full example a read-through in the link to the repo, as well as the fully working Xamarin sample project using it (which includes some more advanced examples, e.g. using context-based rules).

Hope this helps...

Laidlaw answered 26/8, 2019 at 7:56 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.