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.
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
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
Public Overrides Function ProvideValue(serviceProvider As IServiceProvider) As Object
Dim pvt As IProvideValueTarget = DirectCast(serviceProvider.GetService(GetType(IProvideValueTarget)), IProvideValueTarget)
TargetFE = pvt.TargetObject
TopFE = TreeHelper.GetUltimateParent(TargetFE)
Return Me
Else
' Wait for parent to finish loading
AddHandler TopFE.Loaded, AddressOf Parent_Loaded
End Function
Private Sub Parent_Loaded(sender As Object, e As RoutedEventArgs)
CreateBinding(SourceFE, Path, TargetFE, dp)
RemoveHandler TopFE.Loaded, AddressOf Parent_Loaded
Private Function FollowPath(XPath As String) As FrameworkElement
Dim OriginalPath As String = XPath
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
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}")
Return ChildFE
Private Function GetNodeType(ByRef XPath As String) As cNodeType
Dim p As Integer = XPath.IndexOf("/")
Dim NodeType As New cNodeType()
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("[")
NodeType.NodeTypeName = PathPart
Else
NodeType.NodeTypeName = PathPart.Substring(0, p)
PathPart = PathPart.Substring(p)
While p > -1
If Integer.TryParse(IndexContent, Index) Then
If NodeType.NodeIndex <> -1 Then
Throw New Exception($"In {XPath} index can only be set once")
NodeType.NodeIndex = Index
Else
IndexParts = IndexContent.Split("="c).ToList()
Throw New Exception($"Index must be integer or Name=Value, found {IndexContent}")
NodeType.PropertyPairs.Add(New cPropertyPair() With {.PropertyName = IndexParts(0), .PropertyValue = IndexParts(1)})
PathPart = PathPart.Substring(p + 1)
p = PathPart.IndexOf("[")
End If
Return NodeType
Private Function GetParentType(ByRef XPath As String) As cParentType
Dim p As Integer = XPath.IndexOf("/")
Dim ParentType As New cParentType()
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("[")
PathPart = PathPart.Substring(p)
While p > -1
Throw New Exception($"Unmatched [ in {PathPart}")
PathSubPart = PathPart.Substring(1, p - 1)
PathSubParts = PathSubPart.Split("="c).ToList()
Throw New Exception($"Expected Type=Value or Level=Value, found {PathSubPart}")
Select Case PathSubParts(0).ToUpper()
Throw New Exception($"Level must be an integer, found {PathSubPart}")
Case Else
Throw New Exception($"Expected Type or Level, found {PathSubPart}")
PathPart = PathPart.Substring(p + 1)
p = PathPart.IndexOf("[")
End If
Return ParentType
End Function
' Return all matching nodes
Private Function FindAllMatchingNodes(NodeType As cNodeType, fe As FrameworkElement) As List(Of FrameworkElement)
For Each Child As DependencyObject In TreeHelper.FindChildrenOfType(TreeHelper.GetUltimateParent(fe), NodeType.NodeTypeName, IsRecursive:=True)
ChildFEs.Add(Child)
End If
Next
If NodeType.NodeIndex = -1 Then
Return ChildFEs
If ChildFEs.Count <= NodeType.NodeIndex Then
Throw New IndexOutOfRangeException($"Only {ChildFEs.Count} {NodeType.NodeTypeName} were found. Index {NodeType.NodeIndex} is out of range")
Return New List(Of FrameworkElement) From {ChildFEs(NodeType.NodeIndex)}
End Function
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
Dim Counter As Integer = 0
While VisualTreeHelper.GetParent(ParentFE) IsNot Nothing
ParentFE = VisualTreeHelper.GetParent(ParentFE)
Counter += 1
End If
If Counter >= ParentType.ParentLevel Then
Return ParentFE
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
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 DP As DependencyProperty
Dim a As Assembly = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(Function(ra) ra.GetName().Name = "PresentationFramework")
Parent = a.CreateInstance("System.Windows.Controls." & ParentTypeName)
Throw New Exception($"Attached Property {PropertyPair.PropertyName} - invalid class {ParentTypeName}")
Dim FI As FieldInfo
FI = Parent.GetType().GetField(PropertyName & "Property", BindingFlags.Static Or BindingFlags.Public Or BindingFlags.NonPublic)
Throw New Exception($"Attached Property {PropertyPair.PropertyName} - invalid property {PropertyName}")
DP = DirectCast(FI.GetValue(Node), DependencyProperty)
Private Function GetPropertyValue(Node As FrameworkElement, PropertyPair As cPropertyPair) As Object
Dim PI As PropertyInfo = Node.GetType().GetProperty(PropertyPair.PropertyName)
Throw New Exception($"Property {PropertyPair.PropertyName} does not exist on control {Node.GetType().Name}")
Return PI.GetValue(Node).ToString()
Private Function CreateBinding(Source As FrameworkElement, Path As String, Target As FrameworkElement, TargetProperty As DependencyProperty) As Binding
Dim b As New Binding(Path)
b.Converter = Converter
b.ConverterParameter = ConverterParameter
Target.SetBinding(TargetProperty, b)
Return b
End Function
End Class
--------------------------- Helper functions -----------------------
Public Class TreeHelper
Public Shared Function GetUltimateParent(fe As DependencyObject) As DependencyObject
Dim Parent As DependencyObject = fe
Do
Child = Parent
Parent = GetParent(Child)
Loop While Parent IsNot Nothing
Return Child
Public Shared Function GetParent(child As DependencyObject) As DependencyObject
Dim parent As DependencyObject
parent = Windows.Media.VisualTreeHelper.GetParent(child)
parent = LogicalTreeHelper.GetParent(child)
If parent Is Nothing Then
parent = TryCast(child, FrameworkElement).Parent
Return parent
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)
parent = LogicalTreeHelper.GetParent(child)
If parent Is Nothing Then
parent = TryCast(child, FrameworkElement).Parent
Loop While (parent IsNot Nothing And TypeOf parent IsNot T)
Return DirectCast(parent, T)
Public Shared Function FindChildrenOfType(Of T As Windows.DependencyObject)(depObj As Windows.DependencyObject, Optional IsRecursive As Boolean = False) As Collections.Generic.List(Of T)
For i As Integer = 0 To Windows.Media.VisualTreeHelper.GetChildrenCount(depObj) - 1
Children.Add(DirectCast(child, T))
If IsRecursive Then
Children.AddRange(FindChildrenOfType(Of T)(child, IsRecursive:=IsRecursive))
Next
End If
Return Children
Public Shared Function FindChildrenOfType(depObj As Windows.DependencyObject, T As String, Optional IsRecursive As Boolean = False) As List(Of DependencyObject)
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)
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 Class
---------------------- Sample XAML -----------------
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>