Thursday, May 27, 2021

Solving DataGridColumn binding with custom markup

One of the things that bugs me still about WPF is the extra effort required to bind properties of DataGridColumns. I noticed that Infragistics has written a custom markup extension to simplify this on their XamDataGrid fields and field settings ie

AllowEdit="{igDP:FieldBinding IsVendorEditable}"

and I wondered how difficult it would be to do the same thing myself for Microsoft's DataGridColumns. The challenge here is to find the data context. The DataGridColumn is not part of the VisualTree so it doesn't have a DataContext. 

The markup extension only has a constructor and a ProvideValue method. We use the constructor to save off the parameters such as Path, and I planned on finding the DataContext and creating the binding in the ProvideValue method. But ProvideValue is called while the window is still being constructed. I get access to the DataGridColumn but it is also incomplete. In particular, the private DataGridOwner property is null.

So we have to wait until the DataGridColumn is initialized but, hold on, there's no Initialized event! The DataGrid has one, so does the Window but they are what we're trying to find. We have a dilemma here. The DataGridColumn doesn't have what we need and can't tell us when it will have it. We will have to approach this from another direction.

The Application object has a list of windows, one (I assume) of which is currently being initialized. If we add an Initialized event handler to all the windows currently being initialized we can look for our DataGridColumn in all the window's DataGrids and, if we find it, create the binding using that DataGrid's data context.

Time to code. Start a new WPF Framework project in VB and call it ColumnBinding. Add a class called ColumnBinding. Our binding will assume Mode=TwoWay and UpdateSourceTrigger=PropertyChanged. Its only parameter will be the path name. Here's the class and constructor. This code will show an error in the editor.

Option Strict On
Imports System.Windows.Markup
 
Public Class ColumnBindingExtension
    Inherits Markup.MarkupExtension
 
    Public Path As String
    Public dp As DependencyProperty
    Public dc As DataGridColumn = Nothing
 
    Public Sub New(Path As String)
        Me.Path = Path
    End Sub
 
End Class

Markup extensions must implement ProvideValue so let's write that. It grabs and validates the DependencyProperty and DependencyObject and then calls SetDataContext to find initializing windows and attach event handlers. It doesn't have a value to provide right now so it returns the UnsetValue.

Public Overrides Function ProvideValue(serviceProvider As IServiceProvider) As Object
    Dim pvt As IProvideValueTarget = DirectCast(serviceProvider.GetService(GetType(IProvideValueTarget)), IProvideValueTarget)
    dc = TryCast(pvt.TargetObject, DataGridColumn)
 
    If dc Is Nothing Then
        Throw New Exception("FieldBinding can only be used on DataGridColumn")
    End If
 
    dp = DirectCast(pvt.TargetProperty, DependencyProperty)
    SetDataContext()
    Return DependencyProperty.UnsetValue
End Function

Now is the time to add SetDataContext. It looks for Windows(s) that are currently being initialized and attaches an Initialized  event handler to them. 

Private Sub SetDataContext()
    Dim DataContext As Object = Nothing
    For Each window As Window In Application.Current.Windows.OfType(Of Window).Where(Function(w) Not w.IsInitialized)
        AddHandler window.Initialized, AddressOf CompleteBinding
    Next
End Sub

The CompleteBinding method creates the required binding in code. If the window that just initialized contains our target DataColumn then the DataColumn's private DataGridOwner will be populated and we can use the DataGrid's DataContext as our binding's Source.

Private Sub CompleteBinding(sender As Object, e As EventArgs)
    Dim dg As DataGrid = TryCast(dc.GetType().GetProperty("DataGridOwner", BindingFlags.Instance Or BindingFlags.NonPublic).GetValue(dc), DataGrid)
    If dg IsNot Nothing Then
        ' Create binding
        Dim b As New Binding(Path)
        b.Source = dg.DataContext
        b.Mode = BindingMode.TwoWay
        b.UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged
 
        BindingOperations.SetBinding(dc, dp, b)
    End If
    RemoveHandler DirectCast(sender, Window).Initialized, AddressOf CompleteBinding
End Sub

Now let's look at how we would consume this markup extension. We will create a small DataGrid and a combobox. The font size of one of the columns will be bound to the selected item in the combobox using our markup extension.

Change MainWindow.xaml to look like this. Our markup extension is in bold.

<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:FieldBinding"
        DataContext="{Binding RelativeSource={RelativeSource Self}}"
        mc:Ignorable="d"
       
        Title="MainWindow" Height="450" Width="800">
    <StackPanel Orientation="Vertical">
        <ComboBox ItemsSource="{Binding FontSizes}" SelectedItem="{Binding MyFontSize}" Width="100"/>
 
        <DataGrid ItemsSource="{Binding Items}" AutoGenerateColumns="False">
            <DataGrid.Columns>
                <DataGridTextColumn Header="Pet" Binding="{Binding Description}" FontSize="{local:ColumnBinding MyFontSize}"/>
            </DataGrid.Columns>
        </DataGrid>
    </StackPanel>
</Window>

The code in MainWindow.xaml.vb is trivial. It defines some classes and properties and implements INotifyPropertyChanged.

Imports System.ComponentModel
 
Class MainWindow
    Implements INotifyPropertyChanged
 
    Public Class cItem
        Public Property Description As String
    End Class
 
    Private _Items As New List(Of cItem) From
        {
            New cItem() With {.Description = "Dog"},
            New cItem() With {.Description = "Cat"}
        }
 
    Public ReadOnly Property Items As List(Of cItem)
        Get
            Return _Items
        End Get
    End Property
 
    Private _FontSizes As New List(Of Double) From {10, 12, 14, 16, 20}
    Public ReadOnly Property FontSizes As List(Of Double)
        Get
            Return _FontSizes
        End Get
    End Property
 
    Private _MyFontSize As Double = 10
    Public Property MyFontSize As Double
        Get
            Return _MyFontSize
        End Get
        Set(value As Double)
            SetProperty(_MyFontSize, value)
        End Set
    End Property
 
    Public Event PropertyChanged As System.ComponentModel.PropertyChangedEventHandler Implements System.ComponentModel.INotifyPropertyChanged.PropertyChanged
 
    Public Function SetProperty(Of T)(ByRef storage As T, value As T, <System.Runtime.CompilerServices.CallerMemberName> Optional PropertyName As String = Nothing) As Boolean
        If Object.Equals(storage, value) Then Return False
        storage = value
        NotifyPropertyChanged(PropertyName)
        Return True
    End Function
 
    Public Sub NotifyPropertyChanged(<System.Runtime.CompilerServices.CallerMemberName> Optional PropertyName As String = Nothing)
        If PropertyName.StartsWith("set_", System.StringComparison.OrdinalIgnoreCase) Then
            PropertyName = PropertyName.Substring(4)
        End If
        RaiseEvent PropertyChanged(Me, New System.ComponentModel.PropertyChangedEventArgs(PropertyName))
    End Sub
 
End Class

When you first run the application you see this.
  
Text is displayed in 10pt font


After selecting 16, the font size changes

As you can see, the ColumnBinding custom markup extension was able to create the required binding without using proxy elements etc.

No comments:

Post a Comment