NOTE: This post is kinda long. However, most of the length is a result of code postings (even after removing some extra stuff). Even at that, I broke this out into two parts: the section you are are reading right now, and the follow-up to be found at Extending C# Listview with Collapsible Groups (Part II) Bear with me!
Also of Interest:
I’ve been deep in a project for work for the past two months. Sadly, it is nothing sexy, no exciting bleeding-edge technology, just another enterprise database, using the very mature and slightly dull Winforms library in the .NET platform.
However, I did stumble across an interesting project requirement for what is essentially an expandable group of the venerable
ListView
control, what one might get if one combined a ListView
with a TreeView
, or if the “groups” built into the Listview control could be expanded/collapsed, right-clicked, etc.
Fig. 1 – Single Group Expanded:
Fig. 2 – Multiple Groups Expanded (Note Scrollbar on Container Control):
Fig. 3 – On Widen Column (Note Scrollbar on Specific ListGroup):
For those who are about to point out that such a control exists in the ObjectListView, I am aware. However, I needed to do this using standard .NET Libraries. I am also aware that the standard Listview Group can be forced to expand/Collapse, but I needed this to be a faster solution. Also, causing the standard Listview Group to expand/collapse looked to rely on a whole lot of Windows API calls, and I am not so fluent in that arcane area.
My solution was to extend the Listview control, and then assemble multiple Listview controls within a
FlowLayoutPanel
control. The ColumnHeaders
of each Listview double as the “Group.” Clicking on the left-most column toggles group expansion/collapse. The expanded/collapsed state is indicated by a solid arrow image at the left end of the column. In cases where the group is empty (containing no ListViewItems, and looking for all the world like a collapsed group) the arrow image is empty.
For the purpose of clarity, I refer to the aggregate control as a “GroupedListControl",” and each contained ListView as a “ListGroup".” There is a wide potential for improvement in this naming scheme, I am sure. For the purpose of this narrative, assume the following:
- A
GroupedListControl
contains one or more ListGroups, which contain ListViewItems.
- The
GroupedListControl
is a container which inherits from FlowLoyoutPanel
.
- The
ListGroup
is a container which inherits from the Winforms
ListView
.
Extending Native Listview Behaviors with Inner Classes
First, I needed to extend some of the basic behaviors of the stock .net Listview control. For example, in order to treat the leftmost column (Column [0]) differently, and to monitor the addition and removal of columns for the purpose of controlling and adjusting for the appearance of scrollbars, I needed an event to be fired when columns are added and removed. I did a little digging on the interwebs, and found my solution in a post on the Code Project site. I was able to take the core concept there and achieve what I needed:
A basic list of desired behaviors for each ListGroup include:
- Clicking on the leftmost
ColumnHeader
of a ListGroup should toggle the expansion/collapse of the group.
- When the first column is added, the Expanded/Collapsed indicator arrows should be added to the leftmost ColumnHeader.
- If the total width of the columns in any given
ListGroup
exceed the width of the client area of the containing
GroupedListControl
, the ListGroup should show a horizontal scrollbar.
- The height of each
ListGroup
should be adjusted such that all Listview items contained should be displayed in the expanded state, up to an optional maximum height determined either at design-time or runtime. If the number of ListViewItems contained exceeds this maximum, the Individual ListGroup Vertical Scrollbar will appear.
- Any time the Horizontal Scrollbar is displayed for a specific ListGroup, the client area for that ListGroup should be adjusted such that the Horizontal scrollbar does not partially obscure the last displayed
ListViewItem
.
- Detect Mouse Right-Clicks on specific ListView ColumnHeaders and allow for a context menu specific to right-clicking on ColumnHeaders vs. ListView Items.
This covers some minimums for the control to function properly. Let’s look at what a basic code skeleton would look like here. We will fill in some of the empty code stubs a little later in the post.
Complete source code for this project is available at GroupedListControl Project Source Code On GitHub. The source includes additional code not covered here. We will discuss some of it in upcoming posts.
First, I defined some Custom Event Argument Classes which will be utilized within the control. While they are functionally similar to some existing
ListView
Event Argument classes, I wanted to maintain clear naming to the degree possible. These are required by subsequent code.
Note that all examples require the following references at the head of your code file:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Drawing;
using System.Text;
using System.Windows.Forms;
using System.Windows.Forms.Layout;
using System.Runtime.InteropServices;
using System.ComponentModel;
Custom Event Arguments:
namespace GroupedListControl
{
public class ListGroupColumnEventArgs : EventArgs
{
public ListGroupColumnEventArgs(int Columnindex)
{
this.ColumnIndex = ColumnIndex;
}
public int ColumnIndex { get; set; }
}
public class ListGroupItemEventArgs : EventArgs
{
public ListGroupItemEventArgs(ListViewItem Item)
{
this.Item = Item;
}
public ListGroupItemEventArgs(ListViewItem[] Items)
{
this.Items = Items;
}
public ListViewItem Item { get; set; }
public ListViewItem[] Items { get; set; }
}
}
Now, the ListGroup
class itself. I have cut out some additional methods for the sake of brevity here (even at that, it’s a long chunk of code . . .), and left some code stubs to be filled in shortly. This is just to give an idea of the most basic class structure, and core functionality requirements. As it is right here, this code is not functional.
The ListGroup Class – Essentials and Code Stubs
public class ListGroup : ListView
{
public delegate void ColumnAddedHandler(object sender, ListGroupColumnEventArgs e);
public delegate void ColumnRemovedHandler(object sender, ListGroupColumnEventArgs e);
public event ColumnAddedHandler ColumnAdded;
public event ColumnRemovedHandler ColumnRemoved;
public delegate void ItemAddedHandler(object sender, ListGroupItemEventArgs e);
public delegate void ItemRemovedHandler(object sender, ListGroupItemEventArgs e);
public event ItemAddedHandler ItemAdded;
public event ItemRemovedHandler ItemRemoved;
public delegate void GroupExpansionHandler(object sender, EventArgs e);
public event GroupExpansionHandler GroupExpanded;
public event GroupExpansionHandler GroupCollapsed;
public delegate void ColumnRightClickHandler(object sender, ColumnClickEventArgs e);
public event ColumnRightClickHandler ColumnRightClick;
private ListGroupItemCollection _Items;
private ListGroupColumnCollection _Columns;
public ListGroup() : base()
{
}
public new ListGroupItemCollection Items
{
get { return _Items; }
}
public new ListGroupColumnCollection Columns
{
get { return _Columns; }
}
public class ListGroupColumnCollection : ListView.ColumnHeaderCollection
{
}
public class ListGroupItemCollection : ListView.ListViewItemCollection
{
}
private void OnItemAdded(ListViewItem Item)
{
if (ItemAdded != null)
this.ItemAdded(this, new ListGroupItemEventArgs(Item));
}
private void OnItemRemoved(ListViewItem Item)
{
if (ItemRemoved != null)
this.ItemRemoved(this, new ListGroupItemEventArgs(Item));
}
private void OnColumnAdded(int ColumnIndex)
{
if (this.ColumnAdded != null)
this.ColumnAdded(this, new ListGroupColumnEventArgs(ColumnIndex));
}
private void OnColumnRemoved(int ColumnIndex)
{
if (this.ColumnRemoved != null)
this.ColumnRemoved(this, new ListGroupColumnEventArgs(ColumnIndex));
}
void ListGroup_ColumnClick(object sender, ColumnClickEventArgs e)
{
}
public void Expand()
{
if (this.GroupExpanded != null)
this.GroupExpanded(this, new EventArgs());
}
public void Collapse()
{
if (this.GroupCollapsed != null)
this.GroupCollapsed(this, new EventArgs());
}
}
Now, pay close attention to the code stubs where we define our Inner Classes,
ListGroupColumnCollection
and ListGroupItemCollection
. Note that each derives from its respective counterpart in the Winforms ListView Control. This is where we achieve a number of our custom event sourcing behaviors. Once again, I have simplified the class definitions here, leaving out various overloads of the core methods required (for example, there are multiple ways to “Add” an item to either collection – here I only cover the most basic. The rest are defined in the GroupedListControl Project Source Code, obtainable from my GitHub Repo).
Notice how the code within each inner class causes events to be raised within the containing ListGroup class? This provides our event sourcing for the addition/removal of Columns and ListViewItems, since these events are not defined in the base Winforms ListView Class. We need them in order to affect proper control expansion and collapse in response to additions and removals
NOTE: Core Concepts for the use of Inner Classes in this manner was adapted from THIS ARTICLE by Simon Segal on Code Project.
The following code replaces the code stub for the ListGroupColumnCollection
class in our ListGroup class definition:
The ListGroupColumnCollection Class
public class ListGroupColumnCollection : ListView.ColumnHeaderCollection
{
private ListGroup _Owner;
public ListGroupColumnCollection(ListGroup Owner) : base(Owner)
{
_Owner = Owner;
}
public int TotalColumnWidths
{
get
{
int totalColumnWidths = 0;
foreach(ColumnHeader clm in this)
totalColumnWidths = totalColumnWidths + clm.Width;
return totalColumnWidths;
}
}
public new ColumnHeader Add(string text, int width, HorizontalAlignment textAlign)
{
ColumnHeader clm = base.Add(text, width, textAlign);
_Owner.OnColumnAdded(clm.Index);
return clm;
}
public new void Remove(ColumnHeader column)
{
int index = column.Index;
base.Remove(column);
_Owner.OnColumnRemoved(index);
}
public new void Clear()
{
base.Clear();
}
}
The following code replaces the empty stub for the ListGroupItemCollection
class in our original
ListGroup
class definition:
The ListGroupItemCollection Class
public class ListGroupItemCollection : System.Windows.Forms.ListView.ListViewItemCollection
{
private ListGroup _Owner;
public ListGroupItemCollection(ListGroup Owner) : base(Owner)
{
_Owner = Owner;
}
public new ListViewItem Add(string text)
{
ListViewItem item = base.Add(text);
_Owner.OnItemAdded(item);
return item;
}
public new void Remove(ListViewItem Item)
{
base.Remove(Item);
_Owner.OnItemRemoved(Item);
}
}
Filling it all in
The most basic extension of the existing capability of the standard ListView
control we are seeking is the ability for each
ListGroup
to “expand” and/or “collapse” in response to certain user inputs. We will want to define a singular method call which causes the desired action to be performed, so we’ll add a method to our
ListGroup
class named SetControlHeight
. This method evaluates the current state of the control (collapsed or expanded), and calls the appropriate method to toggle that state to the opposite.
The minimum collapsed height of each ListGroup
control should be just enough to display the Column Headers. The
Expanded
height may be unlimited, or may be constrained by setting the
MaximumHeight
property. In either case, however, the control height should include enough space to display the Column Headers, and an even number of
ListViewItem
s such that the last item is fully visible in the display.
The collapsed state is recognizable if the height of the control is equal to the height of the Column Headers and the item count is greater than 0. Otherwise, the control must be in an expanded state. I had to arbitrarily set the header height as a constant which matches the default header height for the standard
ListView
control (25). However, this can be modified to suit.
In order to accomplish all of the above, we will need to define a few more private members in our
ListGroup
class. Add the following code in the declaration area of your class (I am still a little old-school, in that I place most of my declarations at the top of the class, just after the class declaration itself). In the Source file, the following appear just before the Constructor:
Additional Member Declarations:
static string COLLAPSED_IMAGE_KEY = "CollapsedImage";
static string EXPANDED_IMAGE_KEY = "ExpandedImageKey";
static string EMPTY_IMAGE_KEY = "EmptyImageKey";
static int HEADER_HEIGHT = 25;
We also need a Constructor at this point. Note that the Constructor initializes the ListView.SmallImageList with some images stored in the project resources (Properties.Resources). The images are included with the GroupedListControl Project Source Code. Replace the Constructor code stub with the following:
The Constructor:
public ListGroup() : base()
{
this.Columns = new ListGroupColumnCollection(this);
this.Items = new ListGroupItemCollection(this);
this.SmallImageList = new ImageList();
this.SmallImageList.Images.Add
(COLLAPSED_IMAGE_KEY, Properties.Resources.CollapsedGroupSmall_png_1616);
this.SmallImageList.Images.Add
(EXPANDED_IMAGE_KEY, Properties.Resources.ExpandedGroupSmall_png_1616);
this.SmallImageList.Images.Add
(EMPTY_IMAGE_KEY, Properties.Resources.EmptyGroupSmall_png_1616);
this.View = System.Windows.Forms.View.Details;
this.FullRowSelect = true;
this.GridLines = true;
this.LabelEdit = false;
this.Margin = new Padding(0);
this.SetAutoSizeMode(AutoSizeMode.GrowAndShrink);
this.MaximumSize = new System.Drawing.Size(1000, 2000);
this.ColumnClick += new ColumnClickEventHandler(ListGroup_ColumnClick);
this.ItemAdded += new ItemAddedHandler(ListGroup_ItemAdded);
}
Now we can add code to manage the re-sizing of the control in response to user actions. While we have existing stubs for the
Expand()
and Collapse()
methods because these formed obvious behaviors for our control, the next two will have to be added. The
SetControlHeight()
method is our one-stop call to adjust the height of the control:
The SetControlHeight Method:
public void SetControlHeight()
{
if (this.Height == HEADER_HEIGHT && this.Items.Count != 0)
this.Expand();
else
this.Collapse();
}
Add the above, along with the next three methods to our ListGroup
class. The next three methods actually perform the heavy lifting in terms of adjusting the control expanded/collapsed state. The first is a function which returns the proper control height after evaluating several factors (explained in the comments). We don’t have a stub for this in our existing structure, so add it right under the
SetControlHeight
method:
PreferredControlHeight Function:
private int PreferredControlHeight()
{
int output = HEADER_HEIGHT;
int rowHeight = 0;
if(this.Items.Count > 0)
rowHeight = this.Items[0].Bounds.Height;
int horizScrollBarOffset = 10;
if (this.Columns.TotalColumnWidths > this.Width)
horizScrollBarOffset = rowHeight + 10;
output = HEADER_HEIGHT + (this.Items.Count) * rowHeight
+ horizScrollBarOffset + this.Groups.Count * HEADER_HEIGHT;
return output;
}
Then replace the Expand()
and Collapse()
code stubs with the following. While the
PreferredControlHeight
function provides the optimal height for a
ListGroup
, the Expand
and Collapse
methods perform the requested action and also cause the Expanded/Collapsed/Empty images to display properly in the left-most column:
The Expand and Collapse Methods:
public void Expand()
{
if (this.Columns.Count > 0)
{
this.Height = this.PreferredControlHeight();
if (this.Items.Count > 0)
this.Columns[0].ImageKey = EXPANDED_IMAGE_KEY;
else
this.Columns[0].ImageKey = EMPTY_IMAGE_KEY;
this.Scrollable = true;
if (this.GroupExpanded != null)
this.GroupExpanded(this, new EventArgs());
}
}
public void Collapse()
{
if (this.Columns.Count > 0)
{
this.Scrollable = false;
this.Height = HEADER_HEIGHT;
if (this.Items.Count > 0)
this.Columns[0].ImageKey = COLLAPSED_IMAGE_KEY;
else
this.Columns[0].ImageKey = EMPTY_IMAGE_KEY;
if (this.GroupCollapsed != null)
this.GroupCollapsed(this, new EventArgs());
}
}
Wiring it all up
Now we need to wire up behaviors (Expand/Collapse) to the appropriate events. Some of these are obvious. When the user clicks on the left-most column (with the Expanded/Collapsed state image), the control should toggle this state. However, we also need the control to adjust its displayed area (and possible toggle the state image) when items are added/removed, and when columns are added/removed (because when columns are added, there may be a need to add the state image to the first column).
First, we will address Column addition/removal. When a column is added to the control, if it is the FIRST column, it will need the initial state image added, and the control will need to size itself. Since it is the first column, it is reasonably safe (but not 100%) to assume that there have been no items added yet, so the
ListGroup
is empty, and the state image should reflect this.
Add the highlighted items to the OnColumnAdded() Method:
private void OnColumnAdded(int ColumnIndex)
{
if (ColumnIndex == 0)
{
this.Columns[0].ImageKey = EMPTY_IMAGE_KEY;
this.SetControlHeight();
}
if(this.ColumnAdded != null)
{
this.ColumnAdded(this, new ListGroupColumnEventArgs(ColumnIndex));
}
}
What happens if the ListGroup is populated with items, and the last column is removed? I don’t have a good answer for this, other than to clear the ListItems. It seems to me that the control loses its identity and purpose. If you have thoughts about this, please do discuss in the comments, or fork the code on Github. In any case, when removing columns, we need to test and see if the column removed is the last column in the control. If so, call the Clear() Method:
private void OnColumnRemoved(int ColumnIndex)
{
this.ColumnWidthChanged -= new ColumnWidthChangedEventHandler(ListGroup_ColumnWidthChanged);
this.Height = this.PreferredControlHeight();
this.ColumnWidthChanged += new ColumnWidthChangedEventHandler(ListGroup_ColumnWidthChanged);
if(this.ColumnRemoved != null)
this.ColumnRemoved(this, new ListGroupColumnEventArgs(ColumnIndex));
}
Now, the final piece in our simplified ListGroup
is the whole thing where the user clicks on the left-most column (Column[0]), and is rewarded by the control expanding or collapsing. The state image provides a sort of visual cue/affordance to the user which implies the current state. All we have to do is handle the
ColumnClick
event (sourced by the base class ListView
) and we’re done with this part of our (abbreviated) control
Replace the ListGroup_ColumnClick code stub with the following:
Handling the ColumnClick Event:
void ListGroup_ColumnClick(object sender, ColumnClickEventArgs e)
{
int columnClicked = e.Column;
if (columnClicked == 0)
{
this.SuspendLayout();
this.SetControlHeight();
this.ResumeLayout();
}
}
Summing Up Part I
Sadly, I need to break this project up into two parts. Even this one is too long (although much of the length is simply code samples.
In this first post, we have examined extending the Winforms ListView
class so that it can serve as a component within a container control. We have also examined extending the events sourced by the
ListView
control through the use of inner classes, used to extend the
ListViewColumnCollection
and ListViewItemCollection
classes.
In the next post, we will fold our ListGroup
class into the container
GroupedListControl
. In doing so, we will need to make a few calls to the Windows API. I hate it when that happens, but so it is (I hate it because I am not well-schooled in the Win32 API, so that kind of thing is HARD for me!). After that, we will examine some special methods to source a custom Context Menu specific to right-clicks on the List Group column headers, and a few other interesting tidbits which were necessary to make the overall control work properly.
This Article Continues: Extending C# Listview with Collapsible Groups (Part II)
Report Bugs. Submit Improvements. Do Good. Help Me Get Better!
I will try to get the next post up is a day or two. In the meantime, if you find over bugs in the code, or see areas for improvement in the overall implementation, please report bugs (in the comments here, or on Github), and feel free to fork the source and submit pull requests for improvements. I have said many times, I need all the help I can get!
Referenced in This Article: