C#のWPFでリストビューでアイテムの選択とコンテキストメニュー2「ヘッダークリックでソート」

コンピュータ

前回の問題点として右クリックで表示するコンテキストメニューやダブルクリックの検出がリストビューのアイテム以外でも動作してしまう点がありました。ネット検索して解決方法を探しプログラムに組み込んでみました。また、リストビューの項目のヘッダーをクリックすることでソートする機能も組み込んでみました。

プロジェクトの作成

ソースコード

ファイル名:FileEntity.cs

using System.ComponentModel;
using Reactive.Bindings;

namespace ListViewOnClick;

public class FileEntity : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;
    protected virtual void OnPropertyChanged(string name) =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    public ReactiveProperty<string> Name { get; set; } = new("");
    public ReactiveProperty<string> FullName { get; set; } = new("");

    public ReactiveCommand FilesListViewMenu1Command { get; } = new();
}

ファイル名:MainWindow.xaml

<Window x:Class="ListViewOnClick.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:i="clr-namespace:Microsoft.Xaml.Behaviors;assembly=Microsoft.Xaml.Behaviors"
        xmlns:interactivity="clr-namespace:Reactive.Bindings.Interactivity;assembly=ReactiveProperty.WPF"
        xmlns:local="clr-namespace:ListViewOnClick"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Window.DataContext>
        <local:MainWindowViewModel />
    </Window.DataContext>
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="Loaded">
            <interactivity:EventToReactiveCommand Command="{Binding WindowLoadedCommand}" />
        </i:EventTrigger>
    </i:Interaction.Triggers>
    <Grid>
        <ListView
            x:Name="FilesListView"
            ItemsSource="{Binding Files}"
            SelectedIndex="{Binding FilesListViewSelectedIndex.Value}">
            <ListView.Resources>
                <Style x:Key="listviewHeaderStyle" TargetType="GridViewColumnHeader">
                    <Setter Property="HorizontalContentAlignment" Value="Stretch" />
                </Style>
            </ListView.Resources>
            <ListView.ItemContainerStyle>
                <Style TargetType="{x:Type ListViewItem}">
                    <Setter Property="ContextMenu" >
                        <Setter.Value>
                            <ContextMenu>
                                <MenuItem
                                    Header="メニュー1"
                                    Command="{Binding FilesListViewMenu1Command}"/>
                            </ContextMenu>
                        </Setter.Value>
                    </Setter>
                </Style>
            </ListView.ItemContainerStyle>
            <i:Interaction.Triggers>
                <i:EventTrigger EventName="MouseDoubleClick">
                    <interactivity:EventToReactiveProperty ReactiveProperty="{Binding FilesListViewDoubleClicked}" />
                </i:EventTrigger>
            </i:Interaction.Triggers>
            <ListView.View>
                <GridView ColumnHeaderContainerStyle="{StaticResource listviewHeaderStyle}">
                    <GridViewColumn Width="300">
                        <GridViewColumnHeader Content="名前">
                            <i:Interaction.Triggers>
                                <i:EventTrigger EventName="Click">
                                    <interactivity:EventToReactiveCommand Command="{Binding FilesListViewNameClickCommand}" />
                                </i:EventTrigger>
                            </i:Interaction.Triggers>
                        </GridViewColumnHeader>
                        <GridViewColumn.CellTemplate>
                            <DataTemplate>
                                <StackPanel Orientation="Horizontal">
                                    <TextBlock Width="Auto" TextAlignment="Left" Text="{Binding Name.Value}" />
                                </StackPanel>
                            </DataTemplate>
                        </GridViewColumn.CellTemplate>
                    </GridViewColumn>
                </GridView>
            </ListView.View>
        </ListView>
    </Grid>
</Window>

ファイル名:MainWindowViewModel.cs

using System.ComponentModel;
using Reactive.Bindings;

using System.Diagnostics;
using System.IO;
using System.Reactive.Linq;
using System.Windows.Data;

namespace ListViewOnClick;

