Links
- Teil 1 mit den Grundlagen
- Teil 2 zu INotifyPropertyChanged
- Teil 3 zu IDataErrorInfo
- Teil 4 zu Binding von Listen
- Beispiel-Projekt-Repo auf GitHub
- Guter Artikel zu CollectionView von Andy O’Neill
Screencast
Einleitung
Eine der häufigsten Aufgaben im Rahmen der Entwicklung von User Interfaces im Business-Bereich ist die Umsetzung von sogenannten Master-Detail-Ansichten. Die Master-Ansicht ist dabei meist eine Liste von Elementen eines bestimmten Typs. Aus dieser wählt der Benutzer ein Element aus, welches ihm dann in der Detail-Ansicht präsentiert wird.
Übersetzt auf WPF bedeutet dies, dass wir in einem Listen-Steuerelement (ListBox
, ListView
, Grid
usw.) den Master umsetzen und in einem “normalen” Dialog oderBereich den Detail-View. Die Frage ist nun, wie man dieses Muster in WPF und MVVM Light möglichst effizient und vor allem technisch richtig einsetzt.
Normale Listen
Gehen wir zunächst einmal intuitiv vor und beleiben dabei in unserem Beispiel-Code. Wir verfügen über den Typ PersonModel
. Der intuitiv erste Schritt für eine Master-Ansicht würde code-seitig wahrscheinlich Folgendes ergeben:
public List<PersonModel></PersonModel> Persons{ get; set; }
Wenn wir diese Eigenschaft in unser MainViewModel
integrieren, können wir z.B. ein DataGrid
in MainWindow.xaml
wie folgt dagegen binden:
<DataGrid ItemsSource="{Binding Persons}" />
Die Eigenschaft ItemsSource
erbt das Control dabei vom Typ ItemsControl
. Eine schnelle Suche mit ReSharper ergibt folgende Standard-Typen, die von ItemsControl
erben:
- System.Windows.Controls.ComboBox
- System.Windows.Controls.Primitives.DataGridColumnHeadersPresenter
- System.Windows.Controls.Primitives.MenuBase
- System.Windows.Controls.ContextMenu
- System.Windows.Controls.DataGrid
- System.Windows.Controls.DataGridComboBoxColumn.TextBlockComboBox
- System.Windows.Controls.HeaderedItemsControl
- System.Windows.Controls.ListBox
- System.Windows.Controls.ListView
- System.Windows.Controls.Menu
- System.Windows.Controls.MenuItem
- System.Windows.Controls.Primitives.DataGridCellsPresenter
- System.Windows.Controls.Primitives.MultiSelector
- System.Windows.Controls.Primitives.Selector
- System.Windows.Controls.Primitives.StatusBar
- System.Windows.Controls.TabControl
- System.Windows.Controls.ToolBar
- System.Windows.Controls.TreeView
- System.Windows.Controls.TreeViewItem
- MS.Internal.Documents.Application.ZoomComboBox
- MS.Internal.Documents.FindToolBar
Anders ausgedrückt: alle diese Typen können erst einmal für Master-Ansichten verwendet werden, weil sie in der Lage sind, eine Menge von Elementen eines Typs über die ItemsSource
zu binden.
So gut wie alle Dritthersteller-Komponenten, die ich kenne nutzen übrigens ebenfalls ItemsControl
als Basis für ihre Grids, Listen etc.
Was aber ergibt denn nun das Beispiel aus den oberen beiden Listings? Damit wir das etwas besser verstehen können, habe ich den Konstruktur von MainViewModel
um folgende Logik erweitert:
var personList = new List<PersonModel>();
for (var i = 0; i < 100; i++)
{
personList.Add(
new PersonModel
{
Firstname = Guid.NewGuid().ToString("N").Substring(0, 10),
Lastname = Guid.NewGuid().ToString("N").Substring(0, 10)
});
}
Persons = new List<PersonModel>(personList);
(Die umständliche Initilisierung mit List in List erkläre ich später.)
Wenn wir das Projekt jetzt ausführen, erscheint das Grid ca. so:
Da wir alle Optionen im Standard belassen haben, hat das Control seine Spalten selbst erzeugt und benannt und zeigt somit alle Eigenschaften an, die unser PersonModel
aktuell innehat (auch die Berechneten). Ganz am unteren Ende (im Screenshot nicht zu sehen) befindet sich dann noch eine leere Zeile, die es uns erlaubt, neue Einträge vorzunehmen.
Warum dieser Ansatz nicht reicht
Man könnte meinen, der Artikel ist jetzt am Ende angelangt. Ist er nicht und in Wahrheit geht es jetzt erst los. Eine kleine Abwandlung des Codes soll das Problem zeigen, die dieser Ansatz hat. Dazu ergänzen wir das MainViewModel
nun um ein neues Command:
public RelayCommand AddPersonCommand { get; }
und initialiseren es im Konstuktor des MainViewModel
:
AddPersonCommand = new RelayCommand(() => Persons.Add(new PersonModel()));
Zum Schluss rufe ich das Command im UI über ein Menu-Item in MainWindow.xaml
auf:
<MenuItem Command="{Binding AddPersonCommand, Mode=OneWay}" Header="Add Person" />
Ich habe den Code aus Listing 3 noch so abgeändert, dass nur noch 10 Elemente gefüllt werden.
Wie man sieht, zeigt das DataGrid keinerlei Reaktion. Das liegt ganz einfach daran, dass List<T>
keine Logik enthält, um einen Aufrufer über neue oder gelöschte Elemente zu informieren. Mit anderen Worten bekommt das WPF-Binding einfach nicht mit, dass es nun 11 Elemente in Persons
gibt.
Die Lösung ganz einfach
Es gibt einen .NET-Typen, der unser Problem ohne Weiteres lösen kann: ObservableCollection<T>
. Sehen wir uns das einmal an, indem wir im MainViewModel
einfach die Property Persons
in eine ObservableCollection<PersonModel>
umwandeln:
public ObservableCollection<PersonModel> Persons{ get; set; }
Das müssen wir nun im Konstructor (siehe Listing 3) an einer stelle nachziehen (letzte Zeile aus Listing 3):
Persons = new ObservableCollection<PersonModel>(personList);
Das erklärt nebenbei nun auch, warum der Code in Listing 3 so umständlich ist :-). Führen wir das Programm nun aus, verhält es sich wie erwartet:
Was ist nun der Unterschied zwischen List<T>
und ObservableCollection<T>
? Letzteres implementiert 2 Interfaces:
- INotifyCollectionChanged
- INotifyPropertyChanged
Das erklärt dann einiges. INotifyCollectionChanged
kümmert sich darum, dass ein Ereignis CollectionChanged
aufgerufen wird, wenn die Anzahl der Elemente sich ändert. Unser alter Bekannte INotifyPropertyChanged
wird genutzt, damit die abhängigen Eigenschaften, wie z.B. Count
oder Items
auf der Liste ihre Änderung ebenfalls propagieren.
An dieser Stelle lässt sich unser Beispiel sehr schön rund machen, indem wir das XAML für das DataGrid
um ein Binding erweitern:
<DataGrid ItemsSource="{Binding Persons}" SelectedItem="{Binding PersonModel, Mode=TwoWay}" />
Was ich mit der SelectedItem
-Eigenschaft eines ItemsControl
umsetze ist, dass das aktuell ausgewählte Element im entsprechenden ViewModel in einer Eigenschaft gespeichert werden soll. Diese Eigenschaft hatten wir in unseren vorhergehenden Teilen der Serie bereits im MainViewModel
umgesetzt:
public PersonModel PersonModel { get; set; } = new PersonModel();
Durch die Ergänzung aus Listing 9 entsteht nun ohne viel Aufwand eine voll funktionale Master-Detail-Ansicht:
Limitierungen
Der hier gezeigte Ansatz ist scheinbar sehr charmant. Man hat etwas extrem einfaches im ViewModel (ObservableCollection<T>
), bindet dagegen und alles scheint sauber zu funktionieren. Das Problem bei dem Beispiel ist die Performance. Um das zu verstehen, muss man sich vergegenwärtigen, dass das UI-Element DataGrid
in unserem Beispiel direkt auf den Daten operiert. Außerdem beeinflussen die Daten direkt das Erscheinungsbild des UI. Bei 10 oder 100 Elementen spielt das noch keine Rolle. Haben wir aber z.B. 10.000 Elemente in der Datenquelle, wird das Bein recht schnell dick.
Ein weiterer wichtiger Nachteil unserer Lösung ist, dass wir auf das Multi-Threading acht geben müssen. Würde aktuelle ein anderer Thread als der UI-Thread Elemente auf der ObservableCollection<T>
ändern, würden wir recht schnell Exceptions im UI bekommen.
Schlechte Alternative
Ein Typ, der immer wieder genannt wird, um das Pattern angeblich besser zu implementieren ist BindingList<T>
. Um es gleich vorweg zu nehmen: Benutzt es nicht! BindingList<T>
ist extrem ressourcen-hungrig. Ein Beispiel für die schlechte Performance von BindingList<T>
ist, dass es für jedes PropertyChanged
-Event jedes Elements der Liste die gesamte Liste durchgeht, um die Indizes der Elemente zu aktualisieren. Man kann sich leicht vorstellen, welche Auswirkungen das haben kann.
Gute Alternative
Gerade aus Sicht von MVVM bietet sich ein anderer Typ perfekt für die gewünschte Implementierung an: ICollectionView
. Dieser Typ ist schon von seinem Grundaufbau nahezu perfekt für MVVM geeignet, da er die Daten von der Repräsentation trennt. Der Einsatz von ICollectionView
führt also quasi zu MVVM in MVVM. Sehen wir uns das genauer an.
Unser Sample kann zum aktuellen Zeitpunkt recht simpel auf ICollectionView
getrimmt werden. Im folgenden Code-Snippet sind alle notwendigen Änderungen am MainViewModel
komplett dargestellt (der komplette Quellcode kann hier eingesehen werden):
// changed to private
private ObservableCollection<PersonModel> Persons { get; }
// new property
public ICollectionView PersonsView { get; }
// changes in constructor
PersonsView = CollectionViewSource.GetDefaultView(Persons);
PersonsView.CurrentChanged += (s, e) =>
{
RaisePropertyChanged(() => PersonModel);
};
// changed property
public PersonModel PersonModel
{
get => PersonsView.CurrentItem as PersonModel;
set
{
PersonsView.MoveCurrentTo(value);
RaisePropertyChanged();
}
}
Zunächst wird die Persons
-Eigenschaft vor dem UI versteckt indem wir die Eigenschaft als private
deklarieren. Somit verhindern wir das versehentliche Binden direkt gegen diese Eigenschaft. Als nächstes bringen wir eine neue Eigenschaft vom Typ ICollectionView
an den Start. Gegen diese soll das UI später binden.
Damit diese Eigenschaft PersonsView
auch einen sinnvollen Wert erhält, müssen wir im Konstruktor nun die statische Methode CollectionViewSource.GetDefaultView()
benutzen. Wir übergeben ihr unsere interne ObservableCollection<PersonModel>
, damit daraus eine Repräsentation für das UI gebaut wird. Hierbei ist wichtig zu verstehen, dass ICollectionView
es uns prinzipiell erlaubt, beliebig viele Sichten auf die gleichen Daten zu definieren. GetDefaultView()
ist hier die einfachste (und am hähfigsten benutzte) Methode, einen View zu erzeugen.
Im letzten Schritt sorgen wir nun dafür, dass das ViewModel selbst “weiß”, wie man aus der ICollectionView
das aktuell selektierte Element ermittelt. Um diesen letzten Schritt vollständig zu verstehen, sehen wir uns die notwendigen XAMl-Anpassungen in MainWindow.xaml
an:
<DataGrid ItemsSource="{Binding PersonsView}" IsSynchronizedWithCurrentItem="True" EnableRowVirtualization="True" />
Wie man sieht, binden wir das Grid nun an die Eigenschaft PersonsView
. Die WPF-Eigenschaft IsSynchronizedWithCurrentItem
dient dazu, dass das Control (DataGrid) der ICollectionView “mitteilt”, wenn sich das selektierte Elemente ändert und anders herum darauf reagiert, wenn dies im Quellcode durchgeführt wird. Die Eigenschaft EnableRowVirtualization
sorgt letztlich dazu, dass die ICollectionView
dem Control dabei “hilft” nur die Daten anzuzeigen, die laut Fenstergröße, Scrollposition etc. aktuell sichtbar sind. Das hat natürlich vor allem Performance-Verbesserungen zur Folge.
IsSynchronizedWithCurrentItem
erklärt nun auch die neue Gestaltung der Eigenschaft PersonModel
aus Listing 11. Der Getter der Eigenschaft nutzt ICollectionView.CurrentItem
, um jeweils das aktuell selektierte Element zu liefern. Deshalb registrieren wir uns im Konstruktor am Ereignis ICollectionView.CurrentChanged
, damit wir jedes Mal, wenn dies im UI passiert im ViewModel mitteilen, dass sich der Wert von PersonModel
geändert hat. Der Setter von PersonModel
dient für die Rückwärts-Synchronisation im Falle das wir im ViewModel ein neues Element selektieren möchten.
Wir können diese Änderungen nun sehr gut testen, indem wir unser AddPersonCommand
aus Listing 5 ein wenig umbauen:
AddPersonCommand = new RelayCommand(
() =>
{
var newPerson = new PersonModel();
Persons.Add(newPerson);
PersonModel = newPerson;
});
Sehen wir uns das Ergebnis einmal an:
Wir sehen hier, dass die Synchronisierung durch IsSynchronizedWithCurrentItem
funktioniert und dass der Aufruf des Setters von MainViewModel.PersonModel
in Zeile 6 aus Listing 13 tatsächlich direkt im UI sichtbar wird. Allein diese Funktionalität zeigt, wieviel Logik uns ICollectionView
bringt.
Detaillierte Möglichkeiten durch Implementierungen
Ich benutze in diesem Sample erst einmal immer das Interface ICollectionView
. Dieses Interface wird von einigen Typen aus PresentationFramework
implementiert, die man direkt verwenden kann, um mehr Funktionalität zu erhalten:
- MS.Internal.Controls.InnerItemCollectionView
- MS.Internal.Data.CollectionViewProxy
- MS.Internal.Data.CompositeCollectionView
- MS.Internal.Data.EnumerableCollectionView
- System.Windows.Controls.ItemCollection
- System.Windows.Data.BindingListCollectionView
- System.Windows.Data.CollectionView
- System.Windows.Data.ListCollectionView
Vor allem die letzten beiden Typen können genutzt. ListCollectionView
erbt übrigens von CollectionView
. Sieht man sich die durch diese Typen ergänzend zu ICollectionView
definierten Methoden und Eigenschaften an, wird schnell klar, dass man sie wahrscheinlich der reinen ICollectionView
vorziehen wird. Z.B. kennt die ICollectionView
keine Count
-Eigenschaft. Sie kennt außerdem keine Add
-Methode. Diese und andere Limitierungen sorgen dafür, dass mein Quellcode im jetzigen Stadium immer auf die ObservableCollection
zugreift, wenn die reinen Daten benötigt werden. Dies könnte ich mir sparen, wenn ich z.B. ListCollectionView
direkt verwende, was übrigens die meisten Projekte tun.
Noch mehr Vorteile durch ICollectionView
Die Trennung von Präsentation und Daten von ICollectionView
erlaubt noch weitere nette Umsetzungen. Vorab sei hier nur erwähnt, dass die meisten der mir bekannten Dritthersteller-Controls ICollectionView
erkennen und nativ unterstützen. Das wird vor allem dann interessant, wenn wir uns ein paar Möglichkeiten von ICollectionView
vor Augen führen.
Sortierung im Backend
Das Sortieren wird bei Nutzung z.B. von ObservableCollection<T>
einfach dem Control überlassen. Das bedeutet, dass das Steuerelement direkt auf den vorhandenen Daten aber halt im UI die Sortierung übernimmt. Anders ist dies bei Einsatz von ICollectionView
. Wir können hier bestimmte Views fix sortieren (wenn man z.B. Sortierung über das Grid nicht wünscht). In unserem Beispiel könnte das wie folgt aussehen:
PersonsView = CollectionViewSource.GetDefaultView(Persons);
PersonsView.CurrentChanged += (s, e) =>
{
RaisePropertyChanged(() => PersonModel);
};
PersonsView.SortDescriptions.Clear();
PersonsView.SortDescriptions.Add(new SortDescription(nameof(PersonModel.Firstname), ListSortDirection.Ascending));
Die Zeilen 6 und 7 werden im Konstruktor nach den beiden anderen Befehlen eingefügt. Das Grid zeigt nun beim Öffnen direkt die sortierte Version. Das Problem hier ist nun, dass die Sortierung sich nicht anpasst, wenn wir Änderungen vornehmen:
ICollectionView
hat für diese Funktionalität die Methode Refresh()
im Angebot, die wir nun nutzen wollen.
BY SIDE: DIE OBSERVABLECOLLECTION-LÜGE
An dieser Stelle möchte ich kurz auf einen aus meiner Sicht heftigen Fehler von ObservableCollection<T>
eingehen. Sehen wir uns folgenden Screenshot an:
Ich habe die “Lüge” im Screenshot markiert. Das Problem ist, dass ObservableCollection<T>
das Ereignis CollectionChanged
eben NICHT wirft, wenn sich Elemente darin ändern, sondern nur, wenn neue hinzu kommen oder gelöscht werden.
BY SIDE ENDE
Die Lösung ist Folgende:
Persons = new ObservableCollection<PersonModel>(personList);
foreach (var item in Persons)
{
item.PropertyChanged += PersonsOnPropertyChanged;
}
PersonsView = CollectionViewSource.GetDefaultView(Persons);
PersonsView.CurrentChanged += (s, e) =>
{
RaisePropertyChanged(() => PersonModel);
};
PersonsView.SortDescriptions.Clear();
PersonsView.SortDescriptions.Add(new SortDescription(nameof(PersonModel.Firstname), ListSortDirection.Ascending));
Persons.CollectionChanged += (s, e) =>
{
foreach (INotifyPropertyChanged added in e.NewItems)
{
added.PropertyChanged += PersonsOnPropertyChanged;
}
foreach (INotifyPropertyChanged removed in e.OldItems)
{
removed.PropertyChanged -= PersonsOnPropertyChanged;
}
};
private void PersonsOnPropertyChanged(object sender, PropertyChangedEventArgs e)
{
PersonsView.Refresh();
}
Der Event-Handler unten dient nur dazu, jedes Mal Refresh
für das ICollectionView
aufzurufen, wenn eines der Elemente in Persons
eine Eigenschafts-Änderung erfährt. Mit dieser Änderung verhält sich die Anwendung nun wie folgt:
Filterung im Backend
ICollectionView
erlaubt es, dass die Filterung von Datensätzen im ViewModel definiert und ausgeführt wird. Besonders hier zeigt sich, dass die Unterteilung in mehrere Views pro Datenquelle sehr sinnvoll sein kann. Ein Beispiel, das ich hier aus Platzgründen nicht weiter ausführen werde ist das Vorladen von sagen wir 100 Datensätzen von einem Webservice in einen ersten View und das Nachladen aller z.B. 10.000 Datensätze in einen zweiten. Somit kann man dem Benutzer schon einmal Daten zeigen während man im Hintergrund nachlädt und das alles mit einer internen ObservableCollection<T>
.
Gruppierung im Backend
Genau wie bei Sortierung und Filterung kann auch die Gruppierung von Daten im ViewModel erfolgen.
IDataErrorInfo am Beispiel des DataGrid
Nachdem wir nun festgestellt haben, dass man unbdefingt ICollectionView
verwenden sollte, um Listen von Elementen im UI zu binden soll als letzter Abschnitt in diesem Beitrag noch kurz auf die Integration von IDataErrorInfo
in Listen eingegangen werden. Ich mache das hier am Beispiel eines DataGrid
.
Alle dazu notwendigen Anpassungen auf Seiten des ViewModels haben wir in Teil 3 dieser Serie bereits implementiert. Das heißt, wir müssen “nur noch” im MainWindow.xaml
Hand anlegen:
<DataGrid ItemsSource="{Binding PersonsView}" IsSynchronizedWithCurrentItem="True"
EnableRowVirtualization="True">
<DataGrid.Resources>
<Style x:Key="ErrorTextInput" TargetType="{x:Type TextBox}">
<Setter Property="Padding" Value="-2" />
<Style.Triggers>
<Trigger Property="Validation.HasError" Value="True">
<Setter Property="Background" Value="Red" />
<Setter Property="ToolTip"
Value="{Binding RelativeSource={RelativeSource Self}, Path=(Validation.Errors)[0].ErrorContent}" />
</Trigger>
</Style.Triggers>
</Style>
</DataGrid.Resources>
<DataGrid.RowValidationErrorTemplate>
<ControlTemplate>
<Grid Margin="0,-2,0,-2"
ToolTip="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type DataGridRow}}, Path=(Validation.Errors)[0].ErrorContent}">
<Ellipse StrokeThickness="0" Fill="Red"
Width="{TemplateBinding FontSize}"
Height="{TemplateBinding FontSize}" />
<TextBlock Text="!" FontSize="{TemplateBinding FontSize}"
FontWeight="Bold" Foreground="White"
HorizontalAlignment="Center" />
</Grid>
</ControlTemplate>
</DataGrid.RowValidationErrorTemplate>
<DataGrid.Columns>
<DataGridTextColumn Header="Firstname"
Binding="{Binding Firstname, ValidatesOnDataErrors=True, UpdateSourceTrigger=PropertyChanged}"
EditingElementStyle="{StaticResource ErrorTextInput}" />
<DataGridTextColumn Header="Lastname"
Binding="{Binding Lastname, ValidatesOnDataErrors=True, UpdateSourceTrigger=PropertyChanged}"
EditingElementStyle="{StaticResource ErrorTextInput}" />
<DataGridTextColumn Header="Birthday"
Binding="{Binding Birthday, ValidatesOnDataErrors=True, UpdateSourceTrigger=PropertyChanged, StringFormat=d}"
EditingElementStyle="{StaticResource ErrorTextInput}" />
<DataGridTextColumn Header="Age"
Binding="{Binding Age}"
IsReadOnly="True" />
</DataGrid.Columns>
<DataGrid.ContextMenu>
<ContextMenu>
<MenuItem Header="Set date" Command="{Binding SetSomeDateCommand}"
CommandParameter="{Binding PersonModel}" />
</ContextMenu>
</DataGrid.ContextMenu>
</DataGrid>
Gehen wir das nun einmal Schritt für Schritt durch. Zunächst speichere ich innerhalb der Ressourcen des DataGrid
einen Style mit dem Schlüssel “ErrorTextInput”. Diesen verwenden wir später innerhalb der Spalten um dem Control mitzuteilen, dass es diesen Style anwenden soll, wenn es im Editier-Modus eine Textbox zeigte das hier am Beispiel eines DataGrid
. Der Style selbst definiert eine rote Hintergrundfarbe und einen Tooltip, der einfach den Inhalt des ersten Fehlers ausgibt.
Als nächstes überschreibe ich das vom DataGrid
bereits angebotene RowValidationErrorTemplate
. Dieses wird immer dann verwendet, wenn innerhalb einer Zeile mindestens ein Fehler durch IDataErrorInfo
gemeldet wird. Im GIF unten kann man dann sehen, wie es ein Ausrufe-Zeichen in einem rotem Kreis zeigt und auf dem Kreis einen Tooltip bringt. Das ist sozusagen die Entsprechung für die Fehlermeldung, wenn wir nicht im Editiriermodus sind.
Dann definiere ich die Spalten, die letztlich angezeigt werden sollen per Hand und gebe jeder Spalte wie bereits oben erwähnt ein EditingElementStyle
, um das Aussehen und Verhalten der Editierbox anzupassen.
Ganz zum Schluss habe ich noch ein Kontextmenu eingebunden, dass ein neues SetSomeDataCommand
im MainViewModel
nutzt:
SetSomeDateCommand = new RelayCommand<PersonModel>(person => person.Birthday = DateTime.Now.AddYears(-20));
// new command
public RelayCommand<PersonModel> SetSomeDateCommand { get; }
Alles zusammen verhält sich dann ungefähr so:
Fazit
Man kann viel falsch machen beim Binden von Listen über WPF und MVVM. Wie immer hat Microsoft aber glücklicherweise auch an dieses Einsatzgebiet gedacht und dieser Artikel sollte zum einen den Schubs in die richtige Richtung geben und zum anderen aufzeigen, wie man sich dieser Schritt für Schritt nähert.