I am writing a program that requires information provided in a grid-like format, with user specifying both the number of columns (that I call tracks for this purpose) and rows (that I call segments).
For that I employed one DataGrid that will have the number of columns (tracks) and 1 row, and each of these cells should hold the number of segments for each track. Once this information is provided, it will populate a second DataGrid with the number of columns and rows defined for each of them. This means the columns may have different number of rows.
See these 2 pictures from Excel to illustrate what I am after, where I first define the number of columns and add 1 row so the number of rows of the second grid can be specified:
And then I expect a second grid with the added rows, ready for further user input which is what I will use for further processing. I added borders just so you see that these are the usable cells for each track (CanUserAddRows is False in the XAML, so the user needs to specify the exact numbers in the 1st datagrid as they will guide my loops and I can't have empty cells).
Please check my minimum working sample, with XAML and C# codes below:
<Window x:Class="WpfApp1.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:WpfApp1"
mc:Ignorable="d"
Title="MainWindow" Height="350" Width="500">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="204*"/>
<ColumnDefinition Width="69*"/>
<ColumnDefinition Width="227*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="40"/>
<RowDefinition Height="60"/>
<RowDefinition Height="40*"/>
<RowDefinition Height="160*"/>
</Grid.RowDefinitions>
<Label Content="Number of tracks" Grid.Row="0" Grid.Column="0"></Label>
<TextBox x:Name="txt_tracks" Grid.Row="0" Grid.Column="1" Width="50" Height="20"/>
<Button x:Name="bt_settracks" Grid.Row="0" Grid.Column="2" Content="Set tracks" Width="100" Height="20" Click="bt_settracks_Click"/>
<ScrollViewer Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
<DataGrid x:Name="dgrid_tracks" CanUserAddRows="False"></DataGrid>
</ScrollViewer>
<Button x:Name="bt_setsegments" Content="Set segments" Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" Width="100" Height="20" Click="bt_setsegments_Click"/>
<ScrollViewer Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="3" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
<DataGrid x:Name="dgrid_segments" CanUserAddRows="False"></DataGrid>
</ScrollViewer>
</Grid>
</Window>
using System;
using System.Collections.ObjectModel;
using System.Data;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
namespace WpfApp1
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
public class dTracks
{
public ushort rowTracks { get; set; }
}
private void Create_Headers(DataGrid dgrid, ushort NUM_TRACKS)
{
dgrid.ItemsSource = null;
dgrid.Columns.Clear();
for (ushort i = 0; i < NUM_TRACKS; i++)
{
DataGridTextColumn newtrack = new DataGridTextColumn();
newtrack.Header = "Track " + i.ToString();
newtrack.Binding = new Binding("rowTracks");
newtrack.IsReadOnly = false;
newtrack.Width = 100;
dgrid.Columns.Add(newtrack);
}
}
private void Set_Tracks(DataGrid dgrid, ushort NUM_TRACKS)
{
Create_Headers(dgrid, NUM_TRACKS);
ObservableCollection<dTracks> tracks = new ObservableCollection<dTracks>() { new dTracks() { rowTracks = 0 } };
dgrid.ItemsSource = tracks;
}
private void Set_Segments(DataGrid dg_tracks, DataGrid dg_segments, ushort NUM_TRACKS)
{
Create_Headers(dg_segments, NUM_TRACKS);
MessageBox.Show(dg_tracks.Columns.Count.ToString());
MessageBox.Show(dg_tracks.Items.Count.ToString());
for (int j = 0; j < dg_tracks.Columns.Count; j++)
{
for (int i = 0; i < dg_tracks.Items.Count - 1; i++)
{
String s = (dg_tracks.Items[i] as DataRowView).Row.ItemArray[j].ToString();
MessageBox.Show(s);
}
}
}
private void bt_settracks_Click(object sender, RoutedEventArgs e) => Set_Tracks(dgrid_tracks, Convert.ToUInt16(txt_tracks.Text));
private void bt_setsegments_Click(object sender, RoutedEventArgs e) => Set_Segments(dgrid_tracks, dgrid_segments, Convert.ToUInt16(txt_tracks.Text));
}
}
When you run it, you will already see some problems:
Along the function Set_Segments() I attempt to print some values just for debugging. The number of columns for the 1st DataGrid shows 1 more column then it should, and then I try to cycle through the cells to get their values and add them to the second DataGrid (to replicate the second picture), but these values are not coming and the MessageBox in the loop doesn't even come up with anything, not even an error.
Evidently there is something missing/wrong in the C# part, but so far I couldn't find any sample that considers different numbers of rows for each column, as most of the demos populate the datagrid with objects already in memory.
Maybe you guys can demonstrate how to solve those points mentioned above.
The main idea is to use a horizontally oriented ListBox
for the configuration table (in place of the single-row DataGrid
) and a DataGrid
for the actual column view. For this view we have to deisable the cell and its borders to make it appear like a column-based DataGrid
.
We basically fill a DataTable
with NULL
to mark a non-data cell:
Track 0 | Track 1 |
---|---|
empty | empty |
NULL | empty |
NULL | empty |
All cells that contain a null
value are not rendered. The advantage over using a simple ListBox
per column is that you get the sort and reorder functionality of the DataGrid
control.
The following example shows how you can achieve the desired visual presentation by using a value converter and defineing the DataGrid.CellStyle
.
Microsoft learn: Data binding overview (WPF .NET)
Microsoft learn: Data Templating Overview
MainWindow.xaml
<Window x:Name="Root">
<StackPanel x:Name="RootPanel">
<!-- Click to add a new column definition -->
<Button Content="Add column"
Click="OnCreateNewColumnDefinitionButtonClicked" />
<ListBox ItemsSource="{Binding ElementName=Root, Path=TableColumnInfos, Mode=OneTime}">
<!-- Change list box orientation to horizontal -->
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type local:TableColumnInfo}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Grid.Column="0"
Text="Column name:" />
<TextBox Grid.Row="0"
Grid.Column="1"
Text="{Binding ColumnName}" />
<TextBlock Grid.Row="1"
Grid.Column="0"
Text="Row count:" />
<TextBox Grid.Row="1"
Grid.Column="1"
Text="{Binding RowCount}" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Generate the table from the collected data -->
<Button Content="Generate table"
Click="OnGenerateTableButtonClicked" />
<DataGrid ItemsSource="{Binding ElementName=Root, Path=ColumnTable}"
GridLinesVisibility="None"
ColumnReordered="OnDataGridColumnReordered"
CanUserAddRows="False">
<DataGrid.CellStyle>
<Style TargetType="DataGridCell">
<Style.Resources>
<local:CellBorderThicknessConverter x:Key="CellBorderThicknessConverter" />
</Style.Resources>
<!-- Remove the cell border and disable the cell if it is a non-data cell -->
<Setter Property="BorderThickness">
<Setter.Value>
<MultiBinding Converter="{StaticResource CellBorderThicknessConverter}"
ConverterParameter="1">
<Binding RelativeSource="{RelativeSource AncestorType=DataGrid}"
Path="Columns" />
<Binding RelativeSource="{RelativeSource Self}" />
</MultiBinding>
</Setter.Value>
</Setter>
<Setter Property="BorderBrush"
Value="Black" />
</Style>
</DataGrid.CellStyle>
</DataGrid>
</StackPanel>
</Window>
MainWindow.xaml.cs
partial class MainWindow : Window
{
public ObservableCollection<TableColumnInfo> TableColumnInfos { get; }
public DataTable ColumnTable
{
get => (DataTable)GetValue(ColumnTableProperty);
set => SetValue(ColumnTableProperty, value);
}
public static readonly DependencyProperty ColumnTableProperty = DependencyProperty.Register(
"ColumnTable",
typeof(DataTable),
typeof(MainWindow),
new PropertyMetadata(default));
public MainWindow()
{
InitializeComponent();
this.TableColumnInfos = new ObservableCollection<TableColumnInfo>();
}
private void OnGenerateTableButtonClicked(object sender, RoutedEventArgs e)
{
var dataTable = new DataTable();
int maxRowCount = 0;
foreach (TableColumnInfo columnInfo in this.TableColumnInfos)
{
var dataColumn = new DataColumn(columnInfo.ColumnName, typeof(string));
dataTable.Columns.Add(dataColumn);
if (columnInfo.RowCount > maxRowCount)
{
maxRowCount = columnInfo.RowCount;
}
}
for (int rowIndex = 0; rowIndex < maxRowCount; rowIndex++)
{
DataRow row = dataTable.NewRow();
for (int columnIndex = 0; columnIndex < dataTable.Columns.Count; columnIndex++)
{
// Set cells that are non-data cells to NULL
// so that they are later disabled (invisible and disabled for user input)
TableColumnInfo columnInfo = this.TableColumnInfos[columnIndex];
string? cellValue = rowIndex >= columnInfo.RowCount
? default
: string.Empty;
row.SetField(columnIndex, cellValue);
}
dataTable.Rows.Add(row);
}
this.ColumnTable = dataTable;
}
private void OnDataGridColumnReordered(object sender, DataGridColumnEventArgs e)
{
var dataGrid = (DataGrid)sender;
dataGrid.Items.Refresh();
}
private void OnCreateNewColumnDefinitionButtonClicked(object sender, RoutedEventArgs e)
=> this.TableColumnInfos.Add(new TableColumnInfo());
}
CellBorderThicknessConverter.cs
public class CellBorderThicknessConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
DataGridCell dataGridCell = values
.OfType<DataGridCell>()
.First();
IList<DataGridColumn> dataGridColumns = values
.OfType<IList<DataGridColumn>>()
.First();
// Data source is not a DataTable
if (dataGridCell.DataContext is not DataRowView dataRowView)
{
if (dataGridCell.DataContext.ToString().Contains("NewItemPlaceholder"))
{
return Binding.DoNothing;
}
throw new NotSupportedException("Only DataTable supported");
}
double borderThickness = double.TryParse(parameter as string, out double thickness) ? thickness : 1;
int columnDataIndex = dataGridColumns.IndexOf(dataGridCell.Column);
int columnDisplayIndex = dataGridCell.Column.DisplayIndex;
DataRow currentRow = dataRowView.Row;
bool isCurrentCellVisible = IsCellVisible(columnDataIndex, currentRow);
if (!isCurrentCellVisible)
{
// Collapse cell so that it is no longer editable
dataGridCell.Visibility = Visibility.Collapsed;
// Remove the grid lines around this cell
return new Thickness(0);
}
// The current cell is visible.
// Next is to determine if we have to draw the left border and top border.
// Left/top borders are only drawn if the previous cell/row is hidden
// to prevent doubled lines. To know the state of the previous cell/row
// we have to inspect their values.
// The DataGrid differentiates between column dispaly index (user can rearrange columns)
// and the real underlying data column index.
bool isCurrentCellFirstColumn = columnDisplayIndex == 0;
DataTable sourceTable = dataRowView.DataView.Table;
int rowIndex = sourceTable.Rows.IndexOf(currentRow);
bool isCurrentCellFirstRow = rowIndex == 0;
double leftBorderThickness = borderThickness;
double topBorderThickness = borderThickness;
if (!isCurrentCellFirstColumn)
{
int previousDisplayColumnIndex = columnDisplayIndex - 1;
int previousDataColumnIndex = GetDataColumnIndexFromDisplayColumnIndex(previousDisplayColumnIndex, dataGridColumns);
bool isCurrentRowPreviousCellVisible = IsCellVisible(previousDataColumnIndex, currentRow);
if (isCurrentRowPreviousCellVisible)
{
leftBorderThickness = 0;
}
}
if (!isCurrentCellFirstRow)
{
int previousRowIndex = rowIndex - 1;
DataRow previousRow = sourceTable.Rows[previousRowIndex];
bool isPreviousRowCurrentCellVisible = IsCellVisible(columnDataIndex, previousRow);
if (isPreviousRowCurrentCellVisible)
{
topBorderThickness = 0;
}
}
return new Thickness(leftBorderThickness, topBorderThickness, borderThickness, borderThickness);
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
private bool IsCellVisible(int columnIndex, DataRow dataRow)
{
object cellValue = dataRow[columnIndex];
return cellValue != DBNull.Value && cellValue != default;
}
private int GetDataColumnIndexFromDisplayColumnIndex(int displayColumIndex, IList<DataGridColumn> columns)
{
for (int columnIndex = 0; columnIndex < columns.Count; columnIndex++)
{
DataGridColumn column = columns[columnIndex];
if (column.DisplayIndex == displayColumIndex)
{
return columnIndex;
}
}
throw new ArgumentOutOfRangeException(nameof(displayColumIndex));
}
}
TableColumnInfo.cs
public class TableColumnInfo : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
private int rowCount;
public int RowCount
{
get => this.rowCount;
set
{
this.rowCount = value;
OnPropertyChanged();
}
}
private string columnName;
public string ColumnName
{
get => this.columnName;
set
{
this.columnName = value;
OnPropertyChanged();
}
}
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
=> this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}