Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

WPF DataGrid add separate border to every column set

Tags:

I am trying to achieve effect where each column has its own border, but yet can not find a perfectly working solution. enter image description here

This kind of look is desired but this is implemented by putting 3 borders in 3 columned Grid, which is not flexible as Grid columns and DataGrid columns are being sized sized separately

<Window x:Class="WpfApp3.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:WpfApp3" xmlns:usercontrols="clr-namespace:EECC.UserControls"
    mc:Ignorable="d"
    Title="MainWindow" Height="450" Width="800">
<Grid Background="LightGray">
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="*"/>
        <ColumnDefinition Width="*"/>
        <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>

    <Border Background="White" CornerRadius="5" BorderThickness="1" BorderBrush="Black" Margin="5"/>
    <Border Background="White" CornerRadius="5" BorderThickness="1" BorderBrush="Black" Margin="5" Grid.Column="1"/>
    <Border Background="White" CornerRadius="5" BorderThickness="1" BorderBrush="Black" Margin="5" Grid.Column="2"/>

    <DataGrid ItemsSource="{Binding Items}" ColumnWidth="*" AutoGenerateColumns="True" Padding="10" GridLinesVisibility="None" Background="Transparent" Grid.ColumnSpan="3">
        <DataGrid.Resources>
            <Style TargetType="{x:Type DataGridRow}">
                <Setter Property="Background" Value="Transparent"/>
            </Style>
            <Style TargetType="{x:Type DataGridColumnHeader}">
                <Setter Property="Background" Value="Transparent"/>
            </Style>
        </DataGrid.Resources>
    </DataGrid>
</Grid>
like image 935
mister_giga Avatar asked Sep 01 '21 13:09

mister_giga


1 Answers

This is not trivial if you want to use the DataGrid. The "problem" is that the DataGrid uses a Grid to host the cells. The cell borders are drawn using the feature of the Grid to show grid lines. You can hide the grid lines but still you have the Grid controlling the layout.
Creating the gaps you want should not come easy. The layout system of the Grid makes it an effort to implement a solution that scales well. You can extend the SelectiveScrollingGrid (the hosting panel of the DataGrid) and add the gaps when laying out the items. Knowing about DataGrid internals, I can say it is possible, but not worth the effort.

The alternative solution would be to use a ListView with a GridView as host. The problem here is that the GridView is designed to show rows as single item. You have no chance to modify margins column based. You can only adjust the content. I have not tried to modify the ListView internal layout elements or override the layout algorithm, but in context of alternative solutions I would also rule the ListView using a GridView out - but it is possible. It's not worth the effort.


Solution: Custom View

The simplest solution I can suggest is to adjust the data structure to show data column based. This way you can use a horizontal ListBox. Each item makes a column. Each column is realized as vertical ListBox. You basically have nested ListBox elements.

You would have to take care of the row mapping in order to allow selecting cells of a common row across the vertical ListBox columns.
This can be easily achieved by adding a RowIndex property to the CellItem models.

The idea is to have the horizontal ListBox display a collection of ColumnItem models. Each column item model exposes a collection of CellItem models. The CellItem items of different columns but the same row must share the same CellItem.RowIndex.
As a bonus, this solution is very easy to style. ListBox template has almost no parts compared to the significantly more complex DataGrid or the slightly more complex GridView.

To make showcasing the concept less confusing I chose to implement the grid layout as UserControl. For the sake of simplicity the logic to initialize and host the models and source collections is implemented inside this UserControl. I don't recommend this. Instantiation and hosting of the items should be outside the control e.g., inside a view model. You should add a DependencyProperty as ItemsSource for the control as data source for the internal horizontal ListBox.

Usage Example

<Window>
  <ColumnsView />
</Window>

enter image description here

  1. First create the data structure to populate the view.
    The structure is based on the type ColumnItem, which hosts a collection of CellItem items where each CellItem has a CellItem.RowIndex.
    The CellItem items of different columns, that logically form a row must share the same CellItem.RowIndex.

