In dem letzten Posting zu dem Thema habe ich einige Vorbereitungen getroffen, um Data Validation unter die Lupe zu nehmen. Eine simple Beispielanwendung wurde vorgestellt, die als Basis für weitere Untersuchungen dient. Diesmal geht es um
Validation Rules
Hier eine Übersicht der eingebauten Validation Rules:

Validation Rules werden direkt am Binding angegeben. Für die beiden mitgelieferten Rules gibt es zwei Möglichkeiten diese zu aktivieren.
Einmal über die Eigenschaft ValidationRules:
...
<TextBox.Text>
<Binding Source="{StaticResource cd}"
Path="Year"
UpdateSourceTrigger="PropertyChanged">
<Binding.ValidationRules>
<ExceptionValidationRule/>
<DataErrorValidationRule/>
</Binding.ValidationRules>
</Binding>
</TextBox.Text>
...
Oder explizit über die beiden Eigenschaften ValidatesOnExceptions und ValidatesOnDataErrors:
...
<TextBox.Text>
<Binding Source="{StaticResource cd}"
Path="Year"
UpdateSourceTrigger="PropertyChanged"
ValidatesOnExceptions="True"
ValidatesOnDataErrors="True">
</Binding>
</TextBox.Text>
...
Zur Erinnerung: in dem Beispiel aus dem vorherigen Posting ist eine Exception aufgetreten, wenn alle Zeichen in der TextBox gelöscht wurden. Hier kommt die ExceptionValidationRule ins Spiel. Diese Regel fängt alle Exceptions ab, die während der Aktualisierung der Datenquelle auftreten. Schauen wir uns also an, was passiert wenn in dem Beispiel diese Regel aktiviert wird.
WPF teilt uns mit, dass ein ungültiger Wert eingegeben wurde, indem ein roter Rahmen um die TextBox gezeichnet wird. Das ist die Standardvisualisierung von WPF um Validierungsfehler anzuzeigen.
ErrorTemplates
Mit Hilfe eines eigenen ErrorTemplates ist es möglich, dem Anwender eine hilfreichere Visualisierung für Eingabefehler zu bieten. Das könnte z.B. so aussehen:

Aha, zumindest wird jetzt ein erklärender Text angezeigt. Die Wortwahl gefällt mir noch nicht besonders, was daran liegt, dass einfach nur der Exceptiontext der gefangenen FormatException angezeigt wird. Das werden wir später ändern. Wie sieht nun das ErrorTemplate aus?
...
<ControlTemplate x:Key="TextBoxErrorTemplate">
<DockPanel>
<TextBlock Margin="0 0 5 0"
Text="!"
FontSize="14"
FontWeight="Bold"
Foreground="Red"/>
<AdornedElementPlaceholder x:Name="adornedElement"/>
<TextBlock Text="{Binding ElementName=adornedElement,
Path=AdornedElement.(Validation.Errors),
Converter = {local:ValidationErrorToErrorMessageConverter}}"
FontSize="12"
Foreground="Red"
Margin="5 0 0 0"/>
</DockPanel>
</ControlTemplate>
...
Im Mittelpunkt steht das zu dekorierende Element (in diesem Fall die TextBox). Über das Tag AdornedElementPlaceholder bestimmt man, an welcher Stelle im Template dieses Element platziert werden soll. Mit Hilfe eines DockPanels wird zuerst das rote Ausrufezeichen, dann die TextBox und zum Schluss noch ein TextBlock mit dem Fehlertext angezeigt. Der Fehlertext wird über Databinding von der Attached Property Validation.Errors der TextBox geholt. Das Databinding System fügt dieser Liste ein ValidationError Objekt hinzu, wenn eine Validation Rule einen Fehler zurückgibt.
Zusätzlich wird hier noch ein Value Converter verwendet. Dieser bringt die Fehler aus der Liste der ValidationErrors in ein lesbares Format (siehe hierzu auch Josh Smith’s Post für eine andere Variante). Der Code hierzu sieht so aus:
[ValueConversion(typeof(ReadOnlyObservableCollection<ValidationError>), typeof(string))]
public class ValidationErrorToErrorMessageConverter : MarkupExtension, IValueConverter
{
private ValidationErrorToErrorMessageConverter _mySelf;
public override object ProvideValue( IServiceProvider serviceProvider )
{
if (_mySelf == null)
{
_mySelf = new ValidationErrorToErrorMessageConverter();
}
return _mySelf;
}
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
ReadOnlyObservableCollection<ValidationError> errors =
value as ReadOnlyObservableCollection<ValidationError>;
StringBuilder errorMessage = new StringBuilder();
if(null != errors)
{
foreach (var error in errors)
{
errorMessage.AppendLine(error.ErrorContent.ToString());
}
return errorMessage;
}
return errorMessage;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
Der Value Converter leitet sich zusätzlich noch von MarkupExtension ab. Dadurch ist es nicht mehr nötig den Converter als Ressource anzugeben, sondern dieser kann direkt über die Markupextension Syntax verwendet werden (danke an Dr. WPF für diesen Tipp).
Das ErrorTemplate wird der TextBox folgendermaßen bekanntgemacht gemacht (siehe Zeile 5):
...
<TextBox ToolTip="Please enter year of release"
Width="60"
Margin="10"
Validation.ErrorTemplate="{StaticResource TextBoxErrorTemplate}">
<TextBox.Text>
<Binding Source="{StaticResource cd}"
Path="Year"
UpdateSourceTrigger="PropertyChanged"
ValidatesOnExceptions="True">
</Binding>
</TextBox.Text>
</TextBox>
...
Selbstgeschriebene Validation Rules
Zusätzlich zu den mitgelieferten Validation Rules hat man natürlich die Möglichkeit eigene Rules zu implementieren. Dazu wird die eigene Klasse von ValidationRule abgeleitet und die Methode Validate implementiert. Eine Validation Rule um zu prüfen, ob in unserem Beispiel die eingegebene Jahreszahl innerhalb des gewünschten Zeitraums ist, könnte so aussehen:
public class NumberInRangeValidationRule : ValidationRule
{
public int MaxValue { get; set; }
public int MinValue { get; set; }
public override ValidationResult Validate(object value, CultureInfo cultureInfo)
{
Debug.WriteLine("entering NumberInRangeValidationRule.Validate");
// InvalidCast Exception is wanted if this rule is used the wrong way.
int intValue = (int)value;
if( intValue < MinValue || intValue > MaxValue )
{
return new ValidationResult(false,
string.Format("Please enter a number between {0} and {1}.",
MinValue, MaxValue));
}
return ValidationResult.ValidResult;
}
}
Diese Validation Rule bietet die Möglichkeit über zwei Eigenschaften den gewünschten Wertebereich anzugeben. In der Validate Methode wird dann überprüft, ob der eingegebene Wert innerhalb dieses Bereichs ist. Ist das nicht der Fall, wird ein ValidationResult mit einem entsprechenden Text zurückgegeben. Warum aber der Test, ob der übergebene Wert vom Typ int ist (Zeile 10)? Schauen wir uns mal an, wie diese Rule im XAML verwendet wird:
...
<TextBox.Text>
<Binding Source="{StaticResource cd}"
Path="Year"
UpdateSourceTrigger="PropertyChanged"
ValidatesOnExceptions="True">
<Binding.ValidationRules>
<local:NumberInRangeValidationRule MinValue="1980"
MaxValue="2009"
ValidationStep="ConvertedProposedValue"/>
</Binding.ValidationRules>
</Binding>
</TextBox.Text>
...
Ab .Net 3.5 SP1 gibt es die Eigenschaft ValidationStep an der Klasse ValidationRule. Darüber kann man angeben, zu welchem Zeitpunkt während des Databindingvorganges die Validierung erfolgen soll. Es stehen vier Möglichkeiten zur Auswahl:
- RawProposedValue – Das ist der Defaultwert. Die Validierung wird vorgenommen bevor der Wert konvertiert wird. In dem Beispiel mit der TextBox muss damit ein String validiert werden (die Text Eigenschaft ist vom Typ String).
- ConvertedProposedValue – Bei dieser Einstellung wird der konvertierte Wert validiert. Im Beispiel also ein Integer.
- UpdatedValue – Die Validierung erfolgt nachdem die Quelle aktualisiert wurde.
- CommittedValue – Die Validierung erfolgt nachdem der Wert an die Quelle übergeben wurde.
Die letzten beiden Optionen sind mir ein bisschen suspekt. Mir ist kein Anwendungsfall eingefallen und der Unterschied zwischen beiden ist mir auch nicht klar.
Die oben gezeigte NumberInRangeValidationRule arbeitet auf Integer Werten, weshalb ich “ConvertedProposedValue” als ValidationStep angegeben habe. Bei der Implementierung ist eine InvalidCastException gewollt, damit eine falsche Benutzung sofort bemerkt wird.
So, jetzt fehlt nur noch ein schönerer Text für den Fall, dass keine Zahl sondern irgendwas anderes eingegeben wird. Dazu schreiben wir noch eine IsValidIntegerValidationRule:
public class IsValidIntegerValidationRule : ValidationRule
{
public override ValidationResult Validate( object value, System.Globalization.CultureInfo cultureInfo )
{
Debug.WriteLine( "entering IsValidIntegerValidationRule.Validate" );
string inputString = value as string;
if (null != inputString)
{
int inputNumber;
if( false == int.TryParse(inputString, out inputNumber ) )
{
return new ValidationResult( false, "Please enter a valid number." );
}
}
return ValidationResult.ValidResult;
}
}
Für diese Validation Rule belassen wir den ValidationStep beim Default, also “RawProposedValue”. Damit sieht die finale Konfiguration für das Databinding so aus:
...
<TextBox ToolTip="Please enter year of release"
Width="60"
Margin="10"
Validation.ErrorTemplate="{StaticResource TextBoxErrorTemplate}">
<TextBox.Text>
<Binding Source="{StaticResource cd}"
Path="Year"
UpdateSourceTrigger="PropertyChanged">
<Binding.ValidationRules>
<local:NumberInRangeValidationRule MinValue="1980"
MaxValue="2009"
ValidationStep="ConvertedProposedValue"/>
<local:InputMustBeIntegerValidationRule ValidationStep="RawProposedValue"/>
</Binding.ValidationRules>
</Binding>
</TextBox.Text>
</TextBox>
...
Uns so sieht das Ganze dann im UI aus:
Jetzt gibt es noch ein Problem, für das ich bisher noch keine zufriedenstellende Lösung gefunden habe. Beim Start der Anwendung wird über das TwoWay Binding der Initiale Wert aus der Quelle geholt. Dieser Wert ist bei einem Integer 0. Der Wert 0 liegt nicht im Wertebereich der NumberInRangeValidationRule, es wird aber keine Validierungsmeldung angezeigt. Es sieht so aus, also ob die Validierung nur von Ziel zu Quelle durchgeführt wird. Hier bin ich für Ideen bzw. Lösungsvorschläge sehr dankbar.
Der nächste Post zum Thema Validierung mit WPF widmet sich dem IDataErrorInfo Interface. Die Unterstützung dafür wurde mit .Net 3.5 SP1 in WPF eingeführt. Ich bin gespannt.
Das komplette Beispiel kann hier heruntergeladen werden: