Dienstag, 19. Juni 2012

XAML: XPath Binding erweitern durch XPathHelper als MultiConverter

Vor kurzem habe ich eine eher komplexe Lösung implementiert, bei der aus technischen Gründen ein XAML komplett an ein XML gebunden wird. (nicht ganz, es gibt ein ViewModel für das generische Piping der Button Commands) An sich bietet WPF hier durch die Verwendung von XPath-Ausdrücken direkt im XAML eine sehr gute Möglichkeit, und für viele Aufgaben ist dies auch ausreichend.

Jedoch bin ich dabei über ein etwas komplexeres Problem gestolpert, denn nicht jeder XPath-Ausdruck kann zu einem XmlNode evaluiert werden, was dann zur Laufzeit folgenden Fehler hervorbringt:



System.Windows.Data Error: 45 : XML binding failed. Cannot obtain result node collection because of bad source node or bad Path.; SourceNode='#document'; Path='count(/ROOT/Adresse)' BindingExpression:Path=/; DataItem='XmlDocument' (HashCode=9035653); target element is 'Label' (Name=''); target property is 'Content' (type 'Object') XPathException:'System.Xml.XPath.XPathException: Der Ausdruck muss in einem Knotensatz resultieren.
bei System.Xml.XPath.XPathNavigator.Select(XPathExpression expr)
bei System.Xml.XmlNode.SelectNodes(String xpath)
bei MS.Internal.Data.XmlBindingWorker.SelectNodes()'

Beispiel: Eine Adress-Verwaltung im XML

Kommen wir erstmal kurz zu einem Beispiel an dem ich vereinfacht die Herausforderung deutlich machen möchte. Angenommen unser XML besteht aus einer Liste von Adressen - unser Programm bietet nun die Möglichkeit, Adressen hinzuzufügen, jedoch mit einer Einschränkung: Die gleiche Person darf nur 2 mal auftauchen (gleicher Vor- und Nachname). Um das ganze nun im Code nicht abfangen zu müssen, wollen wir den Button zum Speichern deaktivieren, wenn die besprochene Regel greift.
Das XML hat folgende Struktur:

   


Grundsätzlich besteht unser XAML aus einer ListBox.
       <ListBox
            HorizontalContentAlignment="Stretch"
            ItemsSource="{Binding XPath=/root/adresse}" DataContext="{Binding DataAsXmlDocument}" ItemContainerStyle="{StaticResource ContainerStyle}" >
Außerdem haben wir in den Window.Resources entsprechend für den unselektierten und den selektierten Zustand ein DataTemplate, dass über deinen Style-Trigger gesetzt wird. Im selektierten Zustand kann der gewählte Adresssatz bearbeitet werden und mit einem Button wird das Speichern bestätigt.
                <DataTemplate x:Key="ItemTemplate">
                    <Grid HorizontalAlignment="Stretch"  Width="Auto">
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="*" />
                            <ColumnDefinition Width="*" />
                        </Grid.ColumnDefinitions>
                        <Grid.RowDefinitions>
                            <RowDefinition Height="25" />
                            <RowDefinition Height="25" />
                            <RowDefinition Height="25" />
                        </Grid.RowDefinitions>
                        <Label Content="{Binding XPath=@vorname}"       Grid.Column="0" Grid.Row="0"/>
                        <Label Content="{Binding XPath=@name}"          Grid.Column="1" Grid.Row="0"/>

                        <Label Content="{Binding XPath=@strasse}"       Grid.Column="0" Grid.Row="1"/>
                        <Label Content="{Binding XPath=@hausnummer}"    Grid.Column="1" Grid.Row="1"/>

                        <Label Content="{Binding XPath=@plz}"           Grid.Column="0" Grid.Row="2"/>
                        <Label Content="{Binding XPath=@ort}"           Grid.Column="1" Grid.Row="2"/>
                    </Grid>
                </DataTemplate>

                <DataTemplate x:Key="SelectedItemTemplate">
                    <Grid HorizontalAlignment="Stretch"  Width="Auto">
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="*" />
                            <ColumnDefinition Width="*" />
                        </Grid.ColumnDefinitions>
                        <Grid.RowDefinitions>
                            <RowDefinition Height="25" />
                            <RowDefinition Height="25" />
                            <RowDefinition Height="25" />
                            <RowDefinition Height="25" />
                        </Grid.RowDefinitions>
                        <TextBox Text="{Binding XPath=@vorname}"       Grid.Column="0" Grid.Row="0"/>
                        <TextBox Text="{Binding XPath=@name}"          Grid.Column="1" Grid.Row="0"/>

                        <TextBox Text="{Binding XPath=@strasse}"       Grid.Column="0" Grid.Row="1"/>
                        <TextBox Text="{Binding XPath=@hausnummer}"    Grid.Column="1" Grid.Row="1"/>

                        <TextBox Text="{Binding XPath=@plz}"           Grid.Column="0" Grid.Row="2"/>
                        <TextBox Text="{Binding XPath=@ort}"           Grid.Column="1" Grid.Row="2"/>
                        
                        <Button Content="Speichern" Grid.Column="1" Grid.Row="3"/>
                    </Grid>
                </DataTemplate>

                <Style TargetType="{x:Type ListBoxItem}" x:Key="ContainerStyle">
                    <Setter Property="ContentTemplate" Value="{StaticResource ItemTemplate}" />
                    <Style.Triggers>
                        <Trigger Property="IsSelected" Value="True">
                            <Setter Property="ContentTemplate" Value="{StaticResource SelectedItemTemplate}" />
                        </Trigger>
                    </Style.Triggers>
                </Style>

