DataGridViewで自然順ソート

タグの編集
投稿者 たなやん  (社会人) 投稿日時 2021/6/4 11:22:33
Windows 10 VB2019を使用しています。

DataGridViewに日付とファイル名の文字列を直接追加してファイルを管理するツールを作っています。
それぞれの列のヘッダーをクリックするとこのように文字列でソートされます。

file1.txt
file10.txt
file2.txt
file3.txt

これをエクスプローラのファイル順のような自然順で昇順・降順ソートを行いたいのですが、どうすればよいのでしょうか?

昇順ソート
file1.txt
file2.txt
file3.txt
file10.txt

降順ソート
file10.txt
file3.txt
file2.txt
file1.txt

宜しくお願いします。
投稿者 魔界の仮面弁士  (社会人) 投稿日時 2021/6/4 12:17:09
> エクスプローラのファイル順
この順序は、 StrCmpLogicalW API の動作によるものです。
https://docs.microsoft.com/en-us/windows/win32/api/shlwapi/nf-shlwapi-strcmplogicalw
https://wiki.dobon.net/index.php?.NET%A5%D7%A5%ED%A5%B0%A5%E9%A5%DF%A5%F3%A5%B0%B8%A6%B5%E6%2F111

Windows バージョンによって並び順が変化することにご注意ください。
レジストリ設定すれば、StrCmpLogical を使わないようにすることもできます。
https://316-jp.com/windows-sort-name

StrCmpLogicalW などによるカスタムソートを DataGridView に組み込む場合、
DataSource が未設定なら、SortCompare イベントを利用できます。
https://dobon.net/vb/dotnet/datagridview/customsort.html#section3

DataSrouce を割り当てている場合は、そのデータソースの並び替え機構に依存します。

LINQ を使えるなら、OrderBy 拡張メソッドの IComparer(Of ) を受け取るオーバーロードを
併用するというのも一つのです。
投稿者 たなやん  (社会人) 投稿日時 2021/6/4 14:57:29
お返事ありがとうございます。
ヘッダーをクリックした時にSortCompareイベントでソートを行う動作を設定できる事を理解出来ました。

紹介頂いたStrCmpLogicalW APIを利用してソートを行うのが簡単そうなので、これを利用しようとしてみましたが正しい並び替えになりませんでした。
APIの呼び出し方が間違っているのでしょうか?
また降順にしたい時はAPIの呼び出しにオプション引数等が必要なのでしょうか?

[code]
Private Sub DataGridView1_SortCompare(ByVal sender As Object, ByVal e As DataGridViewSortCompareEventArgs) Handles DataGridView1.SortCompare

       DataGridView1.Sort(New LogicalStringComparer())
       e.Handled = True

End Sub

Public Class LogicalStringComparer
    Implements System.Collections.IComparer
    Implements System.Collections.Generic.IComparer(Of String)

    <System.Runtime.InteropServices.DllImport("shlwapi.dll",
        CharSet:=System.Runtime.InteropServices.CharSet.Unicode,
        ExactSpelling:=True)>
    Private Shared Function StrCmpLogicalW(x As String, y As String) As Integer
    End Function

    Public Function Compare(x As String, y As String) As Integer _
        Implements System.Collections.Generic.IComparer(Of String).Compare
        Return StrCmpLogicalW(x, y)
    End Function

    Public Function Compare(x As Object, y As Object) As Integer _
        Implements System.Collections.IComparer.Compare
        Return Me.Compare(x.ToString(), y.ToString())
    End Function
End Class

[/code]
投稿者 魔界の仮面弁士  (社会人) 投稿日時 2021/6/4 16:19:23
> [code]
code ではなく CODE です。


> 正しい並び替えになりませんでした。
複数列の並び替えが必要な場合は IComparer が必要ですが、
単一列の並び替えなら SortCompare イベントだけで済みますよ。

Public Class Form1
    Private WithEvents dgv As DataGridView
    Private Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load
        dgv = New DataGridView() With {.Dock = DockStyle.Fill}
        dgv.AllowUserToAddRows = False
        dgv.ColumnCount = 2
        Controls.Add(dgv)
        dgv.Rows.Add(1, "file1.txt")
        dgv.Rows.Add(2, "file10.txt")
        dgv.Rows.Add(3, "file2.txt")
        dgv.Rows.Add(4, "file3.txt")
    End Sub
    Private Sub dgv_SortCompare(sender As Object, e As DataGridViewSortCompareEventArgs) Handles dgv.SortCompare
        If e.Column.Index = 1 Then
            e.SortResult = StrCmpLogicalW(e.CellValue1, e.CellValue2)
            e.Handled = True
        Else
            e.Handled = False
        End If
    End Sub
    Private Declare Unicode Function StrCmpLogicalW Lib "shlwapi" (x As String, y As StringAs Integer
End Class
投稿者 魔界の仮面弁士  (社会人) 投稿日時 2021/6/4 16:36:07
追記:
先のコードは、列の SortMode プロパティが Automatic という前提です。

> e.SortResult = StrCmpLogicalW(e.CellValue1, e.CellValue2)

Option Strict On の場合は上記を
 e.SortResult = StrCmpLogicalW(e.CellValue1?.ToString(), e.CellValue2?.ToString())
にします。


> また降順にしたい時はAPIの呼び出しにオプション引数等が必要なのでしょうか?
DataGridView 側でソート方向を見繕うので、呼び方は変わらないです。

ソートモードをプログラム制御する場合は、ColumnHeaderMouseClick イベントにて、
たなやんさんが書かれたように ​DataGridView1.Sort(System.Collections.IComparer) を呼び出します。

この場合、IComparer.Compare メソッドの引数は DataGridViewRow 型となりますので、
やろうと思えば複数列によるソートを実装することも出来るでしょう。
複数列ソートの場合に、ソート方向を表す三角グリフを描画したい場合には、
    dgv.Columns(0).HeaderCell.SortGlyphDirection = SortOrder.Ascending
    dgv.Columns(1).HeaderCell.SortGlyphDirection = SortOrder.Descending
などとして個別にマークします。
投稿者 たなやん  (社会人) 投稿日時 2021/6/4 19:01:33
ありがとうございます。
目的は単一列の並び替えでしたので、ご提示頂いたコードで無事に希望通りの並び替えが出来ました。
プログラミング次第で複雑なソートも可能という事も知りとても勉強になります。

>Option Strict On の場合は上記を
> e.SortResult = StrCmpLogicalW(e.CellValue1?.ToString(), e.CellValue2?.ToString())
>にします。

Option StrictはOnでしたので上記に変更しましたところエラーは消えました。
ただこのe.CellValue1とe.CellValue2の後ろに「?」を付けても付けなくても結果は同じでしたが、何か意味があるのでしょうか?
投稿者 魔界の仮面弁士  (社会人) 投稿日時 2021/6/4 20:36:05
> ただこのe.CellValue1とe.CellValue2の後ろに「?」を付けても付けなくても結果は同じでしたが、何か意味があるのでしょうか?

先のサンプルに
 dgv.Rows.Add(5, Nothing)
 dgv.Rows.Add(6, DBNull.Value)
を含めた状態で、 ? の有無を確認してみてください。

Dim x = e.CellValue1?.ToString()
上記のコードは下記と同じ意味です。
Dim x As String = If(e.CellValue1 IsNot Nothing, e.CellValue1.ToString(), Nothing)
投稿者 たなやん  (社会人) 投稿日時 2021/6/4 23:24:31
なるほど、変数の後ろに?を付ければ文字列がnullやNothingだった場合の条件式を書く必要がなくなるんですね。また一つ勉強になりました。

VBはまだまだ分からない事が多いですが、今後ともよろしくお願いします。 
投稿者 たなやん  (社会人) 投稿日時 2021/6/22 19:35:20
引き続きすみません、データ管理の都合によりDataGridViewにDataSrouceを割り当てて表示するように仕様変更をしたところ自然順ソートが機能しなくなりました。

調べてみたところDataSourceを割り当てるとSortCompareイベントが作動しなくなるようなので、余談で教えて頂いたColumnHeaderMouseClickイベントでプログラム制御する方法ならソートが出来そうなのですが、この場合でのStrCmpLogicalW APIの呼び出し方が分かりません。
お手数おかけしますが再度ご教授をよろしくお願いいたします。

    Public Declare Unicode Function StrCmpLogicalW Lib "shlwapi" (x As String, y As StringAs Integer
    Private bFlag As Boolean '昇順、降順フラグ 

    Private Sub DataGridView1_ColumnHeaderMouseClick(sender As Object, e As DataGridViewCellMouseEventArgs) Handles DataGridView1.ColumnHeaderMouseClick

        Dim dt As DataTable = CType(DataGridView1.DataSource, DataTable)
        Dim dv As DataView = dt.DefaultView

        bFlag = Not bFlag


        For Each SelectedColumn As DataGridViewColumn In DataGridView1.SelectedColumns

            If SelectedColumn.Index = 0 Then

                'dv.Sort = StrCmpLogicalW(dv.CellValue1?.ToString(), dv.CellValue2?.ToString())  '1列目のソートAPI呼び出し(エラー) 

                If bFlag Then
                    DataGridView1.Columns(0).HeaderCell.SortGlyphDirection = SortOrder.Ascending
                Else
                    DataGridView1.Columns(0).HeaderCell.SortGlyphDirection = SortOrder.Descending
                End If

            ElseIf SelectedColumn.Index = 1 Then

                'dv.Sort = StrCmpLogicalW(dv.CellValue1?.ToString(), dv.CellValue2?.ToString())  '2列目のソートAPI呼び出し(エラー) 

                If bFlag Then
                    DataGridView1.Columns(1).HeaderCell.SortGlyphDirection = SortOrder.Ascending
                Else
                    DataGridView1.Columns(1).HeaderCell.SortGlyphDirection = SortOrder.Descending
                End If

            End If

        Next

        DataGridView1.DataSource = dv

    End Sub

投稿者 魔界の仮面弁士  (社会人) 投稿日時 2021/6/23 14:34:05
File テーブルの 0 列目にある "FullPath" フィールドで並び替える例を書いてみました。

ここでは単一列で並び替えていますが、もしも複数列で並び替えるなら、
Overrides Function Compare 内の処理を増やすか、もしくは
ColumnHeaderMouseClick で LINQ の ThenBy を併用すれば OK です。

Private ds As DataeSet   'DataSet/DataTable の構築部は省略 

Private Sub 何某()
    Me.DataGridView1.DataSource = Me.ds.Tables("File")  'File テーブルがバインドされていたとする 
    Me.DataGridView1.Columns(0).SortMode = DataGridViewColumnSortMode.Programmatic
End Sub

Private Sub DataGridView1_ColumnHeaderMouseClick(sender As Object, e As DataGridViewCellMouseEventArgs) Handles DataGridView1.ColumnHeaderMouseClick
    If e.ColumnIndex = 0 Then
        Dim order = SortOrder.Ascending
        If Me.DataGridView1.Columns(0).HeaderCell.SortGlyphDirection = SortOrder.Ascending Then
            order = SortOrder.Descending
        End If
        Dim q = Me.ds.Tables("File").AsEnumerable()
        q = q.OrderBy(Function(r) r, New RowSorter(order, "FullPath"))  'LINQ で並び替え 
        Me.DataGridView1.DataSource = q.CopyToDataTable()  'DataTable に復元 
        Me.DataGridView1.Columns(0).HeaderCell.SortGlyphDirection = order
    End If
End Sub

Private Class RowSorter
    Inherits Comparer(Of DataRow)
    Private Declare Unicode Function StrCmpLogicalW Lib "shlwapi" (x As String, y As StringAs Integer
    Private ReadOnly order As SortOrder
    Private ReadOnly fieldName As String
    Public Sub New(order As SortOrder, fieldName As String)
        Me.order = order
        Me.fieldName = fieldName
    End Sub

    Public Overrides Function Compare(x As DataRow, y As DataRow) As Integer
        If order = SortOrder.Descending Then
            Return StrCmpLogicalW(y(fieldName)?.ToString(), x(fieldName)?.ToString())
        Else
            Return StrCmpLogicalW(x(fieldName)?.ToString(), y(fieldName)?.ToString())
        End If
    End Function
End Class
投稿者 やなやん  (社会人) 投稿日時 2021/6/23 19:02:47
魔界の仮面弁士様、いつもありがとうございます。
仕様変更の説明が不足しておりました。
データの管理はDataTable一つのみで行っておりDataSetは使用していません。

ご提示いただいたコードにDataSetを作成してその中に管理しているDataTableとテーブル名を追加する事で自然順ソートが動作する事を確認できました。
ただそれに伴い他の全てのコードをDataSetに対応させなければならないため、大変恐縮なのですが直接DataTable内の単一列を並び替える方法があればそちらも教えて頂けないでしょうか。
よろしくお願いいたします。
投稿者 魔界の仮面弁士  (社会人) 投稿日時 2021/6/23 19:18:45
やなやんさんはたなやんさんの同僚の方なのでしょうか。


> データの管理はDataTable一つのみで行っておりDataSetは使用していません。
自分のコードもそうですよ?

DataSet をバインドしているのであれば、DataSource だけでなく、
DataMember も指定しているはず。

そもそも、
 Dim tbl As DataTable = Me.ds.Tables("File")
ですよね。実質的に使っているのは DataTable だけです。

つまり、DataGridView1.DataSource に割り当てているのは DataTable ですし、
Dim q = Me.ds.Tables("File").AsEnumerable() というのも、
Dim q = Me.yourTable.AsEnumerable() でしかありせん。


そして LINQ でソート後、複数行の DataRow を DataTable に復元するのは、
先に示した CopyToDataTable 拡張メソッドです。


なお、DataSource に割り当てられるインスタンスは、
CopyToDataTable によって、毎回、新しいテーブルに差し変わります。

そのため、
> Dim dt As DataTable = CType(DataGridView1.DataSource, DataTable)
などとするのではなく、最初に取得した DataTable / DataSet / DataView 等を
フィールド変数に保持しておき、それをソート対象にするのが良いと思います。
投稿者 たなやん  (社会人) 投稿日時 2021/6/23 22:21:53
ご回答ありがとうございます。
名前を記入する際にTとYのキーを打ち間違えていました。。

すみません、お恥ずかしい話データベースプログラミングは今回が初めてなのとLINQは未経験です。

> Private ds As DataeSet
ご提示いただいたコードの最初の行でDataSetを宣言されていたのと
> Dim q = Me.ds.Tables("File").AsEnumerable()
宣言されたdsのFileテーブルに対してメソッドを呼び出されていたのでDataSetにして使用できないかと思っておりました。
先ほどAsEnumerableについて調べてみたところ、これはLINQのメソッドでDataTableにも使用できるのですね。
大変失礼しました。

データを管理しているDataTableはグロバール変数に保持していますので、
下記のように書き換えてみたところDataTable自体の並び替えが出来ました、ありがとうございます。
Dim q = MyDataTable.AsEnumerable()


余談なのですがソートする際に、こちらのテーブル列(FullPath)を文字列ではなく列番号で指定することは可能でしょうか?
> q = q.OrderBy(Function(r) r, New RowSorter(order, "FullPath"))  'LINQ で並び替え 
投稿者 魔界の仮面弁士  (社会人) 投稿日時 2021/6/24 10:47:38
> テーブル列(FullPath)を文字列ではなく列番号で指定することは可能でしょうか?
そういう実装に書き換えるだけで良いと思いますが、どの点が問題になっているのでしょうか?

先のサンプルで、列番号ではなくあえて列名を使用したのは、
「DataGridView の列番号」と「DataTable の列番号」を混同しないようにするため、
あえて違う型にしただけです。

たとえば、DataGridView にバインドさせていない非表示列をソート用に使うこともできますが、
まず、クリックした列をそのままソートに使いたいのであれば、
  Dim fieldName = DataGridView1.Columns(e.ColumnIndex).DataPropertyName
のようにして、DataTable の列名を受け取ることができます。


そもそも、今回のソートの肝となるのは
>> Public Overrides Function Compare(x As DataRow, y As DataRow) As Integer
に対する
>> Return StrCmpLogicalW(x(fieldName)?.ToString(), y(fieldName)?.ToString())
なわけですよね。

DataRow のインデクサには、
 『列名』な String
 『列番号』な Integer
 『列オブジェクト』な DataColumn
のいずれも受け付けるので、列番号で渡せるようにしたいのであれば、
単にそういう型の変数や引数を用意するだけで、列番号指定バージョンに作り替えられるでしょう。
https://docs.microsoft.com/ja-jp/dotnet/api/system.data.datarow.item?view=netframework-4.8#System_Data_DataRow_Item_System_Data_DataColumn_
投稿者 たなやん  (社会人) 投稿日時 2021/6/24 20:44:06
ご返事ありがとうございます。
文字列で列指定する方法で全く問題ありません。
もし列番号で指定したい場合にはどのように書けばよいのかふと疑問に思ったため余談でご質問させて頂きました。

捕捉についてもご丁寧なご解説を頂きとても分かりやすかったです。
ひとまずご提示頂いたコードで進めていきたいと思います。
ありがとうございました。