全列によるソート処理
最初に述べたように、ListViewコントロールは、デフォルトでは最初の列でしかソートを行いません。コントロールの持つすべての列を使ってListViewItemsをソートするには、1つの選択した列でソートする際に用いたのと同様の方法を使います。つまり、IComparerインターフェイスを実装したクラスを作成し、作成したクラスの新しいインスタンスをコントロールのListViewItemSorterプロパティに設定するわけです。
この新しい行比較用のクラスは、先ほど説明したクラスに非常によく似ています。このクラスもItemStringメソッドを使って各ListViewItemの文字列表現を作成し、String.Compareを使ってそれらの比較を行います。しかし、このクラスと先に説明したクラスとの間には大きな違いが2つあります。単純な違いは、新しいクラスでは選択した列だけでなくすべての列を比較できるように、ItemStringメソッドに別のコードを用いていることです。新しいバージョンのメソッドでは、すべてのアイテムの値をnull文字で区切ってつなげた1つの文字列を作成することによって、String.Compareを使った2つのアイテムの比較を容易にしています。
最初の文字列のソート順序が2番目の文字列より先になる場合、String.Compareメソッドは-1を返します。フィールド間の区切りとなるnull文字はASCIIコードが0なので、アルファベット順では他のどんな文字よりも前に来ます。これは、最初の部分が一致していて長さの異なる2つの文字列がある場合、短いフィールドを含む文字列の方がアルファベット順で先になることを意味します(もう一方のフィールドの次の文字が何であろうと、アルファベット順でnull文字の方が前に来るからです)。2つのフィールドが完全に一致する場合は、両文字列のそれ以降の部分によってどちらが先になるかが決まります。
この比較用クラスと先ほどのクラスとの2つ目の違いは、もう少し複雑です。ListViewコントロールでは、列ヘッダをクリックして左右にドラッグすることで、列そのものの順序を並べ替えることができます。これはこのコントロールの便利な機能の1つなので、無効にしたくはありませんでした。ただ残念ながら、列の順序の変更を許すと、アイテムのソートが非常に難しくなってしまうのです。
例として、今回のサンプルプログラムでAuthor、Year、Pages、Titleという当初の配置順で列を表示した様子を図3に示します。各行は降順でソートされており、アルファベット順では「Tom Holt」という名前が最後になるため、この名前が最初に表示されています。彼の2冊の著書はどちらも2006年出版なので、これらの著書の順序はPages列によって決まります。ページ数は『Earth, Air, and Custard』の方が多いため、降順のソートではこの著書が最初に表示されます。
図4に、同じフォームでYear列のヘッダをAuthor列の左にドラッグした後の状態を示します。ここでは、私の著書『Expert One-on-One Visual Basic 2005 Design and Development』が最初に表示されています。このリストのなかで唯一、2007年に出版されたものだからです。
リストの下の方を見ると、行のソートメソッドによるその他の影響が分かります。例えば、Sam Testの著書のうちページ数が1001のものが彼の他の著書より先に表示されているのは、ページ数が最多でソートの向きが降順だからです。同様に、Sam Testの著書のうちPagesの値がないものが彼の著書のなかで一番後に表示されているのは、降順では空の値が最後になるからです。
列の並べ替えの検出には、ベースクラスのOnColumnReorderedメソッドがうってつけに思えるかもしれませんが、実はそうでもありません。このメソッドはまるで列の並べ替え完了後に実行されるかのような名前になっていますが、実際のところ、ベースクラスは列の移動前にこのメソッドを呼び出して対応するColumnReorderedイベントをトリガーさせるため、コントロールが持つ列の新しい並び順まではまだ分かりません(本来、このメソッドとイベントの名前は、それぞれ「OnColumnReordering」と「ColumnReordering」にすべきです。我々の考えでは、本当の意味でのOnColumnReorderedとColumnReordered、あるいはOnColumnReorderStartとOnColumnReorderEndが実現されるとさまざまなメリットがあるはずなのですが)。
列の移動こそまだ完了していませんが、OnColumnReorderedメソッド^^は、移動することになる列とその移動先は教えてくれます。そのため、少し処理を追加しさえすれば、移動完了後のすべての列の位置が分かります。
リスト2は、SortableListViewコントロールがユーザーによる並べ替え後の全列の位置を把握するときに使用するコードを示しています。
Public Class SortableListView ' m_SortSubitems(i) is the i-th sub-item ' in the sort order for all column sorting. Private m_SortSubitems() As Integer = Nothing ' Initialize the sort item order to the order given by the ' column headers. Private Sub SetSortSubitems() ReDim m_SortSubitems(Me.Columns.Count - 1) For i As Integer = 0 To Me.Columns.Count - 1 m_SortSubitems(Me.Columns(i).DisplayIndex) = i Next i End Sub ' The user reordered the columns. Resort. Protected Overrides Sub OnColumnReordered( _ ByVal e As System.Windows.Forms.ColumnReorderedEventArgs) ' This raises the ColumnReordered event. MyBase.OnColumnReordered(e) ' If the main program canceled, do nothing. If e.Cancel Then Exit Sub ' Rebuild the list of sort sub-items. SetSortSubitems() ' Fix the list up to account for the moved column. MoveArrayItem(m_SortSubitems, e.OldDisplayIndex, _ e.NewDisplayIndex) ' Resort. Me.Sort() End Sub ' Move an item from position idx_fr to idx_to. Private Sub MoveArrayItem(ByVal values() As Integer, _ ByVal idx_fr As Integer, ByVal idx_to As Integer) Dim moved_value As Integer = values(idx_fr) Dim num_moved As Integer = Math.Abs(idx_fr - idx_to) If idx_to < idx_fr Then Array.Copy(values, idx_to, values, _ idx_to + 1, num_moved) Else Array.Copy(values, idx_fr + 1, values, _ idx_fr, num_moved) End If values(idx_to) = moved_value End Sub End Class
リスト2のm_SortSubitems配列には、SortableListViewコントロールの各アイテムがどんな順序でソートに用いられるかを示すリストが格納されています。例えば、このコントロールの列がAuthor、Year、Pages、Titleという順で並んでいるとします。この場合、各列は0、1、2、3というインデックスを持ちます。ここで、ユーザーがこれらの列をYear、Author、Title、Pagesという順に並び替えたとします。すると、m_SortSubitemsでは、これらの列のインデックスが新しい表示順に従った値(1、0、3、2)に変わるわけです。
SortableListViewコントロールのSetSortSubitemsメソッドは、このコントロールの現在の列の順序を使ってm_SortSubitems配列を初期化します。各列のループ処理により、その列のDisplayIndexプロパティに対応したm_SortSubitemsエントリのインデックスを取得します。先ほどの例では、列の並べ替えが完了したときにAuthor列(列0)が2番目の列(表示位置1)になるため、このコードによってm_SortSubitems(1)=0に設定されます。同様に、Year列(列1)は表示位置が0になるため、このコードによってm_SortSubitems(0)=1に設定されます。
SortableListViewコントロールは、m_SortSubitemsを最新の状態に保つために、ベースクラスのOnColumnReorderedメソッドをオーバーライドしています。SortableListViewのコードが呼び出すのはベースクラスのメソッドであるため、通常の動作を実行することができます。この動作にはColumnReorderedイベントの発生が含まれるので、メインプログラムはこのイベントに応答し、場合によってはe.CancelをTrue(真)に設定して列の並べ替えを取り消すことができます。e.Cancelが真の場合、OnColumnReorderedメソッドはそれ以上の動作を行わずに終了します。
このイベントがキャンセルされない場合、SortableListViewコントロールはSetSortSubitemsを呼び出してm_SortSubitems配列を初期化します。続いて、MoveArrayItemメソッドを使って、移動する列のインデックスをこの配列内の新しい位置に移動させ、配列のSortメソッドを呼び出して新しい列の並び順に基づいて各アイテムの並べ替えを行います。
MoveArrayItemは、整数値を配列内のある場所から別の場所へと移動させるだけのメソッドです。このメソッドは、まず移動させる値を保持し、その新しい位置と古い位置との間にある各要素の位置をArray.Copyを使って1つずつシフトさせたうえで、移動させる値を新しい位置に再び挿入します。MoveArrayItemは、それ単体でも便利なメソッドです。
OnColumnReorderedメソッドによってSortableListViewコントロールのm_SortSubitems配列の更新が終わったら、比較用クラスのItemStringメソッドを使ってこの配列から各要素の比較用文字列を作成できます。以下に、新しいバージョンの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
先ほどのコードでは、アイテムの列値を保持するために文字列の配列を作成し、空の文字列で各要素を初期化していました。今回のコードでは、m_SortSubitems配列の各要素をループで処理しているため、現在コントロール上に現れている順序で各要素を扱います。
続いて、各列のインデックスを取得します。インデックスがListViewItem.SubItemsコレクション内のオブジェクト数よりも小さいときは、該当するサブアイテムが存在するのでその値がitem_value変数に保存されます。インデックスがSubItemsの要素数と同じかそれより大きいときは、このアイテムには対応するサブアイテムがない(例えば、Titleエントリが存在しない)のでアイテムの値として空の文字列が使われます。
列が右詰めの場合は、数値のソートが正しく行われるように、値の左側に空白が追加されます。
各フィールドを表す文字列値の作成が終わると、それらはString.Joinによって、null文字で区切られた1つの文字列に連結され、その結果が返されます。
この時点で、ソートを行うためのすべての準備が整ったことになり、残りの処理は自動的に行われます。ユーザーが列の並べ替えを行った場合は、OnColumnReorderedメソッドにより、m_SortSubitems配列が再構築され、SortableListViewコントロールのSortメソッドが呼び出されます。Sortメソッドは、比較用オブジェクトを使ってアイテムのソートを行います。この際、前述のItemStringメソッドが使われ、このメソッドはm_SortSubitems配列を使って正しい列順でアイテムの文字列を作成します。その結果、現在表示されている列の順序を考慮してソートされたアイテムのリストが得られます。
応用
SortableListViewコントロールのコードには、役に立つテクニックがいくつか含まれています。例えば、ベースクラスのメソッド(OnColumnClickおよびOnColumnReordered)のオーバーライド、配列内の要素の移動(MoveArrayItemメソッド)、ListViewコントロールにおける現在の列表示順の判断といった部分です。
もっと重要なのは、IComparerクラスを使ってカスタムのソート順を実現する方法の部分です。比較用オブジェクトを使ってさまざまなソート順に対応できるオブジェクトは、ListViewコントロールだけではありません。例えば、IComparerオブジェクトを使ってTreeViewコントロールまたはDataGridViewコントロール内のアイテムをソートすることもできます。List、ArrayList、Hashtable、NameValueCollection、SortedDictionaryといったさまざまなコレクションクラスも、IComparerオブジェクトによるソート処理に対応しています。
SortableListViewコントロールを習得すれば、どこでもIComparerオブジェクトを使いこなせるようになります。例えば、いろいろなオブジェクトを使って、顧客データを名前、ID、未払い残高、支払い期限などの基準でソートすることが可能になります。


