Search code examples
powershellwinformssortinglistview

Powershell Winforms - How to sort Listview by column when items grouped


Consider the below. The SortListView function works perfectly if the ListView contains ungrouped items.

Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.Application]::EnableVisualStyles()
$form = New-Object System.Windows.Forms.Form
$form.Text = "Test"
$form.Size = '500,375'
$form.StartPosition = 'CenterScreen'
$form.MaximizeBox = $false
# List of sessions
$LogonList = New-Object System.Windows.Forms.ListView
$LogonList.View = 'Details'
$LogonList.Location = '10,20'
$LogonList.Size = '465,305'
$LogonList.FullRowSelect = $true
$LogonList.Columns.Add('UID',80) | Out-Null
$LogonList.Columns.Add('IPAddress',100) | Out-Null
$LogonList.Columns.Add('HostName',160) | Out-Null
$LogonList.Add_ColumnClick({SortListView $this $_.Column})

$form.Controls.Add($LogonList)
#
Function SortListView {
    Param(
        [System.Windows.Forms.ListView]$xsender,
        $column
    )
    $Script:SortingDescending = !$Script:SortingDescending
    $xsender.Sorting = 'none'
    If (!$xsender.Groups) {
        #
        # No groups in ListView - sort whole list by clicked column property
        #
        $xsender.ShowGroups = $false
        $temp = $xsender.Items | Foreach-Object { $_ }
        $xsender.Items.Clear()
        $xsender.Items.AddRange(($temp | Sort-Object -Descending:$script:SortingDescending -Property @{ Expression={ $_.SubItems[$column].Text } }))
    }
    Else {
        #
        # ListView is grouped, sort each group by clicked column property.
        #
        $xsender.ShowGroups = $true
        $temp = $xsender.Items | Foreach-Object { $_ }
        Write-Host "ListView groups:"
        $temp | Group-Object -Property Group | ForEach-Object {
            Write-Host $_.Name
        }
        $xsender.Items.Clear() 
    }
}
#
$TestData = New-Object System.Collections.ArrayList
$TestData.Add([pscustomobject]@{UID='userjoe';IPAddress='192.168.150.14';Hostname='Workstation99'}) | Out-Null
$TestData.Add([pscustomobject]@{UID='userjoe';IPAddress='192.168.150.15';Hostname='Workstation100'}) | Out-Null
$TestData.Add([pscustomobject]@{UID='userjoe';IPAddress='192.168.150.16';Hostname='Workstation101'}) | Out-Null
$TestData.Add([pscustomobject]@{UID='userjoe';IPAddress='192.168.150.17';Hostname='Workstation102'}) | Out-Null
$TestData.Add([pscustomobject]@{UID='userdave';IPAddress='192.168.150.13';Hostname='Workstation104'}) | Out-Null
$TestData.Add([pscustomobject]@{UID='userdave';IPAddress='192.168.150.12';Hostname='Workstation105'}) | Out-Null
$TestData.Add([pscustomobject]@{UID='userdave';IPAddress='192.168.150.11';Hostname='Workstation106'}) | Out-Null
$TestData.Add([pscustomobject]@{UID='userdave';IPAddress='192.168.150.10';Hostname='Workstation107'}) | Out-Null
#
$TestData | Group-Object -Property UID | ForEach-Object {
    $ThisEntry = New-Object System.Windows.Forms.ListViewGroup
    $ThisEntry.Header = $_.Name
    $LogonList.Groups.Add($ThisEntry) |Out-Null
    $DuplicateUIDs = $_.Group | Sort-Object {$_.IPAddress} -Descending 
    $DuplicateUIDs | ForEach-Object {
        $Entry = New-Object System.Windows.Forms.ListViewItem('-') -ErrorAction Stop
        $Entry.SubItems.Add([string]$_.IPAddress) | Out-Null
        $Entry.SubItems.Add([string]$_.HostName) | Out-Null
        $Entry.Group = $ThisEntry
        # Add compiled object to ListView box
        $LogonList.Items.Add($Entry) | Out-Null
    }
}
$form.Activate()
$form.ShowDialog() | Out-Null
$form.Dispose()