ColumnItem.cs

public class ColumnItem
{
  public ColumnItem(string header, IEnumerable<CellItem> items)
  {
    Header = header;
    this.Items = new ObservableCollection<CellItem>(items);
  }

  public CellItem this[int rowIndex] 
    => this.Items.FirstOrDefault(cellItem => cellItem.RowIndex.Equals(rowIndex));

  public string Header { get; }
  public ObservableCollection<CellItem> Items { get; }
}

CellItem.cs

public class CellItem : INotifyPropertyChanged
{
  public CellItem(int rowIndex, object value)
  {
    this.RowIndex = rowIndex;
    this.Value = value;
  }

  protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = "") 
    => this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));

  public event PropertyChangedEventHandler PropertyChanged;
  public int RowIndex { get; }

  private object value;
  public object Value
  {
    get => this.value;
    set
    {
      this.value = value;
      OnPropertyChanged();
    }
  }

  private bool isSelected;
  public bool IsSelected
  {
    get => this.isSelected;
    set
    {
      this.isSelected = value;
      OnPropertyChanged();
    }
  }
}
  1. Build and initialize the data structure.
    In this example this is all implemented in the UserControl itself with the intend to keep the example as compact as possible.

ColumnsView.xaml.cs

public partial class ColumnsView : UserControl
{
  public ColumnsView()
  {
    InitializeComponent();
    this.DataContext = this;

    InitializeSourceData();
  }

  public InitializeSourceData()
  {
    this.Columns = new ObservableCollection<ColumnItem>();

    for (int columnIndex = 0; columnIndex < 3; columnIndex++)
    {
      var cellItems = new List<CellItem>();
      int asciiChar = 65;

      for (int rowIndex = 0; rowIndex < 10; rowIndex++)
      {
        var cellValue = $"CellItem.RowIndex:{rowIndex}, Value: {(char)asciiChar++}";
        var cellItem = new CellItem(rowIndex, cellValue);
        cellItems.Add(cellItem);
      }

      var columnHeader = $"Column {columnIndex + 1}";
      var columnItem = new ColumnItem(columnHeader, cellItems);
      this.Columns.Add(columnItem);
    }
  }

  private void CellsHostListBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
  {
    var cellsHost = sender as Selector;
    var selectedCell = cellsHost.SelectedItem as CellItem;
    SelectCellsOfRow(selectedCell.RowIndex);
  }

  private void SelectCellsOfRow(int selectedRowIndex)
  {
    foreach (ColumnItem columnItem in this.Columns)
    {
      var cellOfRow = columnItem[selectedRowIndex];
      cellOfRow.IsSelected = true;
    }
  }

  private void ColumnGripper_DragStarted(object sender, DragStartedEventArgs e) 
    => this.DragStartX = Mouse.GetPosition(this).X;

  private void ColumnGripper_DragDelta(object sender, DragDeltaEventArgs e)
  {
    if ((sender as DependencyObject).TryFindVisualParentElement(out ListBoxItem listBoxItem))
    {
      double currentMousePositionX = Mouse.GetPosition(this).X;
      listBoxItem.Width = Math.Max(0 , listBoxItem.ActualWidth - (this.DragStartX - currentMousePositionX));
      this.DragStartX = currentMousePositionX;
    }
  }

  public static bool TryFindVisualParentElement<TParent>(DependencyObject child, out TParent resultElement)
    where TParent : DependencyObject
  {
    resultElement = null;

    DependencyObject parentElement = VisualTreeHelper.GetParent(child);

    if (parentElement is TParent parent)
    {
      resultElement = parent;
      return true;
    }

    return parentElement != null 
      ? TryFindVisualParentElement(parentElement, out resultElement) 
      : false;
  }

  public ObservableCollection<ColumnItem> Columns { get; }
  private double DragStartX { get; set; }
}
  1. Create the view using a horizontal ListView that renders it's ColumnItem source collection as a list of vertical ListBox elements.

ColumnsView.xaml

