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>