WPF und MVVM richtig einsetzen – Teil 3

Wie bereits angekündigt, soll es in diesem dritten Teil der Serie um das Interface IDataErrorInfo gehen. Das Internet ist voll von Beispiel-Implementierungen und guten, wie auch schlechten Samples. Ich will den Versuch wagen, das Thema schrittweise und möglichst elegant anzugehen.

Links

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:

  1. 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.
  2. 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 ein Trigger angelegt, der auf die HasError-Eigenschaft der Validation-Eigenschaft gebunden wird. Letztere steht jedem Control in WPF automatisch zur Verfügung. Wenn nun also Validation.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:

Abb. 1 Textfeld mit Error-Binding

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:

Abb. 2: Namespace System.ComponentModel.DataAnnotations

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 das Dictionary das nicht von sich aus kann (es implementiert selbst INotifyPropertyChanged 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!

11 Antworten auf „WPF und MVVM richtig einsetzen – Teil 3“

  1. Ich verwende in meiner WPF-Anwendung durchaus einige Attribute aus System.ComponentModel.DataAnnotations. Ich kann nicht bestätigen, dass WPF diese nicht unterstützt. Konkret verwende ich RequiredAttribute, CustomValidationAttribute.

    1. Hallo Andrea. Du hast Recht, einige werden tatsächlich unterstützt. Das Problem ist eher, dass die Unterstützung so sporadisch ausfällt. Ich werde den Beitrag trotzdem mal entsprechend anpassen. Danke für Dein Feedback.

  2. Hallo, erst einmal muss ich sagen finde ich deinen Schreibstil und wie du das Thema angehst sehr gut und schlüssig.

    Hast du einen speziellen Grund weshalb du den Style in das MainWindow und nicht allgemein in die App.xaml verwendest?

    Lieben Gruß
    Christian

    1. Hallo Christian, danke für Dein Feedback. Bezüglich des Styles würde ich normalerweise immer ein externes ResourceDictionary oder mindestens die App.xaml verwenden. Das macht sich nur innerhalb der Code Samples relativ schlecht, weil ich immer darauf hinweisen müsste. Hast aber vollkommen Recht – ich werde das in einem der nächsten Beiträge mit anbringen.

  3. Hallo, wieso benutzt Du nicht in deiner Methode CollectErros ein ObservableCollection? Würde es das nicht etwas einfacher machen?
    Ansonsten kann ich es kaum erwarten bis der nächste Teil erscheint.

    1. Hi Dirk! Ich brauche ein Dictionary, weil ich zu jedem Control den Error halten möchte. In diesem Fall würde ein Observable nichts verbessern, weil durch die interne WPF-Logik ohnehin festgelegt ist, wann die Fehler ausgewertet werden. Ein Binding würde hier eher wenig Sinn ergeben.

  4. Gibt es schon ein ungefähren Zeitplan für den nächsten Teil? Das Tutorial ist meiner Meinung nach das Beste, deshalb warte ich sehnsüchtig auf den nächsten Teil

  5. Great videos, very detailed!!
    I’ve been through all parts 1-3 and i’m kinda stuck at the end of part 3.
    I imported the „ComponentModel“ reference and wrote the code but for some reason when i try to access
    the „requiredAttr.ErrorMesage“ (Listing 11, Line 19) it does not recognizes the „Error Message“ property
    i tested around the line to see if the values that i get from GetCustomAttributes are ok and they are.
    any idea why that happens??

  6. Klasse Blogserie! Hilft mir sehr.
    Ich habe gerade vesucht, in VS2017 die BaseModel.cs zu erstellen, doch er findet die Klasse DataAnnotations nicht. Nun habe ich in Nuget gesehen, dass sie komplett aus ASP stammt. Beim Installieren will er nun ganz viele ASP-Abhängigkeiten mitinstallieren. Muss das sein, mache ich was falsch, oder hat das alles seine Richtigkeit? Würde das Projekt ungerne groß aufblähen deswegen. In Google habe ich auf die Schnelle nix gefunden. Danke für die Beiträge!

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.