June 10, 2010

ListView Horizontal and Vertical Gridlines

Recently, in WPF application I was developing I was asked to show gridlines in the ListView with GridView. I thought it was piece of cake, just turn on some property like ShowGridlines. I was totally wrong! It absolutely wasn't as easy. After half an hour of searching I figured out that there were two feasible solutions available for vertical lines (fortunately, horizontal lines are the matter of setting ListViewItem border):
  1. How Do I Set Up Grid Lines for my ListView?
  2. WPF ListView Vertical Lines (Horizontal as Bonus
First is only ok, if you have only few fields in your ListView, it becomes too tedious to set cell template for each cell. Second is quite cumbersome since it requires setting each vertical gridline manually. What if I have 10 grids with 10-15 columns each? Should I create a style for each of the grids? Or should I use code-generation to get border around each cell template? Fortunately, I found out a more elegant solution after I spotted GridViewRowPresenter in the ListViewItem template. After spending some time with Reflector, I figured out how to implement custom ancestor of GridViewRowPresenter to draw vertical lines.
Here is how. It requires a bit of understanding a bit of WPF inner infrastructure namely overriding Measure and Arrage layout passes and Visuals’s VisualChildrenCount and GetVisualChild. The main job ofGridViewRowPresenter  is to arrange cells according to column order and sizes. That is where I plug-in my additional logic of arranging vertical gridlines.
protected override Size ArrangeOverride(Size arrangeSize)
{
    var size = base.ArrangeOverride(arrangeSize);
    var children = Children.ToList();
    EnsureLines(children.Count);
    for (var i = 0; i < _lines.Count; i++)
    {
        var child = children[i];
        var x = child.TransformToAncestor(this).Transform(new Point(child.ActualWidth, 0)).X + child.Margin.Right;
        var rect = new Rect(x, -Margin.Top, 1, size.Height + Margin.Top + Margin.Bottom);
        var line = _lines[i];
        line.Measure(rect.Size);
        line.Arrange(rect);
    }
    return size;
}
It simply calls base’s ArrangeOverride method and then iterates through logical children arranging gridlines after each of the child. It takes margins into account and requires ListViewItem HorizontalContentAlignment to be set to Stretch in order to work properly. Here are two helper methods/properties used:
private IEnumerable<FrameworkElement> Children
{
    get { return LogicalTreeHelper.GetChildren(this).OfType<FrameworkElement>(); }
}

Basically, gets logical children of the presenter. And:
private void EnsureLines(int count)
{
    count = count - _lines.Count;
    for (var i = 0; i < count; i++)
    {
        var line = (FrameworkElement) Activator.CreateInstance(SeparatorStyle.TargetType);
        line.Style = SeparatorStyle;
        AddVisualChild(line);
        _lines.Add(line);
    }
}
Which creates elements that would be used as separator. Activator is included just for flexibility that allows you to use any FrameworkElement not just Rectangle or Border. What would be used as separator is governed by SeparatorStyle property. By default it is set to Rectangle with Fill property set.
static GridViewRowPresenterWithGridLines()
{
    DefaultSeparatorStyle = new Style(typeof (Rectangle));
    DefaultSeparatorStyle.Setters.Add(new Setter(Shape.FillProperty, SystemColors.ControlLightBrush));
    SeparatorStyleProperty = DependencyProperty.Register("SeparatorStyle", typeof (Style), typeof (GridViewRowPresenterWithGridLines),
                                                            new UIPropertyMetadata(DefaultSeparatorStyle, SeparatorStyleChanged));
}
All above can be as well hardcoded as simple as:
line = new Rectangle{Fill=Brushes.LightGray};

One other thing required is to tell WPF that you have added additional visuals (lines) to your control. I do it by overriding following methods:
protected override int VisualChildrenCount
{
    get { return base.VisualChildrenCount + _lines.Count; }
}

protected override Visual GetVisualChild(int index)
{
    var count = base.VisualChildrenCount;
    return index < count ? base.GetVisualChild(index) : _lines[index - count];
}

That is it! Now just modify the ItemContainerStyle of the ListView:
<ListView.ItemContainerStyle>
    <Style TargetType="{x:Type ListViewItem}">
        <Setter Property="Margin" Value="2,0,0,0"/>
        <Setter Property="Padding" Value="0,2"/>
        <Setter Property="BorderBrush" Value="LightGray"/>
        <Setter Property="BorderThickness" Value="0,0,0,1"/>
        <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="{x:Type ListViewItem}">
                    <Border BorderBrush="{TemplateBinding BorderBrush}" 
                            BorderThickness="{TemplateBinding BorderThickness}" 
                            Background="{TemplateBinding Background}">
                        <GridLines:GridViewRowPresenterWithGridLines 
                            Columns="{TemplateBinding GridView.ColumnCollection}"
                            Margin="{TemplateBinding Padding}" />
                        <!-- Try setting the SeparatorStyle property of presenter
                            SeparatorStyle="{StaticResource SeparatorStyle}" 
                        -->
                    </Border>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
        <Style.Triggers>
            <Trigger Property="IsSelected" Value="True">
                <Setter Property="Background" Value="{DynamicResource {x:Static SystemColors.ControlDarkBrushKey}}"/>
            </Trigger>
            <Trigger Property="IsEnabled" Value="False">
                <Setter Property="Background" Value="{DynamicResource {x:Static SystemColors.ControlBrushKey}}"/>
            </Trigger>
        </Style.Triggers>
    </Style>
</ListView.ItemContainerStyle>

Download source code

3 comments:

  1. thank a lot for your code.
    I have changed a little bit of it and use it in my project.

    the problem is if the content of a item is longer than Column's width, the line will be putted in wrong place.

    this is how i changed to reslove:

    var x = child.TransformToAncestor(this).Transform(new Point(child.MinWidth, 0)).X - child.Margin.Right;

    ReplyDelete
    Replies
    1. MinWidth did not work for me so I use actual column width:
      for (int idx = 0; idx < this.Columns.Count; idx++)
      {
      GridViewColumn column = this.Columns[idx];
      // Actual index needed for column reorder
      UIElement uiColumn = (UIElement)base.GetVisualChild((int)ActualIndexProperty.GetValue(column, null));

      // Compute column width
      double w = Math.Min(max, (Double.IsNaN(column.Width)) ? (double)DesiredWidthProperty.GetValue(column, null) : column.Width);

      uiColumn.Arrange(new Rect(current, 0, w, arrangeSize.Height));


      max -= w;
      current += w;

      #region vertical lines
      if ((ShowVerticalLines) && (_lines.Count > idx))
      {
      FrameworkElement feColumn = uiColumn as FrameworkElement;
      //have to take calculated with colX, while columns.ActualWidth do not need to by actual at this moment
      var lineX = uiColumn.TransformToAncestor(this).Transform(new Point(colWidth, 0)).X - (feColumn != null ? feColumn.Margin.Right : 0);
      var rect = new Rect((lineX + 1), -Margin.Top, 1, s.Height + Margin.Top + Margin.Bottom);
      var line = _lines[idx];
      line.Measure(rect.Size);
      line.Arrange(rect);
      }
      #endregion
      }

      Delete
  2. This comment has been removed by a blog administrator.

    ReplyDelete