I'd like to code for the same behaviour when the items are grouped - in the above example clicking the column header should order all the group items within the groups by that column. I'm really struggling to get my head round the different objects/item assignments and come up with a sensible solution - any help appreciated.


Solution

  • if you are using PowerShell 5 or above, you can achieve ListView sort in an easier and faster way :

    First, implement a Comparer Interface somewhere in your code (you can add it right before your sort function) :

    class ListViewItemComparer : System.Collections.IComparer
    {
        [int]$col
        [System.Windows.Forms.SortOrder]$order
    
        ListViewItemComparer()
        {
             $this.col = 0
             $this.order = [System.Windows.Forms.SortOrder]::Ascending
        }
        ListViewItemComparer([int]$column, [System.Windows.Forms.SortOrder]$sortOrder)
        {
             $this.col = $column
             $this.order = $sortOrder
        }
        [int]Compare([object]$x, [object]$y)
        {
             $result = [String]::Compare( `
                       ([System.Windows.Forms.ListViewItem]$x).SubItems[$this.col].Text,`
                       ([System.Windows.Forms.ListViewItem]$y).SubItems[$this.col].Text);
             if ($this.order -eq [System.Windows.Forms.SortOrder]::Ascending)
             {
                  return $result
             }
             else
             {
                  return -($result)
             }
        }
    }
    

    then your sort function will be as simple by setting the ListViewItemSorter Property to a new comparer interface :

    Function SortListView {
         Param(
              [System.Windows.Forms.ListView]$xsender,
              $column
         )
         $Script:SortingDescending = !$Script:SortingDescending
         if ($Script:SortingDescending)
         {
              $xsender.Sorting = [System.Windows.Forms.SortOrder]::Descending
         }
         else
         {
              $xsender.Sorting = [System.Windows.Forms.SortOrder]::Ascending
         }
         $xsender.ListViewItemSorter = [ListViewItemComparer]::new($column, $xsender.Sorting)
    }
    

    This works whatever there is a group or not.

    some more information here and here

    EDIT :

    Do not forget this line before creating your form

    Add-Type -AssemblyName System.Windows.Forms
    

    If you are using an old version of PowerShell, implement the compare interface with C# this way :

    $compareCode = @"
    using System;
    using System.Collections;
    using System.Windows.Forms;
    
    namespace Tools
    {
        public class ListViewItemComparer : IComparer
        {
            private int col;
            private SortOrder order;
            public ListViewItemComparer()
            {
                col = 0;
                order = SortOrder.Ascending;
            }
            public ListViewItemComparer(int column, SortOrder sortOrder)
            {
                col = column;
                order = sortOrder;
            }
            public int Compare(object x, object y)
            {
                int result = String.Compare(
                             ((ListViewItem)x).SubItems[col].Text,
                             ((ListViewItem)y).SubItems[col].Text);
                if (order == SortOrder.Ascending)
                {
                    return result;
                }
                else
                {
                    return -result;
                }
            }
        }
    }
    "@
    
    Add-Type -TypeDefinition $compareCode -ReferencedAssemblies System.Windows.Forms
    

    then call the interface in your sort function (change only this line) :

    $xsender.ListViewItemSorter = [Tools.ListViewItemComparer]::new($column, $xsender.Sorting)
    

    EDIT 2

    The PowerShell class option needs to call Add-Type -AssemblyName System.Windows.Forms BEFORE you run the script in order to work correctly. This is due to the way PowerShell is parsing code before execution.

    #Call script
    Add-Type -AssemblyName System.Windows.Forms
    .\MyFormScriptWithICompareClass.ps1
    

    The C# version does not have this issue.