Friday, July 23, 2021

Even WrapPanel

I have a screen that has 16 buttons at the bottom and at our minimum supported resolution of 1024x768 there is not enough room for them even when arranged in two rows. I want a panel that will grow in height to accommodate all the buttons but the standard WrapPanel looks nasty because it crams as much into the first row before wrapping around. This doesn't look nice and causes the buttons to jump around a lot as the page is resized.

I thought it would be good to have a version of a WrapPanel that tries to keep the number of controls (buttons) the same in each row. ie. If the panel is wide enough for 10 buttons but it contains 16 it puts 8 on each row instead of 10 and 6.

I had to learn about Measure and Arrange, and this is what I came up with.

The MeasureOverride method determines the number of rows I need, then determines how many controls should be on each row to be as even as possible. It asks for enough space to display the controls in this layout. Note it assumes all the controls are the same size, because math.

The ArrangeOverride method does the same thing, but this time it also arranges each control in the layout.

    Imports System.Windows
    Imports System.Windows.Controls
 
    Public Class EvenWrapPanel
        Inherits WrapPanel
 
        Public Sub New()
            Me.Orientation = Orientation.Horizontal
            Me.HorizontalAlignment = HorizontalAlignment.Left
        End Sub
 
        Protected Overrides Function MeasureOverride(constraint As Size) As Size
            Dim MaxHCount As Double
            Dim VCount As Double
            Dim HCount As Double
 
            If Children.Count = 0 Or constraint.Width = 0 Then
                Return MyBase.MeasureOverride(constraint)
            End If
 
            For Each child As UIElement In Me.Children
                child.Measure(constraint)
            Next
 
            MaxHCount = Math.Floor(constraint.Width / Children(0).DesiredSize.Width)
            VCount = Math.Ceiling(Children.Count / MaxHCount)
            HCount = Math.Ceiling(Children.Count / VCount)
            Return New Size(Children(0).DesiredSize.Width * HCount, Children(0).DesiredSize.Height * VCount)
        End Function
 
        Protected Overrides Function ArrangeOverride(arrangeSize As Size) As Size
            Dim MaxHCount As Double
            Dim VCount As Double
            Dim HCount As Double
 
            If Children.Count = 0 Or arrangeSize.Width = 0 Then
                Return MyBase.ArrangeOverride(arrangeSize)
            End If
 
            MaxHCount = Math.Floor(arrangeSize.Width / Children(0).DesiredSize.Width)
            VCount = Math.Ceiling(Children.Count / MaxHCount)
            HCount = Math.Ceiling(Children.Count / VCount)
 
            For Each child As UIElement In Me.Children
                Dim x As Integer = Convert.ToInt32(Children.IndexOf(child) Mod HCount)
                Dim y As Integer = Convert.ToInt32(Math.Floor(Children.IndexOf(child) / HCount))
                child.Arrange(New Rect(New Point(x * child.DesiredSize.Width, y * child.DesiredSize.Height), child.DesiredSize))
            Next
 
            Return New Size(Children(0).DesiredSize.Width * HCount, Children(0).DesiredSize.Height * VCount)
        End Function
    End Class

Here is a comparison of the two panels. The background colors are to show the dimensions of the controls.

Layout at approximately 1280 pixels width

Layout at approximately 800 pixels width

Here's the XAML that consumes the EvenWrapPanel.

<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:EvenWrapPanel"
        mc:Ignorable="d"
        Title="Even Wrap Panel" Height="450" Width="800">
    <Window.Resources>
        <Style TargetType="Button">
            <Setter Property="Width" Value="100"/>
            <Setter Property="Height" Value="20"/>
            <Setter Property="Margin" Value="2"/>
        </Style>
    </Window.Resources>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="auto"/>
        </Grid.RowDefinitions>
        <Grid Grid.Row="1">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"/>
                <ColumnDefinition Width="auto"/>
                <ColumnDefinition Width="auto"/>
            </Grid.ColumnDefinitions>
            <local:EvenWrapPanel Grid.Column="0" Background="AliceBlue" >
                <Button Content="Button1"/>
                <Button Content="Button1"/>
                <Button Content="Button1"/>
                <Button Content="Button1"/>
                <Button Content="Button1"/>
                <Button Content="Button1"/>
                <Button Content="Button1"/>
                <Button Content="Button1"/>
                <Button Content="Button1"/>
                <Button Content="Button1"/>
                <Button Content="Button1"/>
                <Button Content="Button1"/>
                <Button Content="Button1"/>
                <Button Content="Button1"/>
            </local:EvenWrapPanel>
            <TextBlock Grid.Column="1" Text="Status Message" Margin="10" Background="AntiqueWhite"/>
            <StackPanel Grid.Column="2" Orientation="Vertical">
                <Button Content="ButtonA"/>
                <Button Content="ButtonA"/>
            </StackPanel>
        </Grid>
    </Grid>
</Window>
 

Friday, July 2, 2021

Dynamically creating fields in a XamDataGrid

 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.

  1. The number of rows and columns can vary at run time as the user selects different criteria.
  2. There is an editable column description
  3. 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.
  1. 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
  2. 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