Das ganze sieht dann visualisiert (mit gebundenem Beispiel-XML) sieht so aus.
Nicht schön, aber ich bin ja auch kein Designer ;-)

Unabhängig davon, wie diese Adress-Verwaltung neue Einträge hinzufügt, oder Einträge speichert, liegt die Herausforderung nun darin, den Speichern-Button auszublenden, wenn bereits genau 2 andere Personen existieren, die den gleichen Vor- und Nachnamen haben.


Ansatz 1: Die Konservative Lösung (code behind)

Naja, der naive Ansatz wäre jetzt die Logik für diese Regel einfach im code behind zu realisieren.
Dazu müssen im XAML einige Anpassungen gemacht werden. Zum einen müssen wir die zugehörigen Textboxen an das TextChangedEvent hängen und dort dann auf unsere ListBox zugreifen, dh. wir müssen diese über den Namen refernzieren können. Um den Speichern-Button zu deaktivieren, gilt das gleiche.
<!-- [..] -->
<TextBox Text="{Binding XPath=@vorname,UpdateSourceTrigger=PropertyChanged}"       
   Grid.Column="0" Grid.Row="0"  
   TextChanged="TextBox_TextChanged" />
<TextBox Text="{Binding XPath=@name,UpdateSourceTrigger=PropertyChanged}"          
   Grid.Column="1" Grid.Row="0"  
   TextChanged="TextBox_TextChanged" />
<!-- [..] -->
<Button Content="Speichern" 
           Grid.Column="1" Grid.Row="3" 
           Name="Button_Speichern"/>

<!-- [..] -->
        <ListBox
            Name="lstbAdressen"
            HorizontalAlignment="Stretch"
            HorizontalContentAlignment="Stretch"
            ItemsSource="{Binding XPath=/root/adresse}" 
   DataContext="{Binding DataAsXmlDocument}" 
   ItemContainerStyle="{StaticResource ContainerStyle}" >
<!-- [..] -->

Jetzt fehlt uns nurnoch der code behind:
private void TextBox_TextChanged(object sender, TextChangedEventArgs e)
{
 XmlNode selectedItem = (XmlNode)lstbAdressen.SelectedItem;
 if (selectedItem != null)
 { 
  XmlDocument adressenXml = lstbAdressen.DataContext as XmlDocument;
  if (adressenXml != null)
  {
    XmlNodeList sameNameNodes = 
        adressenXml.SelectNodes(
                 "/root/adresse[@vorname = '" 
                 + selectedItem.Attributes["vorname"].Value 
                 + "' and @name = '" 
                 + selectedItem.Attributes["name"].Value 
                 + "']");
                  
    for (
      int i = 0; 
      i < VisualTreeHelper.GetChildrenCount((sender as FrameworkElement).Parent); 
      i++)
    {
     DependencyObject child = 
         VisualTreeHelper.GetChild((sender as FrameworkElement).Parent, i);
     if (child != null 
         && child is FrameworkElement 
         && ((FrameworkElement)child).Name == "Button_Speichern")
     {
       if (sameNameNodes.Count == 3)
       {
        ((FrameworkElement)child).IsEnabled = false;
       }
       else
       {
        ((FrameworkElement)child).IsEnabled = true;
       }

     }
    }
   }
  }
}


