Monday, November 28, 2022

Markup Extension to bind to any other control

Sometimes we would like to bind the properties of two controls together. There are at least three ways to do this.

  • Bind both properties via a property in the code-behind
  • Name a control and use Source=<name>
  • If the source control is an ancestor of the target control we can use RelativeSource

I decided I want a fourth method. I want to specify a binding that takes an XPath-like parameter to specify a control relative to the page or the target control. It took me about a day.

I called the markup extension XBinding and it takes these parameters

  • Path: same as a regular binding
  • XPath: a string that navigates the XAML domain and points to a single control
  • Converter: an optional converter resource
  • ConverterParameter: an optional string parameter passed to the converter

XPath is a list of control types separated by / or //. If it starts with / or // the path is relative to the page otherwise it is relative to the target control. Each control type can have optional property values and an optional index.

Examples. (page could be a window, user control, or any top level control)

//TextBox[0] - the first TextBox anywhere on the page

/TextBox[Width=100][1] - the second textbox that is a direct child of the page and that has a width of 100

.. - The immediate parent of the current control

..[Type=Grid][Level=2] - The second grid in the current control's ancestor hierarchy

..[Type=Grid]/TextBox[1] - Go up from the current control until you find a grid, then find the second direct child that is a TextBox

..[Type=StackPanel]//Grid/TextBlock[ForeGround=#FFFF0000][1] - Go up from the current control until you find a stack panel and find the first descendant grid (doesn't have to be a direct child). Then find the second direct child textblock of that grid with a red foreground. 

I wrote a MarkupExtension and some helper functions.

Imports System.Windows.Markup
Imports System.Reflection
Imports System.Globalization
Imports System.Xml
 
