Links
- Teil 1 mit den Grundlagen
- Teil 2 zu INotifyPropertyChanged
- Teil 4 zu Converter und Messenger
- Teil 5 zu Binding von Listen
- Beispiel-Projekt-Repo auf GitHub
Screencast
Vorbemerkungen
Man findet ziemlich oft lobende Worte zu IDataErrorInfo
, was mich bei näherer Betrachtung immer wieder erstaunt, denn die Implementierung, die einem normalerweise vorgeschlagen ist krude und verleitet die Leute zu schlimmen Spaghetti-Code-Routinen. Um hier ein wenig mehr Bewusstsein und natürlich auch Verständnis zu schaffen, werde ich zunächst mit einer Basis-Implementierung beginnen und mich dann hin zu einer möglichen Alternative begeben.
Sinn und Zweck
IDataErrorInfo
soll es uns ermöglichen, unseren ViewModels Logik mitzugeben, die zu einer automatischen Validierung unser Daten auf Client-Seite führt. Schon während der Eingabe von Informationen kann WPF automatisch eine Prüfung an unsere Klassen delegieren und entsprechend darauf reagieren.
Demo
Am besten lässt sich das ganze mit einer kleinen Demo veranschaulichen. Eines nur vorweg: IDataErrorInfo
ohne INotifyPropertyChanged
macht i.d.R. keinen Sinn. Die folgenden Beispiele lassen INotifyPropertyChanged
erstmal aus dem Spiel, um das Prinzip besser erläutern zu können.
Nehmen wir nun an, wie haben die berühmt-berüchtigte Klasse Person, die wir später direkt an einen View binden wollen:
public class Person
{
public string Firstname { get; set; }
}
Schön ist nun, dass wir diese Klasse für das Binding nehmen können - blöd daran ist, dass wir nirgends eine Möglichkeit haben, Werte abzulehnen, die uns nicht passen. Würden wir jetzt wieder Code im UI hinterlegen, um Fehleingaben zu verhindern, würden wir unser MVVM-Paradigma verletzen.
Hallo IDataErrorInfo!
Also implementieren wir nun das Interface:
public class Person : IDataErrorInfo
{
public string Firstname { get; set; }
public string Error
{
get { throw new NotImplementedException(); }
}
public string this[string columnName]
{
get
{
throw new NotImplementedException();
}
}
}
Wie man sieht, erfordert das Interface die Implementierung von 2 Eigenschaften:
Error
: Diese Eigenschaft wird durch WPF nicht direkt genutzt und kann daher so definiert bleiben, wie im Listing.this[]
: Hier handelt es sich um einen Indexer. Ein Indexer wird über Instanz[] aufgerufen. WPF nutzt dies und übergibt als offset für den Index-Wert jeweils immer den Namen der Eigenschaft, die sich geändert hat.
Es ist wichtig zu verstehen, dass WPF den Indexer jedes Mal aufruft, wenn sich der Wert einer Eigenschaft ändert und erwartet, genau dafür entweder nichts (string.Empty) oder im Fehlerfall eine Message geliefert zu bekommen, die es dem User dann zeigt. Kommt also irgendwas zurück, was nicht ein leerer String ist, dann denkt WPF, dass die Eingabe ein Fehler ist.
Konzentrieren wir uns zunächst auf den Indexer. Das Internet ist voll von Beispielen wie:
public string this[string columnName]
{
get
{
switch (columnName)
{
case "Firstname":
if (string.IsNullOrEmpty(Firstname))
{
return "Firstname must not be empty!";
}
break;
// ....
}
}
}
Von hier an will ich erst einmal Stück für Stück dieses Monster aufdröseln. Stellt man sich hier eine etwas komplexere ViewModel-Klasse vor, wird schnell klar, dass das keine sinnvolle Lösung sein kann.
Schritt 1: Neue C#-Features verwenden
C# bietet inzwischen mit dem nameof
-Operator eine Möglichkeit, um nicht ständig mit Strings hantieren zu müssen, um gegen Eigenschaften zu checken.
public string this[string columnName]
{
get
{
switch (columnName)
{
case nameof(Firstname):
if (string.IsNullOrEmpty(Firstname))
{
return "Firstname must not be empty!";
}
break;
// ....
}
}
}
Die switch
-Anweisung benutzt nun nameof
. Ändert sich nun z.B. der Name von Firstnmame
in irgend etwas anderes, wird unser Code das sofort berücksichtigen.
Schritt 2: Wir wollen alle Fehler kennen
Listing 4 wird immer nur eine Property auf Fehler prüfen. Aus diversen Gründen, auf die wir später noch zu sprechen kommen werden, ist es jedoch viel logischer, alle Fehler zu sammeln und dann IDataErrorInfo
gerecht zu werden.
Zu diesem Zweck führe ich zunächst eine neue Methode CollectErrors
ein sowie ein lokales Dictionarry
ein:
private Dictionary<string, string> Errors { get; } = new Dictionary<string, string>();
private void CollectErrors()
{
Errors.Clear();
if (string.IsNullOrEmpty(Firstname))
{
Errors.Add(nameof(Firstname), "Firstname must not be empty!");
}
// weitere checks kommen hier
}
Das Dictionary
könnte man auch public
machen, um ggf. später die Fehler auszulesen.
Jetzt passe ich meinen Indexer wie folgt an:
public string this[string columnName]
{
get
{
CollectErrors();
return Errors.ContainsKey(columnName) ? Errors[columnName] : string.Empty;
}
}
Wie wir später noch sehen werden, schreit CollectErrors
faktisch nach Reflection
oder einer anderen Art der Automatisierung. Für den Moment haben wir aber erst einmal im Indexer aufgeräumt und ein wenig mehr Stabilität drin.
IDataErrorInfo im View
Damit wir nun die Früchte dieser Arbeit ernten können, müssen wir natürlich auch Anpassungen am View vornehmen. Von allein wird WPF IDataErrorInfo
nicht unterstützen.
Folgendes Beispiel zeigt, wie man eine Textbox inkl. IDataErrorInfo
bindet, wenn der DataContext
auf eine Person
zeigt:
<TextBox Text="{Binding Firstname,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged,
ValidatesOnDataErrors=True" />
Sobald man dies tut und das Programm ausführt, wird standardmäßig ein roter Rahmen um die Box gezeigt, wenn ein Fehler auftaucht. Von der Fehlermeldung ist noch nichts zu sehen.
Nehmen wir das Binding einmal auseinander:
- Zeile 1: Es wird definiert, dass wir gegen
Firstname
binden wollen. - Zeile 2: Wir sichern ab, dass Änderungen in der Textbox zurück ins ViewModel geschrieben wird.
- Zeile 3: Wir sichern ab, dass nicht erst beim Verlassen der Box, sondern bei jeder Änderung sofort ins ViewModel geschrieben wird.
- Zeile 4: Wir teilen WPF mit, dass es
IDataErrorInfo
unterstützen soll. Das ist die entscheidende Zeile in unserem Case.
Es gibt noch 2 weitere Binding-Attribute: NotifyOnValidationError
und ValidatesOnExceptions
, die wir in späteren Teilen dieser Serie behandeln werden.
Error-Message im View anzeigen
Um gleich den “guten” Weg einzuschlagen, werde ich nun die Templates und Styles von TextBox-Elementen so ändern, dass sie eine wiederverwendbare und komplett anpassbare Darstellung von Fehlern sicherstellen.
Das folgende Snippet zeigt alle XAML-Ressource, die ich hierfür benötige:
<ControlTemplate x:Key="ErrorTemplate">
<DockPanel LastChildFill="True">
<Border DockPanel.Dock="Top" BorderBrush="Orange" BorderThickness="1">
<AdornedElementPlaceholder />
</Border>
</DockPanel>
</ControlTemplate>
<!-- Defines the default style for TextBox -->
<Style TargetType="TextBox">
<Setter Property="Validation.ErrorTemplate" Value="{StaticResource ErrorTemplate}" />
<Style.Triggers>
<Trigger Property="Validation.HasError" Value="true">
<Setter Property="ToolTip" Value="{Binding RelativeSource={x:Static RelativeSource.Self},Path=(Validation.Errors).CurrentItem.ErrorContent}" />
</Trigger>
</Style.Triggers>
</Style>
Gehen wir das einmal durch:
- Zeilen 1 - 7: Es wird ein allgemeines Template angelegt, damit Controls einen orangen Rahmen bekommen, wenn
IDataErrorInfo
einen Fehler für das entsprechende Binding liefert. - Zeilen 9 - 16: Es wird ein Style für alle
TextBox
-Elemente definiert. Dieser sorgt zunächst dafür, dass das zuvor generierte Template als ErrorTemplate verwendet wird (Zeile 10). Danach wird einTrigger
angelegt, der auf dieHasError
-Eigenschaft derValidation
-Eigenschaft gebunden wird. Letztere steht jedem Control in WPF automatisch zur Verfügung. Wenn nun alsoValidation.HasError
true
ergibt, wird die Tooltip-Eigenschaft des entsprechenden Controls gesetzt.
In Zeile 13 sieht man auch gleich, dass der von uns generierte Text jedem Control über Validation.Errors
zugewiesen wird. Wer sich hier über den Teil CurrentItem
wundert, für den ist folgender kleiner Exkurs interessant:
Normalerweise wird überall angegeben, dass man das Binding über (ValidationErrors)[0].ErrorContent
durchführen soll. Das Problem ist, dass man dann ständig mit Binding-Exceptions von WPF behelligt wird:
Cannot get ‘Item[]’ value (type ‘ValidationError’) from ‘(Validation.Errors)’ (type ‘ReadOnlyObservableCollection`1’). BindingExpression:Path=(0)…
CurrentItem
meckert nun zwar im Visual Studio Designer (ignorieren), läuft aber zur Runtime ohne Exceptions. Den Tipp dazu habe ich bei Stackoverflow gefunden.
Der folgende Screenshot zeigt das Textfeld in Aktion:
Besser geht immer
Der aktuelle Stand des ViewModels ist schon ganz ordentlich, geht man zumindest von der landläufigen Meinung aus. Meine kritischen Aussagen von oben aufgreifend möchte ich allerdings anmerken, dass eine Methode, wie CollectErrors
schnell unübersichtlich und schlecht zu warten/testen wird. Außerdem müssen wir bei jeder neu hinzu kommenden Eigenschaften immer daran denken, die entsprechende Fehlerbehandlung einzustellen.
Wie ebenfalls bereits oben erwähnt, kann man das auf diverse Art und Weisen automatisieren. Im Folgenden möchte ich eine auf Reflection basierte Lösung zeigen. Bevor es losgeht nehme ich mir aber erstmal Zeit für ein wenig Kritik.
MVVM vc. MVC
Im Web-Bereich hat Microsoft einen hervorragenden Job gemacht, wenn es um client-seitige Validierung geht. Der komplette Namespace System.ComponentModel.DataAnnotations
wird nativ unterstützt. Wenn ich also in ASP.NET festlegen möchte, dass eine Eigenschaft eine Pflichtangabe beinhaltet, dann kann ich das z.B. so machen:
[Required(AllowEmptyStrings = false, ErrorMessage = "First name must not be empty.")]
public string Firstname { get; set; }
Es wird sogar noch besser. In der Regel möchte ich mich eigentlich nicht im Code festlegen, was die Sprache der Fehlermeldungen betrifft. Das RequiredAttribute
bietet auch hierfür eine Lösung:
[Required(AllowEmptyStrings = false, ErrorMessageResourceName = "FirstnameErrorMessage", ErrorMessageResourceType =typeof(MyResourceFile)]
public string Firstname { get; set; }
In dieser Variante kann man dann seine eigentlichen Texte sauber in resx-Dateien packen.
Neben Required
bietet der Namespace noch eine beachtliche Latte weiterer nützlicher Attribute:
Es ist mir völlig schleierhaft, warum WPF diese Attribute nicht unterstützt. Anstelle von DisplayAttribute
mit seinen vielen Eigenschaften, können wir hier nur DisplayName
an - ein unwürdiger Ersatz meiner Meinung nach.
EDIT: Andrea hat in ihrem Kommentar darauf verwiesen, dass der Namespace teilweise schon unterstützt wird. Das stimmt und daher kann man meinen “Zorn” hier etwas relativieren. Ich halte es trotzdem für fragwürdig, hier nur einen Subset anzubieten.
Reflection rein
Bewaffnet mit diesem Wissen können wir nun mit ein wenig Reflection-Logik etwas umsetzen, dass uns das Leben erheblich erleichtert. Los geht’s mit einer ersten Anpassung der CollectErrors
-Methode:
private void CollectErrors()
{
Errors.Clear();
var properties =
this.GetType()
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(prop => prop.IsDefined(typeof(RequiredAttribute), true) || prop.IsDefined(typeof(MaxLengthAttribute), true))
.ToList();
properties.ForEach(
prop =>
{
var currentValue = prop.GetValue(this);
var requiredAttr = prop.GetCustomAttribute<RequiredAttribute>();
var maxLenAttr = prop.GetCustomAttribute<MaxLengthAttribute>();
if (requiredAttr != null)
{
if (string.IsNullOrEmpty(currentValue?.ToString() ?? string.Empty))
{
Errors.Add(prop.Name, requiredAttr.ErrorMessage);
}
}
if (maxLenAttr != null)
{
if ((currentValue?.ToString() ?? string.Empty).Length > maxLenAttr.Length)
{
Errors.Add(prop.Name, maxLenAttr.ErrorMessage);
}
}
});
// we have to this because the Dictionary does not implement INotifyPropertyChanged
OnPropertyChanged(nameof(HasErrors));
OnPropertyChanged(nameof(IsOk));
}
Ich sehe ein, dass das ein wenig Erklärung benötigt.
- Zeilen 2-8: Ich nutze Reflection, um den Typ meiner eigenen Klasse (Reflection halt) über alle Eigenschaften zu befragen, die öffentlich und nicht-statisch sind und die eines der Attribute über sich haben, die mich interessieren (diesen Teil muss man später ergänzen).
- Zeilen 9-29: Ich hole mir den aktuellen Wert der Eigenschaft meiner Instanz und prüfe gegen die Liste der Attribute (ebenfalls ergänzen), um die Prüfungen, wie vorher auch, durchzuführen. Diesmal benutze ich aber die Attribute, um die eigentlichen Texte zu erhalten.
- Zeilen 31-32: Ich rufe manuell das Triggern der Eigenschaften-Änderung von
INotifyPropertyChanged
auf, weil dasDictionary
das nicht von sich aus kann (es implementiert selbstINotifyPropertyChanged
nicht).
Jetzt brauche ich jeder Eigenschaft nur noch die entsprechenden Attribute geben (siehe Listing 9). Möchte man Ressourcen benutzen (Listing 10), müsste man den Code ein wenig anpassen.
Ein wenig eleganter
Eine kleine Optimierung wäre noch, die relativ teure Sammlung per Reflection möglichst nur einmal durchzuführen. Also los!
Als erstes packe ich die Sammlung der Properties in ein statisches ‘Lazy<>`:
private static List<PropertyInfo> _propertyInfos;
private List<PropertyInfo> PropertyInfos
{
get
{
if (_propertyInfos == null)
{
_propertyInfos = this.GetType()
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(prop => prop.IsDefined(typeof(RequiredAttribute), true) || prop.IsDefined(typeof(MaxLengthAttribute), true))
.ToList();
}
return _propertyInfos;
}
}
CollectErrors
ändert sich dann leicht, indem das ForEach
aus Zeile 9 nun PropertyInfos.ForEach
wird. Den kompletten Quellcode kann man unter dem Github-Repository des Sample-Projektes sehen.
Es wäre vielleicht keine schlechte Idee, später alle ViewModels von der gleichen Basisklasse erben zu lassen, die all diese Logik bereits anbietet. Diese sollte übrigens im Falle von MvvmLight selbst von ViewModelBase
erben und hätte damit bereits den ganzen Kram von INotifyPropertyChanged
. Wir werden darauf noch zurück kommen.
Ausblick
Im nächsten Teil werde ich nun endlich zu den Geheimnissen in MVVM Light vorstoßen. Als erstes werden wir uns die Themen Converter und Messenger zu Gemüte führen. Bleibt dran!