다음을 통해 공유


Asynchronous validation in WPF using the MVVM pattern and INotifyDataErrorInfo

WPF 4.5 has added the ability to use asynchronous input validation using the INotifyDataErrorInfo Interface, available until now only to Silverlight developers.

INotifyDataErrorInfo fits nicely into the MVVM pattern by isolating validation from the UI and making it the responsibility of the ViewModel.

Let’s look at a small example and see how we can use it in our WPF program.

Our program contains a text box. As the user types a URL into the box, we verify that the URL is valid and that the site is reachable.
If the URL is not reachable or invalid, we set the edges of the textbox to red and show a tooltip indicating the error.

Let’s start with the XAML. We need define a text box style containing triggers for valid and invalid control states. The style will set the tooltip text. The red edges are a part of the default behavior of WPF.
This is our XAML markup:

<Window x:Class="ValidationExample.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:c="clr-namespace:ValidationExample"
        Title="MainWindow" Height="350" Width="525">
    
    <Window.DataContext>
        <c:MainScreenViewModel></c:MainScreenViewModel>
    </Window.DataContext>
    <Window.Resources>
        <Style x:Key="myStyle" TargetType="TextBox">
            <Style.Triggers>
                <Trigger Property="Validation.HasError" Value="True">
                    <Setter Property="ToolTip">
                        <Setter.Value>
                            <Binding Path="(Validation.Errors).CurrentItem.ErrorContent" RelativeSource="{x:Static RelativeSource.Self}" />
                        </Setter.Value>
                    </Setter>
                </Trigger>
                <Trigger Property="Validation.HasError" Value="False">
                    <Setter Property="ToolTip">
                        <Setter.Value>
                            Website has been reached.
                        </Setter.Value>
                    </Setter>
                </Trigger>
            </Style.Triggers>
        </Style>
    </Window.Resources>
    
    <Grid>
        <Border BorderBrush="Black" BorderThickness="1">
            <TextBox Style="{StaticResource myStyle}" TextWrapping="Wrap" Height="20" Width="200" Text="{Binding URLName ,
                UpdateSourceTrigger=PropertyChanged, 
                ValidatesOnNotifyDataErrors=True, 
                Mode=TwoWay,
                NotifyOnValidationError=True}" />
        </Border>
    </Grid>
</Window>

Notice we defined  our ViewModel in the DataContext of the window

<Window.DataContext>
   <c:MainScreenViewModel></c:MainScreenViewModel>
</Window.DataContext>

Our TextBox is set to use the ViewModel in a two way binding. UpdateSourceTrigger will allow the ViewModel to accept the new value as the user types in the box and ValidatesOnNotifyDataErrors  will include the NotifyDataErrorValidationRule.
NotifyOnValidationError will raise the Error attached event on the TextBox:

<TextBox Style="{StaticResource myStyle}" TextWrapping="Wrap" Height="20" Width="200" Text="{Binding URLName ,
                UpdateSourceTrigger=PropertyChanged, 
                ValidatesOnNotifyDataErrors=True, 
                Mode=TwoWay,
                NotifyOnValidationError=True}" />

Our ViewModel class is implementing two interfaces:** INotifyPropertyChanged** and INotifyDataErrorInfo. Both are essential for the MVVM pattern in WPF. There are lots of tutorials on INotifyPropertyChanged so I am going to concentrate on INotifyDataErrorInfo.
INotifyDataErrorInfo has three methods:

  • HasErrors: Gets a value that indicates whether the entity has validation errors.
  • GetErrors: Gets the validation errors for a specified property or for the entire entity.
  • ErrorsChanged: An event which occurs when the validation errors have changed for a property or for the entire entity.

We are going to keep the errors in a ConcurrentDictionary using our ViewModel property name as key and a list of ValidationResults describing the errors as value. ValidationResult is a useful addition to System.ComponentModel.DataAnnotations, and allows us to validate models with the use of System.ComponentModel.DataAnnotations.Validator, utilizing validation attributes on our properties.

Here is our ViewModel:

 

using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
 