public class MainWindowViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;
    protected virtual void OnPropertyChanged(string name) =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    public ReactiveCommand<EventArgs> WindowLoadedCommand { get; }
    public ReactiveCollection<FileEntity> Files { get; set; } = [];
    public ReactiveProperty<int> FilesListViewSelectedIndex { get; set; } = new();
    public ReactiveProperty<System.Windows.Input.MouseButtonEventArgs> FilesListViewDoubleClicked { get; set; } = new();
    public ReactiveCommand<EventArgs> FilesListViewNameClickCommand { get; } = new();
    public MainWindowViewModel()
    {
        WindowLoadedCommand = new ReactiveCommand<EventArgs>()
            .WithSubscribe(e =>
            {
                string targetDir = @"F:\csharp\dotnet8\wpf\ListViewOnClick";
                foreach(var fullname in Directory.EnumerateFileSystemEntries(targetDir))
                {
                    var fe = new FileEntity
                    {
                        Name = new(Path.GetFileName(fullname)),
                        FullName = new(fullname),
                    };
                    fe.FilesListViewMenu1Command.Subscribe(()=>
                    {
                        // コンテキストメニュー1を実行
                        int index = FilesListViewSelectedIndex.Value;
                        Debug.Print($"コンテキストメニュー1インデックス:{fe.FullName.Value}");
                    });
                    
                    Files.AddOnScheduler(fe);
                }
            }
        );
        FilesListViewSelectedIndex
            .Subscribe(e=>
            {
                // リストビューの選択が変更されると実行される。
                int index = e;
                Debug.Print($"選択されたインデックス:{index}");
            }
        );
        FilesListViewDoubleClicked
            .Select(e => new { EventArgs=e, ViewModel = (e?.Source as System.Windows.FrameworkElement)?.DataContext as FileEntity})
            .Where(e => e.ViewModel != null)
            .Subscribe(e=>
            {
                e.EventArgs.Handled = true;
                // ダブルクリックイベント
                int index = FilesListViewSelectedIndex.Value;
                Debug.Print($"ダブルクリックインデックス:{index}");
            }
        );
        FilesListViewNameClickCommand = new ReactiveCommand<EventArgs>()
            .WithSubscribe(e=>
            {
                const string propertyName = "Name.Value";
                var collectionView = CollectionViewSource.GetDefaultView(this.Files);
                if (collectionView.SortDescriptions.Any() == false || collectionView.SortDescriptions[0].PropertyName != propertyName)
                {
                    collectionView.SortDescriptions.Clear();
                    collectionView.SortDescriptions.Add(new SortDescription(propertyName, ListSortDirection.Descending));
                    return;
                }
                var d = collectionView.SortDescriptions[0].Direction == ListSortDirection.Ascending ? ListSortDirection.Descending : ListSortDirection.Ascending;
                collectionView.SortDescriptions.Clear();
                collectionView.SortDescriptions.Add(new SortDescription(propertyName, d));
            });
    }
}

説明

静的に定義するXAMLに動的に生成されるリストビューのアイテムに対してイベントを記述するシンプルな方法は見つけられませんでした。

まず、コンテキストメニューはStyleで生成されるリストビューのアイテムに対して属性としてセットしています。ただこの場合DataSourceがMainWindowViewModelではなく、ListViewItemのオブジェクトであるFileEntityになっているようなので、FileEntityをViewModelとし再設計しています。コンテキストメニューから実行するコマンドはFileEntityに定義してあります。
FileEntityオブジェクトの生成はMainWindowViewModelで行うのでメニューで実行するコードはMainWindowViewModelで記述することができますが、ListViewItemの数分コンテキストメニューが生成されることに成ります。オブジェクトの解放部分は省いていますが、きちんとDispose()してあげないと後々面倒なことに成りそうです。

ダブルクリックはListViewItemのイベントを拾う方法が見つけられなかったので、ListViewのイベントを拾ってEventArgs引数からイベントの発生元がListViewItem(FileEntity)かAsでキャストして判定しています。asが失敗するとNullになるので?だらけになりました。

ヘッダーのクリックは、XAMLでヘッダー項目を定義してそちらでイベントを拾うようにしています。呼び出されるソート部分はFilesからCollectionViewSourceを取り出してSortDescriptionsを追加することで並べ替えを行っています。扱いがこれで良いか自信がないですが、ヘッダーをクリックすするたび並びが反転するようにしてみました。今回は「名前」だけの一項目だけですが、項目が増えると同様なコードを項目の数分増やす必要がありそうです。

いずれも同じリストビューコントロール上でマウスをクリックするイベントを拾っているはずですが、異なる手段で処理を行うことになりました。がんばってViewModel側で処理を行うようにしてみましたが、Viewに依存する部分がほとんどなのでコードビハインドで記述してあげて、そこからViewModelのコマンドを呼び出すような作りの方がしっくりくるような感じがします。

コメント