Grundlagen - MVVM-Pattern, DataContext und DataTemplates im Treeview

    • WPF

    Es gibt 2 Antworten in diesem Thema. Der letzte Beitrag () ist von ErfinderDesRades.

      Grundlagen - MVVM-Pattern, DataContext und DataTemplates im Treeview

      Databinding und deklarative Programmierung
      Also, eine saubere Wpf-Anwendung basiert strikt auf Databinding, d.h., im Xaml-Code werden die Controls organisiert, und ans Viewmodel gebunden.
      Control-Ereignisse zu verarbeiten verstößt gleichma gegen die "reine Lehre", nach der das Gui rein deklarativ zu implementieren ist, und prozeduraler Code gehört nicht ins Gui, sondern in den Viewmodel-Bereich.
      Hier ein ausgezeichneter Artikel zur Begründung des MVVM-Patterns - von Josh Smith - (den Namen lohnt es sich zu merken)

      Beispiel-"Anwendung"
      Auf einen button-Click hin wird ein Datei-Ordner im Treeview angezeigt:


      Hier mal die Dateien, wie sie in der Solution organisiert sind

      Infrastruktur-Dll
      Zu beachten der Verweis auf die GalaSoft.Mvvm - Library, welche einiges an "InfrastrukturCode" enthält, der unglücklicherweise vom PresentationFramework nicht bereitgestellt wird.
      Derartige Infrastruktur-Dlls gibt es viele (Google: "mvvm toolkit") - ich habe mich für die Galasoft MVVM-Toolkit entschieden, weil das relativ klein ist, und weil deren Entwickler, Laurent Burion, auch ausgezeichnete Tutorials macht.
      Das wichtigste Element dieser Lib, die ViewmodelBase, wird von diese App garnicht genutzt, weil sie mit der Framework-ObeservableCollection auskommt, die bereits INotifyPropertyChanged implementiert.
      Hier wird nur das GalaSoft.RelayCommand genutzt - die zweitwichtigste Klasse.
      Beides könnte man auch selber schreiben, aber wozu? - Besser als das GalaSoft-Teil wird mans nur unter Schwierigkeiten hinkriegen, schlechter jedoch mit Leichtigkeit ;)
      Auf mehrfache Nachfragen, ob diese Dll wirklich nötig sei:
      Ja - steht doch in obigem Absatz, dass ich das GalaSoft.RelayCommand benötige.
      Natürlich würde man lieber mit .Net-Bordmitteln auskommen, aber das PresentationFramework ist einfach in manchen Punkten ungenügend - deshalb gibts ja so viele mvvm-toolkits.

      Code
      Wie gesagt: Wpf trennt strikt zw. Daten und Anzeige. Es werden 2 ganz unterschiedliche Sprachen verwendet, nämlich für die Daten Code, für die Anzeige Xaml.
      Das MainModel:

      VB.NET-Quellcode

      1. 'Singleton-Pattern: Es kann nur ein MainModel-Objekt geben.
      2. 'Da Sub New privat ist, kann kein zweites erstellt werden.
      3. Public Class MainModel
      4. Public Shared ReadOnly Root As New MainModel ' das einzige
      5. Private Sub New()
      6. End Sub
      7. Public Property FileSystemTree As New FileSystemTree
      8. End Class
      MainModel.Root ist also das einzige MainModel, und alles, was' gibt, wird da angebunden.

      In diesem MainModel gibts nur eine Property zum Anbinden, nämlich den FileSystemTree:

      VB.NET-Quellcode

      1. Public Class FileSystemTree : Inherits ViewModelBase
      2. Private _Browser As New FolderBrowserDialog _
      3. With {.SelectedPath = (New DirectoryInfo("..\..")).FullName}
      4. Public Property Roots As New ObservableCollection(Of DirectoryNode)
      5. Public Property Load As New GalaSoft.MvvmLight.Command.RelayCommand( _
      6. Sub()
      7. If _Browser.ShowDialog <> DialogResult.OK Then Return
      8. Dim root As New DirectoryNode _
      9. With {.Item = New DirectoryInfo(_Browser.SelectedPath)}
      10. root.LoadRecursive()
      11. Roots.Clear()
      12. Roots.Add(root)
      13. End Sub)
      14. End Class

      FileSystemTree macht 2 Properties verfügbar, an die im Xaml-Code gebunden werden kann:
      1. die Roots-Collection für die Root-Nodes des Treeviews (es wird aber nur ein einziger DirectoryNode eingefüllt)
      2. ein Command für den Load-Button (beachte: es ist eine Property!): die auszuführende Methode ist gleich als anonyme Methode hineingecodet

      Müssemermal den DirectoryNode angugge, um die Bedeutung von DirectoryNode.LoadRecursive() zu verstehen:

      VB.NET-Quellcode

      1. Public Class FileSystemNode
      2. Public Property Item As FileSystemInfo
      3. End Class
      4. Public Class DirectoryNode : Inherits FileSystemNode
      5. Public Property Childs As New ObservableCollection(Of FileSystemNode)
      6. Public Sub LoadRecursive()
      7. Childs.Clear()
      8. Dim di = DirectCast(MyBase.Item, DirectoryInfo)
      9. For Each d In di.GetDirectories
      10. Dim nd = New DirectoryNode With {.Item = d}
      11. nd.LoadRecursive()
      12. Childs.Add(nd)
      13. Next
      14. For Each f In di.GetFiles
      15. Childs.Add(New FileSystemNode With {.Item = f})
      16. Next
      17. End Sub
      18. End Class

      Polymorphie
      Tatsächlich handelt es sich um 2 Klassen, nämlich DirectoryNode erbt von einer Klasse FileSystemNode.
      (Achtung - im folgenden nicht verwechseln: FileSystemInfo, FileInfo, DirectoryInfo sind Klassen aussm System.IO-Namespace, hingegen FileSystemNode und DirectoryNode sind selbst gecodet)
      FileSystemNode enthält nix weiter als ein FileSystemInfo, der Basisklasse sowohl von System.IO.DirectoryInfo als auch von System.IO.FileInfo.
      Die Property FileSystemNode.Item As FileSystemInfo ist also polymorph - kann sowohl ein DirectoryInfo darstellen als auch ein FileInfo.
      DirectoryNode beerbt nun FileSystemNode, und erweitert es um die Property Childs As ObservableCollection(Of FileSystemNode). Diese Collection ist also ebenfalls polymorph - kann sowohl FileSystemNodes als auch DirectoryNodes enthalten, in bunter Mischung.
      Das ist ja genau unser Ziel: Wir wollen im Treeview zweierlei Arten von Treenode darstellen: welche für Directories, und annere für Files.

      Xaml
      StartObjekt ist die Application.Xaml - die macht folgendes:

      XML-Quellcode

      1. <Application x:Class="Application"
      2. xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      3. xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
      4. StartupUri="Views\MainWindow.xaml">
      5. <Application.Resources>
      6. <!--hier könnten alle möglichen Objekte global verfügbar gemacht werden-->
      7. </Application.Resources>
      8. </Application>
      setzt also derzeit nur das Start-Fenster: "Views\MainWindow.xaml".

      Bleibt noch zu gugge, wie und welche Controls im MainWindow an welche Code-Objekte gebunden werden:

      XML-Quellcode

      1. <Window x:Class="MainWindow"
      2. xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      3. xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
      4. Title="MainWindow" Height="350" Width="525"
      5. xmlns:my="clr-namespace:WpfFilesystemTreeView"
      6. DataContext="{x:Static my:MainModel.Root}">
      7. <Grid>
      8. <Grid.RowDefinitions>
      9. <RowDefinition Height="Auto" />
      10. <RowDefinition />
      11. </Grid.RowDefinitions>
      12. <Menu>
      13. <MenuItem Header="Load" Command="{Binding Path=FileSystemTree.Load}" />
      14. </Menu>
      15. <TreeView Grid.Row="1" ItemsSource="{Binding Path=FileSystemTree.Roots}" >
      16. <TreeView.Resources>
      17. <HierarchicalDataTemplate DataType="{x:Type my:DirectoryNode}"
      18. ItemsSource="{Binding Path=Childs}">
      19. <StackPanel Orientation="Horizontal">
      20. <Image Width="16" Height="16" Source="/WpfFilesystemTreeView;component/Icons/Folder.ico" />
      21. <TextBlock Margin="2 1 0 1" Text="{Binding Path=Item.Name}" />
      22. </StackPanel>
      23. </HierarchicalDataTemplate>
      24. <DataTemplate DataType="{x:Type my:FileSystemNode}" >
      25. <StackPanel Orientation="Horizontal">
      26. <Image Width="16" Height="16" Source="/WpfFilesystemTreeView;component/Icons/File.ico" />
      27. <TextBlock Margin="2 1 0 1" Text="{Binding Path=Item.Name}" />
      28. </StackPanel>
      29. </DataTemplate>
      30. </TreeView.Resources>
      31. </TreeView>
      32. </Grid>
      33. </Window>
      Zunächstmal wird der <my>-Namespace klargemacht - diese Umständlichkeit bleibt keiner Xaml-Datei erspart, die auf selbstgecodete Klassen zugreift.
      Dann wird der DataContext des gesamten Windows festgelegt:
      DataContext="{x: Static my:MainModel.Root}" - Mit dieser komischen Syntax:
      "{x: Static ...}" wird auf das Public Shared Root - Feld der MainModel-Klasse zugegriffen. Die Syntax wird nicht durch Intellisense unterstützt, und der Compiler meckert das auch an, bis man explizit kompiliert.
      Dennoch ist diese Vorgehensweise die einfachste Möglichkeit, das Viewmodel ins Xaml zu holen.

      Soweit jetzt nix anneres angegeben wird, beziehen sich jetzt alle Bindings, die im folgenden eingerichtet werden, auf genau diesen einen DataContext.

      Aufbau des Windows
      Alle anderen Controls des Windows sind hier auffm <Grid>-Control angebracht, welches hat 2 Rows, deren erste auf Height="Auto" eingestellt ist, sodaß diese Zeile ihre Höhe nach dem darin enthaltenen Control richtet.
      Das erste Control ist ein Menu mit einem <MenuItem> darin, letzteres beschriftet mit dem sinnigen Text "Load", und sein Command ist an die Load-Property der FileSystemTree-Property des Root-MainModels (DataContext!) gebunden:
      <MenuItem Header="Load" Command="{Binding Path=FileSystemTree.Load}" />
      Was folgt ist der TreeView, und dem ist Grid.Row="1" zugeteilt, also die 2.Zeile des Grids (die das gesamte restliche Window ausfüllt).
      ItemsSource des Treeviews - also wo er seine Nodes hernimmt - ist gebunden an FileSystemTree.Roots (ihr erinnert euch: das ist die ObservableCollection, die mit maximal einem DirectoryNode befüllt wurde)
      Ja, das wars im wesentlichen - wären da nicht noch die Resourcen des TreeViews.
      Da haben wir nämlich 2 DataTemplates, eines davon sogar ein <HierarchicalDataTemplate> - und damit wären wir beim Thema DataTemplating:
      So ein DataTemplate gibt zunächst an, für welchen Typ es zuständig ist, und dann werden da Controls hineingebastelt und an Eigenschaften dieses Typs gebunden.
      Da habe ich jetzt in beide Templates eiglich dasselbe hineingebastelt, nämlich ein <Stackpanel> mit einem <Image> und einem <Textblock>. Der Text ist in beiden Fällen gebunden an die Item.Name-Property des zuständigen Typs. Ihr erinnert euch: FileSystemNode hat eine Item-Property vom Typ System.IO.FilesystemInfo, und DirectoryNode hat diese Property natürlich auch, nämlich von FileSystemNode geerbt. Und ein System.IO.FilesystemInfo hat einen Name, und den wollemer anzeige.
      Zu beachten ist, dass innerhalb des DataTemplates nicht der DataContext des Windows gilt, sondern die Typ-angabe des DataTemplates gibt hier den lokalen DataContext vor.
      Noch das Spezifikum eines HierarchicalDataTemplate erläutert: Beim HierarchicalDataTemplate kannich (ganz ähnlich wie beim Treeview) eine ItemsSource-Property binden, und damit ist dem HierarchicalDataTemplate mitgeteilt, wie an untergeordnete Nodes zu kommen ist: Das ist natürlich die Childs-Property eines DirectoryNodes, mit der man an seine Kind-Knoten kommt - habterja bereits weiter oben gesehen ;)

      Wars das jetzt - keine Treenodes adden und Zeugs?
      Jepp - den Rest erledigt das DataTemplating für uns: Der Treeview holt sich Daten-Nodes aus seiner gebundenen ItemsSource, und für jeden Daten-Node schaut er in seine Resourcen. Hat er einen DirectoryNode erwischt, wendet er das HierarchicalDataTemplate an, um das gebundene TreeviewItem zu erstellen.
      Nämlich weil die Typ-Angabe dieses HierarchicalDataTemplate so schön auf DirectoryNode matcht.
      Hat er einen FileSystemNode geholt, so nimmterhalt das annere DataTemplate, welches - als wäre es Absicht! - auf FileSystemNode matcht.
      Und weil das DirectoryNode-Datatemplate halt hierarchical ist, schauter beim Expandieren des DirectoryNode-TreeviewItems auch in dessen Itemssource, sodaß - rekursiv - alle Daten-Nodes angezeigt werden.

      Zusammenfassung
      So, das war jetzt ein ziemlicher Crashkurs, odr?
      • Viewmodel: Zunächst wurde gezeigt, wie mit den Klassen Mainmodel, DirectoryTree, DirectoryNode und FileSystemNode ein Gesamt-Modell entsteht, was überhaupt geeignet ist, das Datei-System einer Festplatte auch abzubilden. In diesem Modell kommen keinerlei Controls vor - aber es gibt Public Properties, die entscheidend sind.
      • DataContext: Xaml bindet nun an diese Public Properties an. Dabei kommt das DataContext-Konzept zum Tragen, also einen DataContext zu setzen/binden macht das gesetzte Viewmodel für alle eingeschachtelten Xaml-Elemente zugreifbar.
        Wohlgemerkt nur für die eingeschachtelten, und er wirkt auch nur dann in sie hinein, solange lokal kein abweichender DataContext gesetzt ist.
        So kann man sehr differenziert bestimmen, was wo wie gebunden werden kann.
        (Übrigens kann auch anders gebunden werden, und nicht nur an Viewmodels oder DataContexte. Aber der DataContext-Mechanismus ist wegen seines Vererbungs-Konzepts besonders hervorzuheben)
      • Templates: Auch gezeigt wurden DataTemplates, die mit ihrer DataType-Angabe sowohl ihren lokalen DataContext definieren, aber gleichzeitig auch, auf welche DatenElemente sie angewendet werden.
        Templating ist ein fundamentaler Ansatz: Ich erzeuge keine Treenodes und fülle sie in den Treeview, sondern ich binde den Treeview an Daten, und stelle passende Templates bereit - befüllen tut der Treeview dann sich selbst, und ist dabei clever genug, die Elemente auch jeweisl mit dem passenden Template zu präsentieren.
        (Übrigens sind DataTemplates nicht die einzige Form von Templating in Wpf: Styles und ControlTemplates setzen ebenfallsTemplating-Konzepte um, aber mit anderen Eigenheiten.)
      Dateien

      Dieser Beitrag wurde bereits 6 mal editiert, zuletzt von „ErfinderDesRades“ ()

      "Frequently asked Questions" und "Lazy Loading"

      Frequently Asked Questions
      1. Ich bekomme lauter Fehler angezeigt, wenn ich das MainWindow.Xaml angucken will
        Antwort: Die Solution als erstes mal erstellen, damit die Dlls, die der Designer zur Anzeige braucht, auch vorhanden sind.

      2. Wie greife ich auf ein Treeview-Item zu, wie füge ich ein Treeview-Item in den TreeView ein?
        Antwort: Gar nicht. Für Leute, die Databinding nicht kennen, ist es meist überaus schwer zu fassen, dass man an den Controls nichts, gar nichts, herumzufummeln hat.
        Man fügt Daten ins Datenmodell ein, und weil die Controls daran gebunden sind, holen sie die Daten selbsttätig.


      Lazy Loading
      Das Code-Sample des vorigen Posts durchläuft beim Laden rekursiv das Dateisystem, und erstellt für jede Datei und jeden Ordner einen Node. Das kann überaus imperformant werden, nämlich wenn etwa c:\ als zu ladendes Directory angegeben wird, dann wird daraufhin die gesamte Festplatte durchsucht, und es werden hunderttausende von Nodes erstellt.
      Wpf bietet uns eine sehr elegante Möglichkeit, nur die Nodes zu laden, die wir auch sehen wollen. Wir dürfen halt keine Methode DirectoryNode.LoadRecursive() proggen, sondern wir modifizieren die Childs-Property so, dass sie nur dann aufs Dateisystem zugreift, wenn sie erstmalig abgerufen wird.
      Und sie geht auch nicht rekursiv alle Unterordner durch, sondern nur den einen, dessen Childs abgerufen werden.

      das gesamte ViewModel stellt sich nun also folgendermaßen dar:

      VB.NET-Quellcode

      1. Imports System.IO
      2. Imports System.Collections.ObjectModel
      3. Imports Microsoft.Win32
      4. Imports System.Windows.Forms
      5. Public Class FileSystemTree
      6. Private _Browser As New FolderBrowserDialog _
      7. With {.SelectedPath = Path.GetFullPath("..\..")}
      8. Public Property Roots As New ObservableCollection(Of DirectoryNode)
      9. Public Property Load As New GalaSoft.MvvmLight.Command.RelayCommand( _
      10. Sub()
      11. If _Browser.ShowDialog <> DialogResult.OK Then Return
      12. Roots.Clear()
      13. Roots.Add(New DirectoryNode(_Browser.SelectedPath))
      14. End Sub)
      15. End Class
      16. Public Class FileSystemNode
      17. Public Property Item As FileSystemInfo
      18. End Class
      19. Public Class DirectoryNode : Inherits FileSystemNode
      20. Private _Childs As ObservableCollection(Of FileSystemNode)
      21. Public ReadOnly Property Childs As ObservableCollection(Of FileSystemNode)
      22. Get
      23. If _Childs Is Nothing Then CreateChilds()
      24. Return _Childs
      25. End Get
      26. End Property
      27. Public Sub New(ByVal path As String)
      28. MyClass.New(New DirectoryInfo(path))
      29. End Sub
      30. Public Sub New(ByVal di As DirectoryInfo)
      31. Item = di
      32. End Sub
      33. Private Sub CreateChilds()
      34. _Childs = New ObservableCollection(Of FileSystemNode)
      35. Dim di = DirectCast(Item, DirectoryInfo)
      36. For Each d In di.GetDirectories
      37. _Childs.Add(New DirectoryNode(d))
      38. Next
      39. For Each f In di.GetFiles
      40. _Childs.Add(New FileSystemNode With {.Item = f})
      41. Next
      42. End Sub
      43. End Class


      (Upload in post#1 enthält beide Projekte, zuzüglich ein Helpers-Projekt, was ein verbessertes Handling des MainModels ermöglicht)

      Dieser Beitrag wurde bereits 7 mal editiert, zuletzt von „ErfinderDesRades“ ()

      DatenInitialisierung zu Lauf- und Design-Zeit

      Üblicherweise will man bereits zum Start einer App iwelche Daten anzeigen. Einfacherweise füge ich im MainModel-Konstruktor dem FileSystemTree gleich das übergeordnete aktuelle Directory zu:

      VB.NET-Quellcode

      1. Public Class MainModel
      2. Public Shared ReadOnly Root As New MainModel
      3. Private Sub New()
      4. FileSystemTree.Roots.Add(New DirectoryNode("..\"))
      5. End Sub
      6. Public Property FileSystemTree As New FileSystemTree
      7. End Class

      Aber das ist tückisch, denn der Konstruktor wird auch im Designer aufgerufen.
      Wann immer nun Designer-Code geändert wird, wird dieser Konstruktor aufgerufen, fügt einen DirectoryNode hinzu, was mittelbar zu einer Suche im Dateisystem führt.
      Mit sowas kann man den eh schmerzgrenzwertig langsamen Designer komplett ausbremsen.

      Andererseits ist schick (und für viele Designs unerhört nützlich oder sogar notwendig), wenn man im Designer bereits Daten sehen kann:


      Also programmiere ich eine Unterscheidung ein, bei der zur Designzeit Dummi-Daten geladen werden. Die oberste Ebene - Roots.Add(New DirectoryNode("..\")) - belasse ich so, aber die Dateisuche wird umgangen, indem _Childs schon im Konstruktor initialisiert wird, sodaß der Property-Childs-Abruf nicht zu einem CreateChilds()-Aufruf führt:

      VB.NET-Quellcode

      1. Private _Childs As ObservableCollection(Of FileSystemNode)
      2. Public ReadOnly Property Childs As ObservableCollection(Of FileSystemNode)
      3. Get
      4. If _Childs Is Nothing Then CreateChilds()
      5. Return _Childs
      6. End Get
      7. End Property
      8. Public Sub New(ByVal path As String)
      9. MyClass.New(New DirectoryInfo(path))
      10. If GalaSoft.MvvmLight.ViewModelBase.IsInDesignModeStatic Then
      11. ' _Childs vor-initialisieren, sodaß im DesignMode CreateChilds()
      12. ' nicht ausgeführt wird
      13. _Childs = New ObservableCollection(Of FileSystemNode)
      14. _Childs.Add(New FileSystemNode)
      15. End If
      16. End Sub