Public Class XBinding
    Inherits MarkupExtension
 
    Private dp As DependencyProperty
    Private TargetFE As FrameworkElement ' The element containing this binding
    Private TopFE As FrameworkElement ' The top-level element (window, user control, etc)
    Private SourceFE As FrameworkElement ' The element that provides the path value
 
    Public Property Path As String
    Public Property XPath As String
    Public Property Converter As IValueConverter
    Public Property ConverterParameter As Object
 
    Private Class cNodeType
        Public NodeTypeName As String = "DependencyObject"
        Public NodeIndex As Integer = -1
        Public PropertyPairs As New List(Of cPropertyPair)()
    End Class
 
    Private Class cPropertyPair
        Public PropertyName As String = ""
        Public PropertyValue As String = ""
    End Class
 
    Private Class cParentType
        Public ParentType As String = ""
        Public ParentLevel As Integer = 1
    End Class
 
    Public Overrides Function ProvideValue(serviceProvider As IServiceProvider) As Object
        Dim pvt As IProvideValueTarget = DirectCast(serviceProvider.GetService(GetType(IProvideValueTarget)), IProvideValueTarget)
        dp = pvt.TargetProperty
        TargetFE = pvt.TargetObject
        TopFE = TreeHelper.GetUltimateParent(TargetFE)
        If pvt.TargetObject.GetType().Name = "SharedDP" Then
            Return Me
        Else
            ' Wait for parent to finish loading
            AddHandler TopFE.Loaded, AddressOf Parent_Loaded
        End If
    End Function
 
    Private Sub Parent_Loaded(sender As Object, e As RoutedEventArgs)
        SourceFE = FollowPath(XPath)
        CreateBinding(SourceFE, Path, TargetFE, dp)
        RemoveHandler TopFE.Loaded, AddressOf Parent_Loaded
    End Sub
 
    Private Function FollowPath(XPath As String) As FrameworkElement
 
        Dim OriginalPath As String = XPath
        Dim ParentFE As FrameworkElement
        Dim ChildFE As FrameworkElement = Nothing
        Dim NodeType As cNodeType
        Dim ParentType As cParentType
        If XPath.StartsWith("/") Then
            ParentFE = TopFE
        Else
            ParentFE = TargetFE
        End If
 
        While XPath.Length > 0
            If XPath.StartsWith("//") Then
                XPath = XPath.Substring(2)
                NodeType = GetNodeType(XPath)
                ChildFE = FindAllMatchingNodes(NodeType, ParentFE).FirstOrDefault()
            ElseIf XPath.StartsWith("/") Then
                XPath = XPath.Substring(1)
                NodeType = GetNodeType(XPath)
                ChildFE = FindMatchingNode(NodeType, ParentFE)
            ElseIf XPath.StartsWith("..") Then
                XPath = XPath.Substring(2)
                ParentType = GetParentType(XPath)
                ChildFE = FindParent(ParentFE, ParentType)
            Else
                NodeType = GetNodeType(XPath)
                ChildFE = findmatchingnode(NodeType, ParentFE)
            End If
            ParentFE = ChildFE
        End While
 
        If ChildFE Is Nothing Then
            Throw New Exception($"No matching controls for path {OriginalPath}")
        End If
        Return ChildFE
    End Function
 
    Private Function GetNodeType(ByRef XPath As String) As cNodeType
        Dim p As Integer = XPath.IndexOf("/")
        Dim PathPart As String = ""
        Dim NodeType As New cNodeType()
        Dim IndexContent As String
        Dim Index As Integer
        Dim IndexParts As List(Of String)
 
        If p = -1 Then
            PathPart = XPath
            XPath = ""
        Else
            PathPart = XPath.Substring(0, p)
            XPath = XPath.Substring(p)
        End If
 
        p = PathPart.IndexOf("[")
        If p = -1 Then
            NodeType.NodeTypeName = PathPart
        Else
            NodeType.NodeTypeName = PathPart.Substring(0, p)
            PathPart = PathPart.Substring(p)
            While p > -1
                p = PathPart.IndexOf("]")
                IndexContent = PathPart.Substring(1, p - 1)
                If Integer.TryParse(IndexContent, Index) Then
                    If NodeType.NodeIndex <> -1 Then
                        Throw New Exception($"In {XPath} index can only be set once")
                    End If
                    NodeType.NodeIndex = Index
                Else
                    IndexParts = IndexContent.Split("="c).ToList()
                    If IndexParts.Count <> 2 Then
                        Throw New Exception($"Index must be integer or Name=Value, found {IndexContent}")
                    End If
                    NodeType.PropertyPairs.Add(New cPropertyPair() With {.PropertyName = IndexParts(0), .PropertyValue = IndexParts(1)})
                End If
                PathPart = PathPart.Substring(p + 1)
                p = PathPart.IndexOf("[")
            End While
        End If
        Return NodeType
    End Function
 
    Private Function GetParentType(ByRef XPath As String) As cParentType
        Dim p As Integer = XPath.IndexOf("/")
        Dim PathPart As String = ""
        Dim ParentType As New cParentType()
        Dim PathSubPart As String
        Dim PathSubParts As List(Of String)
 
        If p = -1 Then
            PathPart = XPath
            XPath = ""
        Else
            PathPart = XPath.Substring(0, p)
            XPath = XPath.Substring(p)
        End If
 
        p = PathPart.IndexOf("[")
        If p > -1 Then
            PathPart = PathPart.Substring(p)
            While p > -1
                p = PathPart.IndexOf("]")
                If p = -1 Then
                    Throw New Exception($"Unmatched [ in {PathPart}")
                End If
                PathSubPart = PathPart.Substring(1, p - 1)
                PathSubParts = PathSubPart.Split("="c).ToList()
                If PathSubParts.Count <> 2 Then
                    Throw New Exception($"Expected Type=Value or Level=Value, found {PathSubPart}")
                End If
                Select Case PathSubParts(0).ToUpper()
                    Case "TYPE" : ParentType.ParentType = PathSubParts(1)
                    Case "LEVEL" : If Not Integer.TryParse(PathSubParts(1), ParentType.ParentLevel) Then
                            Throw New Exception($"Level must be an integer, found {PathSubPart}")
                        End If
                    Case Else
                        Throw New Exception($"Expected Type or Level, found {PathSubPart}")
                End Select
                PathPart = PathPart.Substring(p + 1)
                p = PathPart.IndexOf("[")
            End While
        End If
        Return ParentType
 
    End Function
 
    ' Return all matching nodes
    Private Function FindAllMatchingNodes(NodeType As cNodeType, fe As FrameworkElement) As List(Of FrameworkElement)
        Dim ChildFEs As New List(Of FrameworkElement)()
 
        For Each Child As DependencyObject In TreeHelper.FindChildrenOfType(TreeHelper.GetUltimateParent(fe), NodeType.NodeTypeName, IsRecursive:=True)
            If PropertyMatch(Child, NodeType) Then
                ChildFEs.Add(Child)
            End If
        Next
        If NodeType.NodeIndex = -1 Then
            Return ChildFEs
        Else
            If ChildFEs.Count <= NodeType.NodeIndex Then
                Throw New IndexOutOfRangeException($"Only {ChildFEs.Count} {NodeType.NodeTypeName} were found. Index {NodeType.NodeIndex} is out of range")
            End If
            Return New List(Of FrameworkElement) From {ChildFEs(NodeType.NodeIndex)}
        End If
    End Function
 
    ' Return first immediate matching child node
    Private Function FindMatchingNode(NodeType As cNodeType, ParentFE As FrameworkElement) As FrameworkElement
 
        Dim ChildFEs As New List(Of FrameworkElement)()
        For Each Child As FrameworkElement In TreeHelper.FindChildrenOfType(ParentFE, NodeType.NodeTypeName, IsRecursive:=False)
            If PropertyMatch(Child, NodeType) Then
                ChildFEs.Add(Child)
            End If
        Next
        If NodeType.NodeIndex = -1 Then
            Return ChildFEs(0)
        Else
            If ChildFEs.Count <= NodeType.NodeIndex Then
                Throw New XamlParseException($"Only {ChildFEs.Count} {NodeType.NodeTypeName} were found. Index {NodeType.NodeIndex} is out of range")
            End If
            Return ChildFEs(NodeType.NodeIndex)
        End If
    End Function
 

    Private Function FindParent(ChildFE As FrameworkElement, ParentType As cParentType) As FrameworkElement
 
        Dim Counter As Integer = 0
        Dim ParentFE As FrameworkElement = ChildFE
 
        While VisualTreeHelper.GetParent(ParentFE) IsNot Nothing
            ParentFE = VisualTreeHelper.GetParent(ParentFE)
            If String.IsNullOrEmpty(ParentType.ParentType) OrElse ParentFE.GetType().Name.Equals(ParentType.ParentType, StringComparison.CurrentCultureIgnoreCase) Then
                Counter += 1
            End If
            If Counter >= ParentType.ParentLevel Then
                Return ParentFE
            End If
        End While
        Return Nothing
    End Function
 
    Private Function PropertyMatch(Node As FrameworkElement, NodeType As cNodeType) As Boolean
        If NodeType.PropertyPairs.Count = 0 Then Return True
 
        Dim Value As Object
 
        For Each PropertyPair As cPropertyPair In NodeType.PropertyPairs
            If PropertyPair.PropertyName.Contains(".") Then
                Value = GetAttachedPropertyValue(Node, PropertyPair)
            Else
                Value = GetPropertyValue(Node, PropertyPair)
            End If
            If Not Value.Equals(PropertyPair.PropertyValue) Then
                Return False
            End If
        Next
        Return True
    End Function
 
    Private Function GetAttachedPropertyValue(Node As FrameworkElement, PropertyPair As cPropertyPair) As Object
 
        Dim ParentTypeName As String = PropertyPair.PropertyName.Split("."c)(0)
        Dim PropertyName As String = PropertyPair.PropertyName.Split("."c)(1)
        Dim Parent As FrameworkElement = Nothing
        Dim DP As DependencyProperty
        Dim a As Assembly = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(Function(ra) ra.GetName().Name = "PresentationFramework")
 
        Parent = a.CreateInstance("System.Windows.Controls." & ParentTypeName)
        If Parent Is Nothing Then
            Throw New Exception($"Attached Property {PropertyPair.PropertyName} - invalid class {ParentTypeName}")
        End If
        Dim FI As FieldInfo
        FI = Parent.GetType().GetField(PropertyName & "Property", BindingFlags.Static Or BindingFlags.Public Or BindingFlags.NonPublic)
        If FI Is Nothing Then
            Throw New Exception($"Attached Property {PropertyPair.PropertyName} - invalid property {PropertyName}")
        End If
 
        DP = DirectCast(FI.GetValue(Node), DependencyProperty)
        Return Node.GetValue(DP).ToString()
    End Function
 
    Private Function GetPropertyValue(Node As FrameworkElement, PropertyPair As cPropertyPair) As Object
 
        Dim PI As PropertyInfo = Node.GetType().GetProperty(PropertyPair.PropertyName)
        If PI Is Nothing Then
            Throw New Exception($"Property {PropertyPair.PropertyName} does not exist on control {Node.GetType().Name}")
        End If
 
        Return PI.GetValue(Node).ToString()
    End Function
 
    Private Function CreateBinding(Source As FrameworkElement, Path As String, Target As FrameworkElement, TargetProperty As DependencyProperty) As Binding
 
        Dim b As New Binding(Path)
        b.Source = Source
        b.Converter = Converter
        b.ConverterParameter = ConverterParameter
        Target.SetBinding(TargetProperty, b)
        Return b
 
    End Function