Wie man sieht ist diese Lösung mit den ca. 10 Codezeilen nicht ganz so trivial wie angenommen. Prinzipiell liegt ein größerer Aufwand darin, den Speichern-Button zu finden, da dieser in einem DataTemplate gekapselt ist. Nichtsdestotrotz funktioniert dieser Ansatz ganz gut, vielleicht gibt performancemäßige Probleme, aber bei kleinen Datensätzen sollte es ganz gut klappen.

Ansatz 2: MVVM-Pattern

Die Lösung mit dem code behind wird bei vielen WPF-Entwicklern arge Bauchschmerzen verursachen, da es ein datentechnisches Problem über visuelle Events und damit verbunden direkten Zugriff auf die View verursacht als Lösung heranzieht. Sauberer ist natürlich das ganze über ein Command-Property im ViewModel zu lösen. Wir sparen uns dadurch die Iteration durch den VisualTree und können komplett von der View abstrahieren.

Ansatz 3: Die Lösung im XAML

Eine Lösung komplett im XAML ist vermutlich nicht möglich, denn XPath kann keine Variablen speichern. Um durch alle Adress-Knoten zu iterieren und nach einem Attribut aus dem aktuellen Kontext zu vergleichen ist dies unabdingbar. Es müssste etwas geben um im XPath Platzhalter zu definieren, um diese wiederum dann durch weitere Bindings zu ersetzen, so etwas wie:

{Binding XPath=/root/adresse[@vorname={Binding XPath=@vorname} and @name={Binding XPath=@name}] }

Abgesehen davon, dass das vollkommener Quatsch ist, kommt noch hinzu, dass wir die Anzahl der Nodes zählen müssten:
{Binding XPath=count(/root/adresse[@vorname={Binding XPath=@vorname} and @name={Binding XPath=@name}]) }

Ich hab lange überlegt wie man auf elegante Weise dieses Problem lösen kann, mit einer MarkupExtension ? Einem Converter? Oder doch was ganz anderem?
Die meines Erachtens beste Lösung ist die Verwendung eines MultiBinding. Die Idee dahinter ist, die Funktion String.Format mit einem SelectNodes zu kombinieren.
Das MultiBinding an sich soll folgendermaßen funktionieren:

<MultiBinding Converter="{StaticResource XPathHelper}" 
ConverterParameter="count:/root/adresse[@vorname={0} and @name={1}]"> 
  <Binding XPath="@vorname" />
  <Binding XPath="@name" />
  <Binding XPath="." />
</MultiBinding>

Prinzipiell sollte jetzt jedem klar sein, wie der Converter implementiert wird, für alle die gern kopieren & pasten, hier ein Implementierungsvorschlag:
    public class XPathHelper : IMultiValueConverter
    {
        public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
        {
            string xpath = (string)parameter;
            string.Format(xpath, values);
            bool justCountResult = false;
            if (xpath.StartsWith("count:", StringComparison.InvariantCultureIgnoreCase))
            {
                xpath = xpath.Substring(6);
                justCountResult = true;
            }

            XmlDocument doc = (XmlDocument) values[values.Count() - 1];
            if (doc != null)
            {
                XmlNodeList nodesFound = doc.SelectNodes(xpath);
                if (justCountResult) // return count only
                    return nodesFound.Count.ToString();
                if (nodesFound.Count > 0) //return only the value of the first node though
                    return nodesFound[0].Value;
            }
            return null; // nothing found or no xmldocument given
        }

        public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }

Keine Kommentare:

Kommentar veröffentlichen