use the ListView control a lot. Not to display large or small icon views, which are attractive but information-sparse. Instead, I tend to use it to display a detail view similar to the one shown on the right half of Windows Explorer when you choose “Details” from the View menu or the dropdown button on the toolbar.
In detail view, the ListView acts like an easy-to-use read-only grid. In code, it’s easy to add, remove, and rearrange the control’s items and sub-items. And at run time, the control can let the user select rows, rearrange columns, and even sort the rows it displays.
While the ListView provides the basic tools to do these things, however, you need to add some code to make things work. The SortableListView control described in this article adds that code. It inherits from the ListView class, so it not only has all the features of the ListView control, but also adds support for sorting by all columns and sorting by a selected column.
Selected Column Sorting
The ListView control contains an Items collection that holds the items that it displays. Each item in the collection is an instance of a ListViewItem class and exposes a SubItems collection that gives further detail for the item. In the Details view, the ListView lists the text for the items in the leftmost column and lists sub-items in the other columns.
The ListView control has a built-in Sort method that makes it sort its data. By default, however, the control only sorts the items?not the sub-items. Fortunately the control’s ListViewItemSorter property allows you to change the way in which the control sorts its items.
To implement a customized sort, you must set the ListViewItemSorter property to an object that implements the IComparer interface. That interface specifies a single method, Compare, that takes two items as parameters and returns ?1, 0, or 1 to indicate whether the first item should be considered less than, equal to, or greater than the second item in the sort order, respectively.
This control uses IComparer classes to provide two new sorting styles. The first sorts by a specific column, not necessarily the first column. Figure 1 shows the control sorted by its Pages column in ascending order (smallest values on top). Note that the row that contains no Pages data sorts to the top. The control displays an upward arrow on the currently sorted column as an indication that the column is sorted in ascending order (the arrow points towards the smallest item). If you click the column header, the control switches to a descending sort.
If you click on a different column header, the control sorts on that column?just like Windows Explorer. Figure 2 shows the form after I clicked on the Title column twice, first to sort by that column and then again to change the sort order to descending.
The following code shows the SelectedColumnSorter class used by the SortableListView control to sort on a selected column. You’ll find both this Visual Basic version and a C# version in the downloadable code attached to this article:
' Sort the ListView items by the selected column. Private Class SelectedColumnSorter Implements IComparer ' Compare two ListViewItems. Public Function Compare(ByVal x As Object, _ ByVal y As Object) As Integer _ Implements System.Collections.IComparer.Compare ' Get the items. Dim itemx As ListViewItem = DirectCast(x, ListViewItem) Dim itemy As ListViewItem = DirectCast(y, ListViewItem) ' Get the selected column index. Dim slvw As SortableListView = itemx.ListView Dim idx As Integer = slvw.m_SelectedColumn If idx < 0 Then Return 0 ' Compare the sub-items. If itemx.ListView.Sorting = SortOrder.Ascending Then Return String.Compare( _ ItemString(itemx, idx), _ ItemString(itemy, idx)) Else Return -String.Compare( _ ItemString(itemx, idx), _ ItemString(itemy, idx)) End If End Function ' Return a string representing this item's sub-item. Private Function ItemString( _ ByVal listview_item As ListViewItem, ByVal idx As Integer) _ As String Dim slvw As SortableListView = listview_item.ListView ' Make sure the item has the needed sub-item. Dim value As String = "" If idx <= listview_item.SubItems.Count - 1 Then value = listview_item.SubItems(idx).Text End If ' Return the sub-item's value. If slvw.Columns(idx).TextAlign = _ HorizontalAlignment.Right _ Then ' Pad so numeric values sort properly. Return value.PadLeft(20) Else Return value End If End Function End Class
The Compare function converts its two parameters from generic Objects into ListViewItem objects. It uses the first item's ListView property to get the SortableListView control that contains the item.
The code then uses the SortableListView control's m_SelectedColumn variable to see which column it should use for sorting (this variable is described later). If no column is selected, the function simply exits.
Next the code checks the SortableListView control's Sorting property to see whether it should sort the objects in ascending or descending order. The SelectedColumnSorter object calls its own ItemString function to generate strings representing the selected column in each of the ListViewItem objects. It uses String.Compare to compare the strings and returns the results. If the items should be sorted in descending order, the code negates the result of String.Compare to give the proper result.
The SelectedColumnSorter class's ItemString function returns a string representing the selected column for a ListViewItem. It first checks whether the ListViewItem has the needed column. For example, if a ListViewItem has only one sub-item but the control is sorting on its fifth column, the code uses a blank string for the column.
After finding the correct value for the column (either the sub-item's text or a blank string), the code checks the corresponding column's TextAlign property. If the text in this column should be right-aligned, then the column probably contains numeric data that should not be sorted alphabetically. For example, the string "100" comes alphabetically before "11: but you probably want "11" to appear first in the sorted list.
To sort right-aligned numbers properly, the ItemString function pads those values on the left with spaces. Spaces come before digits alphabetically so the string " 11" will appear before "100" as desired.
|Author's Note: You may want to make this test more elaborate depending on your data. For example, this code assumes right-aligned columns contain simple numbers with at most 20 digits. The code would not know that "1e10" is the same as "1E+10" or that "April 1" should come after "January 1." But the sample code demonstrates the general idea.|
Using the SelectedColumnSorter Class
The SortableListView control can now use the SelectedColumnSorter class to sort its items by a particular column. The code fragment in Listing 1 shows key pieces of the SortableListView control. For simplicity, I've omitted the code that's not related to sorting by a selected column.
After importing the System.ComponentModel namespace, the code in Listing 1 includes a ToolboxBitmap attribute and the Class statement that indicates the control inherits from ListView:
_ Public Class SortableListView
The control defines the SortStyles enumeration so you can indicate whether the control should sort using the default method, by all columns, or by a selected column:
Public Enum SortStyles SortDefault SortAllColumns SortSelectedColumn End Enum
It also declares the variable m_SelectedColumn to hold the index of the column on which it should be sorting.
The SortStyle property gets and sets a SortStyles value. The Get procedure simply returns the value saved in variable m_SortStyle:
Public Property SortStyle() As SortStyles Get Return m_SortStyle End Get ...
The Set procedure shown in Listing 1 performs a little more work. First it checks whether the control is currently sorting by a selected column. If it is, then the code removes the sort indicator arrow by setting the column's ImageKey property to Nothing so the column doesn't display any image:
Set(ByVal value As SortStyles) ' If the current style is SortSelectedColumn, ' remove the column sort indicator. If m_SortStyle = SortStyles.SortSelectedColumn Then If m_SelectedColumn >= 0 Then Me.Columns(m_SelectedColumn).ImageKey = Nothing m_SelectedColumn = -1 End If End If ...
Next, the Set procedure saves the new sort style and then uses a Select Case statement to take appropriate action. When the new sort style is SortDefault, the code sets the ListViewItemSorter property to Nothing so the ListView's default sorting behavior takes over. When the sort style is SortAllColumns, the code sets the ListViewItemSorter property to a new AllColumnSorter object (described in the next section). When the new style is SortSelectedColumn, then the code sets the ListViewItemSorter property to a new SelectedColumnSorter object:
... ' Save the new value. m_SortStyle = value Select Case m_SortStyle Case SortStyles.SortDefault Me.ListViewItemSorter = Nothing Case SortStyles.SortAllColumns Me.ListViewItemSorter = New AllColumnSorter() Case SortStyles.SortSelectedColumn Me.ListViewItemSorter = _ New SelectedColumnSorter() End Select ' Resort. ' Me.Sort() End Set End Property
At this point, the Set procedure could call the control's Sort method to resort the items using the new sorter. I found that confusing in the example program so I commented out the code and let the main program call the control's Sort method explicitly.
The last piece of the control's code needed for column sorting is the OnColumnClick method, called whenever the user clicks on a column header. The code starts by calling the base class's OnColumnClick method. It then determines whether the current sorting style is SortSelectedColumn:
Protected Overrides Sub OnColumnClick( _ ByVal e As System.Windows.Forms.ColumnClickEventArgs) MyBase.OnColumnClick(e) If Me.SortStyle = SortStyles.SortSelectedColumn Then ' If this is the same sort column, switch the sort order. If e.Column = m_SelectedColumn Then If Me.Sorting = SortOrder.Ascending Then Me.Sorting = SortOrder.Descending Else Me.Sorting = SortOrder.Ascending End If End If ' Remove the image from the previous sort column. If m_SelectedColumn >= 0 Then Me.Columns(m_SelectedColumn).ImageKey = Nothing End If ' If we're not currently sorting, sort ascending. If Me.Sorting = SortOrder.None Then Me.Sorting = SortOrder.Ascending End If ' Save the new sort column and give it an image. m_SelectedColumn = e.Column If Me.Sorting = SortOrder.Descending Then Me.Columns(m_SelectedColumn).ImageKey = _ "sortDescending.bmp" Else Me.Columns(m_SelectedColumn).ImageKey = _ "sortAscending.bmp" End If ' Resort. Me.Sort() End If End Sub
When a user clicks the same column that was selected previously, the OnColumnClick code shown switches the control's sort order, and clears the sort indicator arrow. If there's no current sort-order selection, the code uses the default ascending order.
The code then saves the newly clicked column as the selected column and sets the appropriate sort order indicator arrow for the column.
|Author's Note: Your main program should set the control's SmallImageList property to an ImageList control that contains these images. Their names in the ImageList should be sortDescending.bmp and sortAscending.bmp. I've found that 16 x 14 pixel images seem to look the best.|
Finally, the OnColumnClick code calls the control's Sort method to resort the items based on the newly selected column and sort order.
Sorting By Complete Rows
Recall that the ListView control sorts only its items in the first column by default. To sort ListViewItems using all the control's columns, you use an approach similar to the one you use to sort by a single specific column?create a class that implements the IComparer interface and then set the control's ListViewItemSorter property to a new instance of the class.
This new row-comparer class is very similar to the one described previously. It also uses an ItemString method to build string representations of ListViewItems and then uses String.Compare to compare them. But there are two main differences between this class and the old one. The simpler difference is that the new class uses different code for its ItemString method so it can compare all of the columns rather than just the selected column. The new version of the function builds a single string containing all the item's values separated by null characters, making it easy to use String.Compare to compare two items' strings.
The String.Compare method returns ?1 if the first string should sort before the second. The null character between fields has ASCII code 0 so it comes alphabetically before any other character. That means if two strings match at the beginning and one is shorter than the other, then the string containing the shorter field comes first alphabetically (because the null character will appear before whatever the other field contains next). If two fields match exactly, then later parts of the strings determine which item belongs first.
The second difference between this comparer class and the previous one is a little more complicated. The ListView control allows users to rearrange columns by clicking and dragging column headers to the right or left. This is one of the control's more useful features, so I didn't want to disable it. Unfortunately, allowing users to change column order makes sorting the items much more difficult.
For example, Figure 3 shows the example program in its original arrangement displaying the columns in the order: Author, Year, Pages, Title. The rows are sorted descending so Tom Holt is listed first because his name comes last alphabetically. His two books in the list were both published in 2006, so their order isn't determined until the Pages column. "Earth, Air, Fire, and Custard" has more pages than his other book so it is listed first in descending order.
Figure 4 shows the same form after I dragged the Year column's header to the left of the Author column. Now my book "Expert One-on-One Visual Basic 2005 Design and Development" is listed first because it's the only book in the list that was published in 2007.
If you look down the list, you can see other effects of the row sorting method. For example, Sam Test's book with 1001 pages is listed before his other books because it has the most pages and the sort is in descending order. Similarly the book by Sam Test that has no Pages value is listed last because empty values come last in descending order.
To detect column reordering, the base classes' OnColumnReordered method would seem to be exactly what the doctor ordered?but it isn't, quite. While the method's name implies that it fires after column reordering has completed, the base class actually calls this method and fires the corresponding ColumnReordered event before the column is moved?so you don't yet know the new ordering of the control's columns. (The method and event should really be called OnColumnReordering and ColumnReordering, respectively. As long as we're dreaming, it would be nice to have OnColumnReordered and ColumnReordered. Or OnColumnReorderStart and OnColumnReorderEnd.)
While the column has not yet been moved in the OnColumnReordered method, the method does tell you which column will be moved and where it's going. That means with a little extra work you can figure out where all of the columns will be after the move is complete.
Listing 1 shows how the SortableListView control keeps track of where all of its columns will be after the user reorders columns.
In Listing 2, the m_SortSubitems array contains a list of the control's items in the order in which they should be used for sorting. For example, suppose the control's columns are Author, Year, Pages, and Title. These columns have indices 0, 1, 2, and 3. Now suppose the user rearranges the columns to Year, Author, Title, and Pages. Now m_SortSubitems contains the indices of these columns in the order in which they appear: 1, 0, 3, 2.
The control's SetSortSubitems method uses the control's current column ordering to initialize the m_SortSubitems array. It loops through the columns, saving each column's index in the m_SortSubitems entry corresponding to the column's DisplayIndex property. In the previous example, when the column reordering completes, the Author column (column 0) will be the second column (display position 1) so the code sets m_SortSubitems(1) = 0. Similarly the Year column (column 1) will be in display position 0 so the code sets m_SortSubitems(0) = 1.
The control overrides the base class's OnColumnReordered method to keep m_SortSubitems up to date. The code calls the base class's version of the method so it can perform its normal actions. Those actions include raising the ColumnReordered event so the main program can respond and possibly set e.Cancel to True to cancel the reordering. If e.Cancel is true, the OnColumnReordered method exits without taking further action.
If the event is not being canceled, the control calls SetSortSubitems to initialize the m_SortSubitems array. It then uses the MoveArrayItem method to move the index of the column that is being moved to its new position in the array and calls its Sort method to rearrange the items based on the new column order.
The MoveArrayItem method simply moves an integer from one position in an array to another. The code saves the value that is moving, uses Array.Copy to move the items in the array between the new and old positions, and then reinserts the moved value in its new position. MoveArrayItem is a handy method in its own right.
After the OnColumnReordered method updates the control's m_SortSubitems array, the comparer class's ItemString method can use that array to build comparison strings for items. The following code shows the new version of ItemString:
' Return a string representing this item as a ' null-separated list of the item sub-item values. Private Function ItemString(ByVal listview_item As ListViewItem) _ As String Dim slvw As SortableListView = listview_item.ListView ' Make sure we have the sort sub-items' order. If slvw.m_SortSubitems Is Nothing Then slvw.SetSortSubitems() ' Make an array to hold the sort sub-items' values. Dim num_cols As Integer = slvw.Columns.Count Dim values(num_cols - 1) As String ' Build the list of fields in display order. For i As Integer = 0 To slvw.m_SortSubitems.Length - 1 Dim idx As Integer = slvw.m_SortSubitems(i) ' Get this sub-item's value. Dim item_value As String = "" If idx < listview_item.SubItems.Count Then item_value = listview_item.SubItems(idx).Text End If ' Align appropriately. If slvw.Columns(idx).TextAlign = _ HorizontalAlignment.Right _ Then ' Pad so numeric values sort properly. values(i) = item_value.PadLeft(20) Else values(i) = item_value End If Next i ' Concatenate the values to build the result. Return String.Join(vbNullChar, values) End Function
The preceding code builds an array of strings to hold the item's column values, and initializes the entries to blank strings. The code loops through the entries in the m_SortSubitems array so it considers them in the order in which they currently appear on the control.
Next, the code gets each column's index. When the index is less than the number of objects in the ListViewItem.SubItems collection, then the sub-item exists so the code saves its value in the item_value variable. If the index is greater than or equal to the SubItems count, then this item does not have the corresponding sub-item (for example, there is no Title entry) so the code uses an empty string for the item's value.
If the column is right-aligned, the code pads the value on the left so numeric values sort properly.
After building the values for each field, the function uses String.Join to concatenate the results into a string with fields separated by null characters, and returns the result.
At this point, all the pieces for sorting are in place and the rest is automatic. If the user rearranges columns, the OnColumnReordered method rebuilds the m_SortSubitems array and calls the control's Sort method. Sort uses comparer objects to sort the items. The comparers use the ItemString function shown earlier, which uses the m_SortSubitems array to build the item strings in the correct column order. The result is a list of items sorted on each of the items' fields in their current display order.
More Sort Support
The SortableListView control demonstrates several useful techniques. It shows how to override base class methods (OnColumnClick and OnColumnReordered), move an item in an array (the MoveArrayItem method), and determine the current display order of columns in a ListView control.
More importantly, it shows how to use IComparer classes to implement custom sort orders. ListView controls are not the only objects that use comparer objects to support different sort orders. For example, you can use IComparer objects to sort items in TreeView or DataGridView controls. Various collection classes such as List, ArrayList, Hashtable, NameValueCollection, and SortedDictionary also support sorting with IComparer objects.
After you've mastered the SortableListView control, you'll find uses for IComparer objects all over the place. For example, you'll be able to use different objects to sort customers by name, customer ID, outstanding balance, or date overdue.