<UserControl x:Class="ColumnsView">
  <UserControl.Resources>
    <Style x:Key="ColumnGripperStyle"
         TargetType="{x:Type Thumb}">
      <Setter Property="Margin"
            Value="-2,8" />
      <Setter Property="Width"
            Value="4" />
      <Setter Property="Background"
            Value="Transparent" />
      <Setter Property="Cursor"
            Value="SizeWE" />
      <Setter Property="Template">
        <Setter.Value>
          <ControlTemplate TargetType="{x:Type Thumb}">
            <Border Background="{TemplateBinding Background}"
                  Padding="{TemplateBinding Padding}" />
          </ControlTemplate>
        </Setter.Value>
      </Setter>
    </Style>
  </UserControl.Resources>

  <!-- Column host. Displays cells of a column. -->
  <ListBox ItemsSource="{Binding Columns}">
    <ListBox.ItemsPanel>
      <ItemsPanelTemplate>
        <VirtualizingStackPanel Orientation="Horizontal" />
      </ItemsPanelTemplate>
    </ListBox.ItemsPanel>

    <ListBox.ItemTemplate>
      <DataTemplate>
        <Border Padding="4" 
                BorderThickness="1" 
                BorderBrush="Black" 
                CornerRadius="8">
          <StackPanel>
            <TextBlock Text="{Binding Header}" />

            <!-- Cell host. Displays cells of a column. -->
            <ListBox ItemsSource="{Binding Items}" 
                     BorderThickness="0" 
                     Height="150"
                     Selector.SelectionChanged="CellsHostListBox_SelectionChanged">
              <ListBox.ItemTemplate>
                <DataTemplate>
                  <TextBlock Text="{Binding Value}" />
                </DataTemplate>
              </ListBox.ItemTemplate>
          
              <ListBox.ItemContainerStyle>
                <Style TargetType="ListBoxItem">
              
                  <!-- Link item container selection to CellItem.IsSelected -->
                  <Setter Property="IsSelected" Value="{Binding IsSelected}" />
                </Style>
              </ListBox.ItemContainerStyle>
            </ListBox>
          </StackPanel>
        </Border>
      </DataTemplate>
    </ListBox.ItemTemplate>

    <ListBox.ItemContainerStyle>
      <Style TargetType="ListBoxItem">
        <Setter Property="Margin" Value="0,0,8,0" /> <!-- Define the column gap -->    
        <Setter Property="Template">
          <Setter.Value>
            <ControlTemplate TargetType="ListBoxItem">
              <Grid>
                <Grid.ColumnDefinitions>
                  <ColumnDefinition />
                  <ColumnDefinition Width="Auto" />
                </Grid.ColumnDefinitions>
                <ContentPresenter />
                <Thumb Grid.Column="1" 
                       Style="{StaticResource ColumnGripperStyle}"
                       DragStarted="ColumnGripper_DragStarted" 
                       DragDelta="ColumnGripper_DragDelta" />
              </Grid>
            </ControlTemplate>
          </Setter.Value>
        </Setter>
      </Style>
    </ListBox.ItemContainerStyle>
  </ListBox>
</UserControl>

Notes for improvement

The ColumnsView.Columns property should be a DependencyProperty to allow to use the control as binding target.
The column gap can also be a DependencyProperty of ColumnsView.
By replacing the TextBlock that displays the column header with a Button, you can easily add sorting. Having the active column that triggers the sorting e.g. lexically, you would have to sync the other passive columns and sort them based on the CellItem.RowIndex order of the active sorted column.
Maybe choose to extend Control rather than UserControl.
You can implement the CellItem to use a generic type parameter to declare the Cellitem.Value property like CellItem<TValue>.
You can implement the ColumnItem to use a generic type parameter to declare the ColumnItem.Items property like ColumnItem<TColumn>.
Add a ColumnsView.SelectedRow property that returns a collection of all CellItem items of the current selected row

like image 188
BionicCode Avatar answered Sep 30 '22 15:09

BionicCode