We have a bizarre requirement on one of our new screens. It's for a XamDataGrid that needs to bind to a matrix. Normally we know the columns at run time, but not the rows. In this case we don't know the columns or the rows. There are some extra wrinkles.
- The number of rows and columns can vary at run time as the user selects different criteria.
- There is an editable column description
- Each row and column has a heading that comes from the database but is read-only
So we have two interesting new technical issues to solve. Here are the issues and some possible solutions.
- How can we clear and rebuild the column collection at run time?
- We could define 100 columns in XAML and hide the ones we don't need
- We could bind a FieldCollection via MVVM and populate it
- We could build the XamDataGrid's Fields directly in code
- How can we display the description textbox at the top of each column?
- We could place a single row XamDataGrid above the main grid and sync the scrollbars
- We could place a textbox into the column headers
- We could use a template selector to change the editors for the first row
Thinking about issue 1, the first solution seems unwieldy and we can't even guarantee that 100 columns is enough. The second solution would be great if the FieldLayout's field collection was bindable but it derives from ObservableCollection so you can't do that. Option 3 is all that's left.
Thinking about issue 2, the first solution looks good although it's not exactly what the users want. However you cannot hide the scrollbars of the XamDataGrid (HorizontalScrollbarVisibility is ignored - yet another bug). The third solution requires us to build templates in code which is not advised since FrameworkElementFactory is deprecated. Option 2 looks like our best bet.
All this could also be done with a DataPresenter but the users want it to look like a XamDataGrid, and so do I.
Let's think about the data structures. Normally we would bind to a collection of objects with properties, and indeed we could have created a class on the fly with reflection. In this case, however, we will simply use enumerations.
In our requirement the data is a collection of decimals arranged in two dimensions called Step and Range. The Ranges are displayed horizontally as columns, and the Steps are displayed vertically as rows. Just for fun, some of our users are configured to switch the axes, but the description is always on the column, so it is simply a matter of loading the data differently for them. The classes will look like this.
Public Class cStepAndRange
Public Property Rows As New ObservableCollection(Of cRow)()
Public Property Columns As New ObservableCollection(Of cColumn)()
End Class
Public Class cRow
Implements IDisposable
Public Property Header As String
Public Property Values As New List(Of Double)
Public Sub
Dispose() Implements IDisposable.Dispose
End Sub
End Class
Public Class cColumn
Implements IDisposable
Public Property Header As String
Public Property Description As String
Public Sub
Dispose() Implements IDisposable.Dispose
End Sub
End Class
Every row and column also has a heading which I will implement using a XAML coded field and the column headers (which therefore contain a read-only heading and an editable description). Most of the window's contents are generated in code the XAML is quite simple. You will notice there is a combobox that selects the number of columns, and a XamDataGrid that displays them. The XamDataGrid's Initialized event allows us to grab a reference to the grid so we can manipulate its field collection.
<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:StepAndRange"
xmlns:igDP="http://infragistics.com/DataPresenter"
mc:Ignorable="d"
DataContext="{Binding RelativeSource={RelativeSource Self}}"
Title="MainWindow" Height="450" Width="800">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal">
<TextBlock Text="Range count: "/>
<ComboBox SelectedValue="{Binding StepCount}"
SelectedValuePath="Content">
<ComboBoxItem Content="1"/>
<ComboBoxItem Content="10"/>
</ComboBox>
</StackPanel>
<igDP:XamDataGrid Grid.Row="1" DataSource="{Binding StepAndRange.Rows}" Initialized="Results_Initialized"
GroupByAreaLocation="None" ScrollingMode="Immediate">
<igDP:XamDataGrid.FieldSettings>
<igDP:FieldSettings/>
</igDP:XamDataGrid.FieldSettings>
<igDP:XamDataGrid.FieldLayoutSettings>
<igDP:FieldLayoutSettings AutoGenerateFields="False"/>
</igDP:XamDataGrid.FieldLayoutSettings>
<igDP:XamDataGrid.FieldLayouts>
<igDP:FieldLayout >
<igDP:FieldLayout.Fields>
<igDP:TextField Label="Description" Name="Header" AllowEdit="False"/>
</igDP:FieldLayout.Fields>
</igDP:FieldLayout>
</igDP:XamDataGrid.FieldLayouts>
</igDP:XamDataGrid>
</Grid>
</Window>
The code to populate our StepAndRange object is fairly simple. In reality, populating this from the database will be a challange.
Private Sub InitializeData()
StepAndRange.rows.Clear()
StepAndRange.columns.Clear()
For columnIndex As Integer = 1 To 10
StepAndRange.columns.Add(New cColumn() With
{.Description = "Desc " & columnIndex, .Header = "Range
" & columnIndex})
Next
For rowIndex As Integer = 1 To 10
Using row As New cRow()
row.Header = "Step " & rowIndex
For valueIndex As Integer = 1 To 10
row.Values.Add(rowIndex /
valueIndex)
Next
StepAndRange.rows.Add(row)
End Using
Next
End Sub
The real fun starts when we populate the columns. The first thing we do is remove all columns except the row heading that was defined in XAML. Then we iterate through the columns and build a field for each one, binding its value and building its label.
Private Sub RefreshColumns()
If StepAndRangeDataGrid Is Nothing Then Exit Sub
Dim resultFieldLayout As Infragistics.Windows.DataPresenter.FieldLayout = StepAndRangeDataGrid.FieldLayouts(0)
While resultFieldLayout.Fields.Count > 1
resultFieldLayout.Fields.RemoveAt(1)
End While
For columnIndex As Integer = 0 To StepCount - 1
Dim field As New Infragistics.Windows.DataPresenter.NumericField()
Dim valueBinding As New Binding("Values[" & columnIndex & "]")
Dim descriptionBinding As New Binding("StepAndRange.Columns[" & columnIndex & "].Description")
Dim sp As New StackPanel() With
{.Orientation = Orientation.Vertical}
Dim tb As New TextBox()
valueBinding.Mode = BindingMode.TwoWay
field.BindingType =
Infragistics.Windows.DataPresenter.BindingType.UseAlternateBinding
field.AlternateBinding =
valueBinding
field.AllowEdit = True
sp.Children.Add(New TextBlock() With {.Text =
StepAndRange.columns(columnIndex).Header})
descriptionBinding.Source = Me.DataContext
descriptionBinding.Mode = BindingMode.TwoWay
tb.SetBinding(TextBox.TextProperty, descriptionBinding)
sp.Children.Add(tb)
field.Label = sp
resultFieldLayout.Fields.Add(field)
Next
End Sub
The final result looks like this...
The entire code-behind looks like this.Imports
System.Collections.ObjectModel
Imports
System.ComponentModel
Imports
System.Runtime.CompilerServices
Class MainWindow
Inherits Window
Implements INotifyPropertyChanged
Private StepAndRangeDataGrid As Infragistics.Windows.DataPresenter.XamDataGrid
Public Class cStepAndRange
Public Property Rows As New ObservableCollection(Of cRow)()
Public Property Columns As New ObservableCollection(Of cColumn)()
End Class
Public Class cRow
Implements IDisposable
Public Property Heading As String
Public Property Values As New List(Of Double)
Public Sub
Dispose() Implements IDisposable.Dispose
End Sub
End Class
Public Class cColumn
Implements IDisposable
Public Property Heading As String
Public Property Description As String
Public Sub
Dispose() Implements IDisposable.Dispose
End Sub
End Class
Private _StepCount As String
Public Property StepCount As String
Get
Return _StepCount
End Get
Set(value As String)
_StepCount = value
NotifyPropertyChanged()
RefreshColumns()
End Set
End Property
Private _StepAndRange As New cStepAndRange
Public ReadOnly Property StepAndRange As cStepAndRange
Get
Return _StepAndRange
End Get
End Property
Private _Columns As New ObservableCollection(Of cColumn)
Public Sub New()
InitializeData()
InitializeComponent()
StepCount = "1"
End Sub
Private Sub Results_Initialized(sender As Object, e As EventArgs)
StepAndRangeDataGrid = DirectCast(sender,
Infragistics.Windows.DataPresenter.XamDataGrid)
End Sub
Private Sub InitializeData()
StepAndRange.Rows.Clear()
StepAndRange.Columns.Clear()
For columnIndex As Integer = 1 To 10
StepAndRange.Columns.Add(New cColumn() With
{.Description = "Desc " & columnIndex, .Heading = "Range
" & columnIndex})
Next
For rowIndex As Integer = 1 To 10
Using row As New cRow()
row.Heading = "Step " & rowIndex
For valueIndex As Integer = 1 To 10
row.Values.Add(rowIndex /
valueIndex)
Next
StepAndRange.Rows.Add(row)
End Using
Next
End Sub
Private Sub RefreshColumns()
If StepAndRangeDataGrid Is Nothing Then Exit Sub
Dim resultFieldLayout As Infragistics.Windows.DataPresenter.FieldLayout = StepAndRangeDataGrid.FieldLayouts(0)
While resultFieldLayout.Fields.Count > 1
resultFieldLayout.Fields.RemoveAt(1)
End While
For columnIndex As Integer = 0 To StepCount - 1
Dim field As New Infragistics.Windows.DataPresenter.NumericField()
Dim valueBinding As New Binding("Values[" & columnIndex & "]")
Dim descriptionBinding As New Binding("StepAndRange.Columns[" & columnIndex & "].Description")
Dim sp As New StackPanel() With
{.Orientation = Orientation.Vertical}
Dim tb As New TextBox()
valueBinding.Mode = BindingMode.TwoWay
field.BindingType =
Infragistics.Windows.DataPresenter.BindingType.UseAlternateBinding
field.AlternateBinding =
valueBinding
field.AllowEdit = True
sp.Children.Add(New TextBlock() With {.Text =
StepAndRange.Columns(columnIndex).Heading})
descriptionBinding.Source = Me.DataContext
descriptionBinding.Mode = BindingMode.TwoWay
tb.SetBinding(TextBox.TextProperty, descriptionBinding)
sp.Children.Add(tb)
field.Label = sp
resultFieldLayout.Fields.Add(field)
Next
End Sub
Public Event PropertyChanged As PropertyChangedEventHandler Implements INotifyPropertyChanged.PropertyChanged
Private Sub NotifyPropertyChanged(<CallerMemberName> Optional PropertyName As String = "")
RaiseEvent PropertyChanged(Me, New PropertyChangedEventArgs(PropertyName))
End Sub
End Class
------------------ Update ----------------------
My colleague tried to use this algorithm and ran into a slight problem. When he had displayed the original data and then rebuilt his data and grid, he got binding errors. I was not rebuilding my data and didn't get any binding errors. Let's modify the algorithm a bit to reflect his architecture better. Change the StepCount property slightly and split some of the code.
Private _StepCount As String
Public Property StepCount As String
Get
Return _StepCount
End Get
Set(value As String)
_StepCount = value
NotifyPropertyChanged()
ClearColumns()
InitializeData()
CreateColumns()
End Set
End Property
Private Sub InitializeData()
StepAndRange.Rows.Clear()
StepAndRange.Columns.Clear()
For columnIndex As Integer = 1 To StepCount
StepAndRange.Columns.Add(New cColumn() With
{.Description = "Desc " & columnIndex, .Heading = "Range
" & columnIndex})
Next
For rowIndex As Integer = 1 To 10
Using row As New cRow()
row.Heading = "Step " & rowIndex
For valueIndex As Integer = 1 To StepCount
row.Values.Add(rowIndex /
valueIndex)
Next
StepAndRange.Rows.Add(row)
End Using
Next
End Sub
Private Sub ClearColumns()
If StepAndRangeDataGrid Is Nothing Then Exit Sub
Dim resultFieldLayout As Infragistics.Windows.DataPresenter.FieldLayout = StepAndRangeDataGrid.FieldLayouts(0)
While resultFieldLayout.Fields.Count > 1
resultFieldLayout.Fields.RemoveAt(1)
End While
End Sub
Private Sub CreateColumns()
If StepAndRangeDataGrid Is Nothing Then Exit Sub
Dim resultFieldLayout As Infragistics.Windows.DataPresenter.FieldLayout = StepAndRangeDataGrid.FieldLayouts(0)
For columnIndex As Integer = 0 To StepCount - 1
Dim field As New Infragistics.Windows.DataPresenter.NumericField()
Dim valueBinding As New Binding("Values[" & columnIndex & "]")
Dim descriptionBinding As New Binding("StepAndRange.Columns[" & columnIndex & "].Description")
Dim sp As New StackPanel() With
{.Orientation = Orientation.Vertical}
Dim tb As New TextBox()
valueBinding.Mode = BindingMode.TwoWay
field.BindingType =
Infragistics.Windows.DataPresenter.BindingType.UseAlternateBinding
field.AlternateBinding =
valueBinding
field.AllowEdit = True
sp.Children.Add(New TextBlock() With {.Text =
StepAndRange.Columns(columnIndex).Heading})
descriptionBinding.Source = Me.DataContext
descriptionBinding.Mode = BindingMode.TwoWay
tb.SetBinding(TextBox.TextProperty, descriptionBinding)
sp.Children.Add(tb)
field.Label = sp
resultFieldLayout.Fields.Add(field)
Next
End Sub
If you run the application now we will recreate the data each time we chose a different number of ranges and we will see a binding error the second time we hit StepAndRange.Columns.Clear().
After some thought it seems the binding exists even after the column is removed from the grid. This makes sense considering the binding can be applied to more than one DependencyProperty so it clearly has a life of its own. We want to clear the binding before we remove the column from the datagrid. Change ClearColumns to do this.
Private Sub ClearColumns()
If StepAndRangeDataGrid Is Nothing Then Exit Sub
Dim resultFieldLayout As Infragistics.Windows.DataPresenter.FieldLayout = StepAndRangeDataGrid.FieldLayouts(0)
While resultFieldLayout.Fields.Count > 1
BindingOperations.ClearAllBindings(DirectCast(DirectCast(resultFieldLayout.Fields(1).Label, StackPanel).Children(1), TextBox))
resultFieldLayout.Fields.RemoveAt(1)
End While
End Sub