namespace ValidationExample
{
    public class MainScreenViewModel : INotifyPropertyChanged, INotifyDataErrorInfo
    {
        private string uRLName = null;
        public event PropertyChangedEventHandler PropertyChanged;
        public event EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
        private ConcurrentDictionary<string, List<ValidationResult>> modelErrors = new ConcurrentDictionary<string, List<ValidationResult>>();
 
        private void NotifyPropertyChanged([CallerMemberName] String propertyName = "")
        {
            if (PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
 
        private void NotifyErrorsChanged([CallerMemberName] String propertyName = "")
        {
            if (ErrorsChanged != null)
                ErrorsChanged(this, new DataErrorsChangedEventArgs(propertyName));
        }
 
        public bool HasErrors
        {
            get
            {
                return modelErrors.Count > 0;
            }
        }
        public IEnumerable GetErrors(string propertyName)
        {
            List<ValidationResult> propertyErrors = null;
            modelErrors.TryGetValue(propertyName, out propertyErrors);
            return propertyErrors;
 
        }
 
        [ReachableURL]
        public string URLName
        {
            get
            {
                return this.uRLName;
            }
            set
            {
                if (value != this.uRLName)
                {
                    this.uRLName = value;
                    NotifyPropertyChanged();
                    ValidateURL(uRLName);
                }
            }
        }
 
        private async void ValidateURL(string uRLName, [CallerMemberName] String propertyName = "")
        {
            await Task.Factory.StartNew(() =>
            {
                // Remove the existing errors for this property
                List<ValidationResult> existingErrors;
                modelErrors.TryRemove(propertyName, out existingErrors);
 
                // Check for valid URL
                var results = new List<ValidationResult>();
                ValidationContext vc = new ValidationContext(this) { MemberName = propertyName };
                if (!Validator.TryValidateProperty(this.URLName, vc, results) && results.Count > 0)
                {
                    modelErrors.AddOrUpdate(propertyName, new List<ValidationResult>()
                    { new ValidationResult(results[0].ErrorMessage)
                    },
                    (key, existingVal) =>
                    {
                        return new List<ValidationResult>() 
                    { new ValidationResult(results[0].ErrorMessage)
                    };
                    });
                    NotifyErrorsChanged(propertyName);
                }
            });
        }
    }
}

We are calling ValidateURL as the value in our TextBox changes and raise the event NotifyErrorsChanged as we finish checking for errors.
You can see we use a custom attribute called: [ReachableURL] to check if the URL can be reached.
Here is the ReachableURL attribute:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
using System.Net.Http;
 
namespace ValidationExample
{
    [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)]
    public class ReachableURL : DataTypeAttribute
    {
        private static Lazy<HttpClient> lazyHttpClient = new Lazy<HttpClient>(()=> new HttpClient());
        private static Regex _regex = new Regex(@"^(https?|ftp):\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture);
 
        public ReachableURL()
            : base(DataType.Url)
        {
            ErrorMessage = "Invalid URL";
        }
 
        public override bool IsValid(object value)
        {
            if (value == null)
            {
                return true;
            }
 
            string valueAsString = value as string;
            if (valueAsString != null && _regex.Match(valueAsString).Length > 0)
            {
                Task<bool> isReachableTask = IsReachableURL(value);
                isReachableTask.Wait();
                return isReachableTask.Result;
            }
            return false;
        }
 
        private async Task<bool> IsReachableURL(object value)
        {
            bool isReachableURL = false;
            
            try
            {
                lazyHttpClient.Value.CancelPendingRequests();
                Uri uri = new Uri(value.ToString());
                string responseBody = await lazyHttpClient.Value.GetStringAsync(uri);
                isReachableURL = responseBody.Length > 0;
            }
            catch (Exception e)
            {
                ErrorMessage = e.Message;
            }
            return isReachableURL;
        }
    }
}

It is simply an HttpClient call to download the HTML on the URL we requested.

As we type into the Textbox, HTTP requests are being asynchronously made from the program and the results are wired via MVVM back to the TextBox’s control state.

Conclusion

The INotifyDataErrorInfo Interface is a welcome addition to WPF and is playing an important role in making MVVM a more complete pattern. It allows for better Unit testing and separation of concerns when performing complicated data and input validation and allows a simpler way for the UI to stay responsive as the application’s elements are changing states.