End Class

--------------------------- Helper functions -----------------------

Imports System.Windows
 
Public Class TreeHelper
 
    Public Shared Function GetUltimateParent(fe As DependencyObject) As DependencyObject
 
        Dim Parent As DependencyObject = fe
        Dim Child As DependencyObject
        Do
            Child = Parent
            Parent = GetParent(Child)
        Loop While Parent IsNot Nothing
        Return Child
    End Function
 
    Public Shared Function GetParent(child As DependencyObject) As DependencyObject
        Dim parent As DependencyObject
 
        parent = Windows.Media.VisualTreeHelper.GetParent(child)
        If parent Is Nothing Then
            parent = LogicalTreeHelper.GetParent(child)
        End If
        If parent Is Nothing Then
            parent = TryCast(child, FrameworkElement).Parent
        End If
        Return parent
    End Function
 
    Public Shared Function GetParentOfType(Of T As Windows.DependencyObject)(depObj As Windows.DependencyObject) As T
        Dim child As DependencyObject
        Dim parent As DependencyObject = depObj
 
        If TypeOf depObj Is T Then Return depObj
 
        Do
            child = parent
            parent = Windows.Media.VisualTreeHelper.GetParent(child)
            If parent Is Nothing Then
                parent = LogicalTreeHelper.GetParent(child)
            End If
            If parent Is Nothing Then
                parent = TryCast(child, FrameworkElement).Parent
            End If
        Loop While (parent IsNot Nothing And TypeOf parent IsNot T)
 
        Return DirectCast(parent, T)
    End Function
 
    Public Shared Function FindChildrenOfType(Of T As Windows.DependencyObject)(depObj As Windows.DependencyObject, Optional IsRecursive As Boolean = False) As Collections.Generic.List(Of T)
        Dim Children As New Collections.Generic.List(Of T)
        If depObj IsNot Nothing Then
            For i As Integer = 0 To Windows.Media.VisualTreeHelper.GetChildrenCount(depObj) - 1
                Dim child As Windows.DependencyObject = Windows.Media.VisualTreeHelper.GetChild(depObj, i)
                If child IsNot Nothing AndAlso TypeOf child Is T Then
                    Children.Add(DirectCast(child, T))
                End If
                If IsRecursive Then
                    Children.AddRange(FindChildrenOfType(Of T)(child, IsRecursive:=IsRecursive))
                End If
            Next
        End If
        Return Children
    End Function
 
    Public Shared Function FindChildrenOfType(depObj As Windows.DependencyObject, T As String, Optional IsRecursive As Boolean = False) As List(Of DependencyObject)
        Dim Children As New List(Of DependencyObject)
        If depObj IsNot Nothing Then
            For Each o As Object In LogicalTreeHelper.GetChildren(depObj) ' Can return non dependency objects
                If TypeOf o Is DependencyObject Then
                    Dim Child As DependencyObject = DirectCast(o, DependencyObject)
                    If Child.GetType().Name.Equals(T, StringComparison.CurrentCultureIgnoreCase) Then
                        Children.Add(Child)
                    End If
                    If IsRecursive Then
                        Children.AddRange(FindChildrenOfType(Child, T, IsRecursive:=IsRecursive))
                    End If
                End If
            Next
        End If
        Return Children
    End Function
 
End Class
 

---------------------- Sample XAML -----------------

<Window x:Class="MainWindow"
        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:XPathMarkupExtension"
        mc:Ignorable="d"
        d:DataContext="{d:DesignData local:MainWindow}"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="auto"/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="200"/>
            <ColumnDefinition Width="200"/>
        </Grid.ColumnDefinitions>
       
        <TextBlock Grid.Row="0" Grid.Column="0" Text="{local:XBinding Path=Text, XPath='//TextBox[0]'}"/>
        <Grid Grid.Row="0" Grid.Column="1">
            <TextBlock Grid.Row="0" Text="{local:XBinding Path=Text.Length, XPath='..[Type=Grid][Level=2]//TextBox[1]'}"/>
        </Grid>
        <StackPanel Grid.Row="1" Orientation="Horizontal">
            <TextBox Text="Hello" Width="100"/>
            <TextBox Text="Mother" Width="100"/>
        </StackPanel>
    </Grid>
</Window>

 



No comments:

Post a Comment