Links
- Teil 1 mit den Grundlagen
- Teil 2 zu INotifyPropertyChanged
- Teil 3 zu IDataErrorInfo
- Teil 5 zu Binding von Listen
- Beispiel-Projekt-Repo auf GitHub
Screencast
Vorbemerkungen
Genau genommen suggeriert die Einleitung, dass Converter
etwas wären, das es in MVVM ohne “Light” nicht gäbe. Das will ich gleich korrigieren. Converter sind bereits tief in MVVM eingebaut und sind einfach ein weiterer Baustein, den wir kennen sollten, wenn wir wirklich tiefer in die Materie einsteigen wollen. Sie kommen deshalb erst in diesem Teil der Serie vor, weil sie in Bezug auf Bindings oft essentiell sind.
Das Thema Messenger
ist wiederum extrem wichtig, wenn wir durchgehend vermeiden wollen, das MVVM-Paradigma zu vermeiden. Insbesondere die Tatsache, dass das ein ViewModel
nichts von einem View
wissen darf, ist ohne Technologien, wie den Messenger nicht durchzuhalten.
Converter
Ein Converter entsteht, indem man eine Klasse schreibt (also einen Typ definiert), der das Interface IValueConverter
implementiert. Diese ebenso richtige, wie im Moment wahrscheinlich wenig hilfreiche Aussage sollte man immer im Hinterkopf behalten, wenn man mal wieder denkt, dass Converter etwas Schwieriges wären. Der Teufel steckt natürlich wie immer im Detail.
Wozu braucht man Converter? Am besten lässt sich diese Frage oft mit einer Eigenschaft beantworten, die die meisten WPF-Elemente kennen: Visibility
. In den guten alten Windows-Forms-Zeiten war das Leben noch einfach. Wollte man ein Element unsichtbar machen, setzte man seine Visible
-Eigenschaft vom Typ bool
einfach auf false
. Visibility
in WPF dient dem gleichen Zweck, kenn aber 3 Zustände: Collapsed
, Hidden
und Visible
. Trotz dieser gesteigerten Komplexität haben wir in ViewModels oftmals trotzdem noch eine simple Boolesche Eigenschaft, gegen die Visibility gebunden werden soll. Nehmen wir z.B. unser PersonViewModel
aus unserem Beispiel-Projekt. Dieses erbt von BaseViewModel
die Boolesche Eigenschaft ValidationOk
. Nun wollen wir unseren OK-Button unsichtbar machen, wenn IsOk nicht true
liefert. Das folgende XML funktioniert NICHT:
<Button Name="btnTest" Visibility="{Binding ValidationOk}" />
Das wird nicht funktionieren, weil wir dieser Ausdruck in C# folgendes bedeuten würde:
btnTest.Visibility = model.ValidationOk;
Auch Bindings müssen natürlich typsicher sein und hinter einem XAML-Binding steht letztlich eine Klasse, die Code ausführt.
Was wir also brauchen, ist etwas, dass aus einem Boolean
das richtige Visibility
macht. Und genau dafür sind Converter da. Ein Converter für unseren Fall könnte z.B. so aussehen:
/// <summary>
/// Converts a boolean into the appopriate visibility.
/// </summary>
public class BooleanToVisibilityConverter : IValueConverter
{
/// <inheritdoc />
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value == null)
{
throw new ArgumentNullException(nameof(value));
}
bool transformed;
if (!bool.TryParse(value.ToString(), out transformed))
{
throw new ArgumentException("Value is not a bool.");
}
return transformed ? Visibility.Visible : Visibility.Collapsed;
}
/// <inheritdoc />
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
Ein Converter ist also zunächst einmal ein Typ, der IValueConverter
implementiert. Dieses Interface fordert uns auf, 2 Methoden zu implementieren: Convert
und ConvertBack
. Da wir in Listing 2 nur Convert
implementiert haben, können wir mit diesem Typ nur von bool
nach Visiblity
konvertieren. Das reicht für das oben beschriebene Binding völlig aus. Bei aufwendigeren Convertern wird auch gern ConvertBack
implementiert.
Sobald wir diesen Typ in userem Projekt haben und zur Sicherheit einmal neu bauen, können wir den Converter im Binding-Dialog auswählen:
Abb. 1 zeigt den Zustand des Binding-Dialoges, wenn noch kein Converter in einer der verfügbaren Ressourcen zur Verfügung gestellt wird (App.xaml, Window.Ressources usw.). Ein Click auf “Add value converter…” bringt uns zu:
Ich habe bewusst am unteren Rand den Haken bei “Show all assemblies” gesetzt. Das führt dazu, dass wir auch Converter aus den aktuell referenzierten Bibliotheken zu sehen bekommen. Wir man sieht, hätten wir uns also die Arbeit für einen eigenen BooleanToVisibilityConverter
sparen können. Da er aber das Prinzip sehr anschaulich macht, werden wir erstmal unseren eigenen nutzen.
Das Binding aus Listing 1 sieht nach der Bestätigung des Dialoges nun so aus:
<Button Name="btnTest" Visibility="{Binding ValidationOk, Converter={StaticResource BooleanToVisibilityConverter}} />
Damit das auch funktioniert, braucht man die statische Ressource mit dem Key BooleanToVisibilityConverter
. Den hat der Dialog aus Abb. 1 gleich in den Window-Ressources des Fensters abgelegt:
<Window.Resources>
<Converters:BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter"/>
</Window.Resources>
In unserem aktuellen Beispiel hätten wir nun auch den Converter aus `System.Windows.Controls’ nehmen können. Daher werde ich den in Listing 3 dargestellten Converter nicht mit in den Sample-Source bringen. Sehen wir uns stattdessen einen anderen typischen Converter an:
public class AgeToBrushConverter : IValueConverter
{
#region explicit interfaces
/// <inheritdoc />
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value == null)
{
return new SolidColorBrush(Colors.Transparent);
}
var transformed = 0;
if (int.TryParse(value.ToString(), out transformed))
{
return transformed < 18 ? new SolidColorBrush(Color.FromRgb(255, 0, 0)) : new SolidColorBrush(Color.FromRgb(0, 255, 0));
}
throw new ArgumentException("Value is not valid.");
}
/// <inheritdoc />
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
#endregion
}
Dies ist ein durchaus typisches Szenario für einen benutzerdefinierten Converter. Hier wird festgelegt, dass ein roter Brush zurück gegeben werden soll, wenn eine Person jünger als 18 Jahre ist, ein grüner, wenn sie älter oder gleich 18 Jahre als ist und ein transparenter Brush, wenn die Person kein Alter hat.
Ich habe nun das XAML für das MainWindow im Sample ein wenig erweitert (hier nur der Bereich für die Darstellung einer Person aus den vorherigen Teilen der Serie):
<GroupBox Grid.Column="0" Grid.Row="1" Header="Binding & IDataErrorInfo">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Firstname -->
<Label Content="Firstname:" Grid.Column="0" Grid.Row="0" />
<TextBox
Text="{Binding PersonModel.Firstname, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True}"
Grid.Column="1" Grid.Row="0" />
<!-- Lastname -->
<Label Content="Lastname:" Grid.Column="0" Grid.Row="1" />
<TextBox
Text="{Binding PersonModel.Lastname, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True}"
Grid.Column="1" Grid.Row="1" />
<!-- Age -->
<Label Content="Birthday:" Grid.Column="0" Grid.Row="2" />
<DatePicker Grid.Column="1" Grid.Row="2"
SelectedDate="{Binding PersonModel.Birthday, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnDataErrors=True}" />
<!-- Age -->
<Label Content="Age:" Grid.Column="0" Grid.Row="3" />
<Label Content="{Binding PersonModel.Age}" Grid.Column="1" Grid.Row="3"
Background="{Binding PersonModel.Age, Converter={StaticResource AgeToBrushConverter}, Mode=OneWay}" />
<!-- OK-Button -->
<Button Grid.Row="4"
Grid.Column="0"
Grid.ColumnSpan="2"
HorizontalAlignment="Right"
Margin="5"
Content="OK"
Width="100"
Command="{Binding PersonModel.OkCommand}" />
</Grid>
</GroupBox>
Das Ergebnis sieht dann im MainWindow ungefähr so aus:
Grenzen der Converter
Scheinbar sind also Converter ein bequemes Mittel, um schnell zwischen der Business- und View-Logik switchen zu können. Aber Vorsicht! Converter sind nicht ganz billig und können schnell falsch eingesetzt werden. In Listing 6 z.B. benutze ich immer wieder eine neue Instanz eines SolidColorBrush
, was gelinde gesagt dämlich ist. Besser wäre es, diesen Brush aus den Ressourcen zu holen oder als Singleton zu implementieren, weil Brushes aus WPF-Sicht recht teuer sind. Man muss sich nur mal vorstellen, dass man eine Liste mit 1000 PersonModel
-Instanzen an ein Grid bindet und dass dann für jede Zeile des Grid der Converter ausgewertet wird.
Ich habe mir zur Regel gemacht, dass Converter immer dann sinnvoll sind, wenn ich relativ “billige” Logik umsetzen möchte und wenn WPF-spezifische Typen, wie halt Brushes zum Einsatz kommen. Alles andere regele ich über Eigenschaften in den ViewModels selbst.
Messenger
Ein PRoblem, das immer wieder auftaucht besteht darin, das MVVM-Pattern sauber einzuhalten. So ganz gelingt das in komplexeren Projekten nicht wirklich. Ein schönes Beispiel für ein Pattern-Problem ist das Öffnen eines neuen Dialoges aus einem ViewModel heraus.
Lasst uns also mal ein kleines Beispiel konstruieren. Nehmen wir mal an, ich wollte aus dem MainWindow
heraus ein neues ChildWindow
öffnen, wenn man auf einen Button klickt. Das schreit förmlich nach einem entsprechenden Command im MainViewModel
. Also los:
/// <summary>
/// Opens a new child window.
/// </summary>
public RelayCommand OpenChildCommand { get; private set; }
Man sollte darauf achten, dass Commands eines ViewModels immer einen privaten Setter haben, weil es einfach unlogisch wäre, wenn etwas von außen die Logik des Commands ändert. Das Command muss nun noch im Constructor des MainViewModels instantiiert werden.
Hier beginnen aber die Probleme! Folgendes geht in MVVM nicht:
/// <summary>
/// Opens a new child window.
/// </summary>
OpenChildCommand = new RelayCommand(() => new ChildWindow().ShowDialog());
Das funktioniert nicht, weil das MainVieWModel
i`m Projekt Logic.Ui ist und dieses keine Referenz auf unser View-Projekt hat und haben soll. Das würde nämlich bedeuten, dass:
- Die ViewModels an die Views gebunden wären, was das Pattern verbietet.
- Eine Zirkel-Referenz entsteht.
Was aber nun? Wenn man etwas anders an die Sache rangeht, wird das Leben hier erheblich leichter. Dazu müssen wir ein wenig ausholen.
Ein Ring sie alle zu binden
Um es gleich vorweg zu nehmen. Wir werden nicht ohne Logic im View-Projekt auskommen. Es geht im Folgenden nur darum, keine binären Bindungen und Reference-Locks aufzubauen, sondern die beiden Welten ViewModel und View weiterhin getrennt voneinander zu lassen.
Wenn man sich klar macht, dass das ViewModel nicht einfach einen View öffnen kann, der sich dann über den DataContext
wiederum sein ViewModel “besorgt”, dann bleibt als Option nur eine dritte Komponente, die diese Aufgabe erfüllt. Genau so etwas ist der Messenger
. Er agiert als eine Art ServiceBus
aus Sicht unseres Projektes und ist in MvvmLight bereits enthalten. Da sowohl das ViewLogic- als auch das View-Projekt Referenzen auf MvvmLight halten, können wir ihn an beiden Stellen nutzen.
Einen ServiceBus
kann man sich vereinfacht als eine Art ringförmiges Förderband vorstellen.
Ein System A (z.B. das ViewModel) sendet eine Nachricht an den ServiceBus. System B (z.B. der View) erhält wirgendwann diese Nachricht und versteht sie. Daraufhin löst das System B die von System A gewünschte Aktion aus.
Es gibt komplexe Service Busses (Enterprise Service Bus), die es ermöglichen diverse Parameter einzustellen (Zustellsicherheit, Ausfallsicherheit usw.). In unserem Fall brauchen wir das aber nicht. Wichtig ist nur, dass alle teilnehmenden System ein und denselben ServiceBus nutzen.
IMessenger
MvvmLight bringt ein Interface (wass sonst) mit. IMessenger
kann von Typen implementiert werden, die von sich behaupten, ein Messenger (also ein MVVM-ServiceBus) zu sein. MvvmLight hat naürlich auch schon eine eigene Implementierung dabei und wartet innerhalb eines ViewModelBase
mit einer Eigenschaften MessengerInstance
auf, die den fix und fertig verdrahteten ServiceBus zurück gibt.
IMessenger
hat nun im wesentlichen Send- und Register-Methoden. Send ist dafür da, dass ein Teilnehmer etwas auf den ServiceBus packen kann und Register kann genutzt werden, um mitzuteilen, dass man sich für einen bestimmten Typ von Nachricht interessiert.
Nachrichtentypen
Es empfiehlt sich, jedem Nachrichtentyp eine eigene Klasse zu verpassen. Sehen wir uns das mal genauer an:
public class OpenChildWindowMessage
{
}
Senden
Ich füge dem UI-Logic-Projekt einfach eine simple Klasse OpenChildMessage
hinzu. Jetzt kann ich die eine Seite der Kommunikation bereits fertig stellen. Dazu korrigiere ich Listing 8:
OpenChildCommand = new RelayCommand(() => MessengerInstance.Send(new OpenChildWindowMessage()));
Das bedeutet, dass mein MainViewModel
einfach eine Nachricht sendet, dass doch bitte jemand ein ChildWindow
aufmachen soll. Mehr nicht.
Empfangen und Umsetzen
Gehen wir nun hinüber auf die UI-Projektseite. Im Konstruktor des CodeBehind des MainWindow
wird nun die Nachricht registriert und definiert, was bei Eintreffen einer entsprechenden Nachricht geschehen soll:
public MainWindow()
{
InitializeComponent();
Messenger.Default.Register<OpenChildWindowMessage>(
this,
msg =>
{
new ChildWindow().ShowDialog();
});
}
Hier weichen wir nun natürlich von unseren zuvor gefassten Vorsätzen (keine Logic in Views ab). Ich werde später zeigen, wie man hier vielleicht ein wenig mehr Eleganz hinein bekommt.
Nachdem nun Listing 11 umgesetzt ist, muss nur noch das MainView.xaml ergänzt werden (das ChildWindow und sein ViewModel müssen natürlich irgendwie auch schon im Projekt vorhanden sein! - siehe GitHub).
<Button Visibility="{Binding ValidationOk, Converter={StaticResource BooleanToVisibilityConverter}}"
Content="ShowChild"
Command="{Binding OpenChildCommand, Mode=OneWay}" />
Ein wenig mehr Ordnung im View-Projekt
Achtung! Das Folgende wird etwas verwirrend werden und ist nur für Leute gedacht, die gespannt sind, wie man das Problem mit dem CodeBehind etwas abmildern kann.
Um unsere CodeBehind-Logik nicht ständig bemühen zu müssen, können wir den Code aus Listing 11 auch an eine andere Stelle verlagern. Dazu machen wir uns den Umstand zu Nutze, dass die meisten Anwendungen ein Fenster haben, dass so lange geöffnet bleibt, wie die Anwendung läuft. In unserem Fall ist das das MainWindow
.
Wir erstellen nun zunächst eine neue simple Klasse im View-Projekt:
public class MessageListener
{
#region constructors and destructors
public MessageListener()
{
InitMessenger();
}
#endregion
#region methods
private void InitMessenger()
{
// Hook to the message that states that some caller wants to open a ChildWindow.
Messenger.Default.Register<OpenChildWindowMessage>(
this,
msg =>
{
new ChildWindow().ShowDialog();
});
}
#endregion
#region properties
public bool BindableProperty => true;
#endregion
}
Wie man sieht, habe ich in der Methode InitMessenger
bereits die Logik aus Listing 11 eingebaut. Die Logik im CodeBehind des MainWindow
kann nun wieder raus. Natürlich bleibt das Problem, dass nun niemals der Contructor der neuen Klasse aufgerufen wird. Für dieses Problem machen wir uns die Ressourcen von WPF zu Nutze.
In der App.xaml wird zunächst erstmal ein Eintrag mit dem Verweis auf unsere neue Klasse generiert:
<Application x:Class="codingfreaks.blogsamples.MvvmSample.Ui.Desktop.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
d1p1:Ignorable="d"
xmlns:d1p1="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:UiLogic="clr-namespace:codingfreaks.blogsamples.MvvmSample.Logic.Ui;assembly=MvvmSample.Logic.Ui"
xmlns:Desktop="clr-namespace:codingfreaks.blogsamples.MvvmSample.Ui.Desktop">
<Application.Resources>
<ResourceDictionary>
<UiLogic:ViewModelLocator x:Key="Locator"
d:IsDataSource="True" />
<Desktop:MessageListener x:Key="MessageListener" />
</ResourceDictionary>
</Application.Resources>
</Application>
Die neuen Zeilen sind hervorgehoben. Wir importieren also unseren Namespace und legen einfach einen neuen Ressourcen-Eintrag mit dem Key MessageListener
(kann irgendein string sein) an. Das reicht aber noch nicht, weil WPF Ressourcen erst dann initialisert, wenn sie das erste Mal genutzt werden. Genau das regeln wir, indem wir eine Eigenschaft des MainWindow
an die seltsame BindableProperty
-Eigenschaft aus Listing 13 binden. Dazu ergänzen wir das MainWindow.xaml um folgenden Teil:
<Window.IsEnabled>
<Binding Path="BindableProperty" Source="{StaticResource MessageListener}"/>
</Window.IsEnabled>
Wie man in Listing 13 sehen kann, ist BindableProperty
aus C#-Sicht völlig nutzlos, weil sie einfach immer true
zurück gibt. Sie dient einzig und allein dem Zweck, eine Eigenschaft im MessageListener
zu haben, an die wir das MainWindow
binden können, damit beim Programmstart eine Instanz dieser Klasse entsteht.
Auch ohne das CodeBehind im MainWindow
funktioniert unser Service Bus immer noch mit dem Vorteil, dass wir nun wieder ein wenig mehr Ordnung im Pattern haben.
Die Message kann mehr
Jetzt, wo wir also zwischen ViewModel und View Nachrichten austauschen, können wir natürlich übergebem was auch immer wir wollen. Eine simple Erweiterung unserer OpenChildWindowMessage
könnte sein:
public class OpenChildWindowMessage
{
#region constructors and destructors
public OpenChildWindowMessage(string someText)
{
SomeText = someText;
}
#endregion
#region properties
public string SomeText { get; private set; }
#endregion
}
entsprechend ändern wir Listing 10 ab:
OpenChildCommand = new RelayCommand(() => MessengerInstance.Send(new OpenChildWindowMessage("Hello Child!")));
und ergänzen eine Eigenschaft im ChildViewModel
:
public string MessageFromParent { get; set; }
Wenn wir nun noch das ChildWindow überarbeiten:
<Window x:Class="codingfreaks.blogsamples.MvvmSample.Ui.Desktop.ChildWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:codingfreaks.blogsamples.MvvmSample.Ui.Desktop"
mc:Ignorable="d"
Title="ChildWindow" Height="300" Width="300">
<Window.DataContext>
<Binding Path="Child" Source="{StaticResource Locator}" />
</Window.DataContext>
<Grid>
<TextBlock Text="{Binding MessageFromParent}" />
</Grid>
</Window>
brauchen wir eigentlich nur noch den MessageListener
anpassen:
Messenger.Default.Register<OpenChildWindowMessage>(
this,
msg =>
{
var window = new ChildWindow();
var model = window.DataContext as ChildViewModel;
if (model != null)
{
model.MessageFromParent = msg.SomeText;
}
window.ShowDialog();
});
Das Ergebnis sieht nach einem Klick auf den Button dann ca. so aus:
Bewertung
Natürlich ist das alles nicht wirklich optimal. In Listing 19 könnte man nun anmängeln, dass dieser Code “wissen” muss, dass das ChildViewModel
zum ChildWindow
gehört und dieses “Wissen” ja eigentlich über das DataContext
-Bindung abgebildet werden soll. Ja stimmt! Aber wir haben das Pattern MVVM logisch gesehen nicht gebrochen. Darum geht es, weil wir nur dann die Vorteile des Patterns, wie z.B. Testbarkeit wirklich ausnutzen können.
Das Internet ist voll von Menschen, die immer wieder Fragen zu MVVM stellen, weil sie völlig in dem Pattern verloren sind. Meist wissen sie nicht, wie sie eine spezielle Logik, wie halt das öffnen von anderen Views einbauen sollen und verdammen nach ein paar Versuchen das ganze MVVM-Thema. Ich denke, mit ein wenig Kompromißbereitschaft und sauberer Programmierung kommt man mit MVVM sehr weit und erhält zum Lohn mehr Spaß beim Entwicklen.
Ausblick
Ich hoffe, der nächste Teil lässt nicht so lange auf sich warten, wie dieser. Ich möchte mich um das Thema der Listen und den Herausforderungen damit kümmern. Wir werden Grids und ListViews binden und uns ansehen, wie so etwas mit MVVM gemacht werden kann.