XAMLを使わないWPF入門36「ListViewでMVVM」

コンピュータ

ファイルマネージャのファイルの一覧表示部分をMVVMで実装しました。

実装した機能は、ディレクトリをダブルクリックするとカレントディレクトリを移動します。
ヘッダーをクリックするとソートします。

それだけですが、ソースコードは結構な大きさになってしまいました。


ソースコード

ファイル名:FileSelector02.csproj


<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net9.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWPF>true</UseWPF>
  </PropertyGroup>

</Project>

ファイル名:App.cs



using System;
using System.Windows;

namespace FileSelector02;

public class App : Application
{
    [STAThread]
    public static void Main(string[] args)
    {
        var app = new App();
        app.Run();
    }

    protected override void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);
        var win = new MainWindow();
        win.Show();
    }
}

ファイル名:AssemblyInfo.cs


using System.Windows;

[assembly:ThemeInfo(
    ResourceDictionaryLocation.None,            //where theme specific resource dictionaries are located
                                                //(used if a resource is not found in the page,
                                                // or application resource dictionaries)
    ResourceDictionaryLocation.SourceAssembly   //where the generic resource dictionary is located
                                                //(used if a resource is not found in the page,
                                                // app, or any theme specific resource dictionaries)
)]

ファイル名:Helper\ObservableObject.cs


using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace FileSelector02;

public abstract class ObservableObject : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    protected bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
    {
        if (Equals(field, value)) return false;
        field = value;
        OnPropertyChanged(name);
        return true;
    }

    protected void OnPropertyChanged([CallerMemberName] string? name = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    }
}

ファイル名:Helper\RelayCommand.cs


using System;
using System.Windows.Input;

namespace FileSelector02;

public sealed class RelayCommand : ICommand
{
    private readonly Action<object?> _exec;
    private readonly Func<object?, bool>? _can;
    public RelayCommand(Action<object?> exec, Func<object?, bool>? can = null)
    { _exec = exec; _can = can; }
    public bool CanExecute(object? p) => _can?.Invoke(p) ?? true;
    public void Execute(object? p) => _exec(p);
    public event EventHandler? CanExecuteChanged;
    public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

ファイル名:Helper\ViewModelBase.cs


using System.ComponentModel;

public abstract class ViewModelBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;
    protected void OnPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string? name = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

ファイル名:Model\FileSelectorItem.cs


namespace FileSelector02;

public class FileSelectorItem
{
    public string DisplayName { get; set; } = "";
    public string FullPath { get; set; } = "";
    public bool IsDir { get; set; }
    public bool IsUpEntry { get; set; }   // 追加: 「..」専用フラグ
    public long Size { get; set; }

    public override string ToString() => DisplayName;
}

ファイル名:Service\FileSystemService.cs


using System.IO;

namespace FileSelector02;
public sealed class FileSystemService : IFileSystemService
{
    public IEnumerable<FileSelectorItem> Enumerate(string dir)
    {
        // "__root__" なら論理ドライブ一覧を返す(IsReady==true のみ)
        if (string.Equals(dir, "__root__", StringComparison.OrdinalIgnoreCase))
        {
            foreach (var dv in DriveInfo.GetDrives())
            {
                bool ready = false;
                try { ready = dv.IsReady; } catch { ready = false; }
                if (!ready) continue;

                string display = dv.Name; // "C:\\"
                try
                {
                    if (!string.IsNullOrEmpty(dv.VolumeLabel))
                        display = $"{dv.Name} ({dv.VolumeLabel})";
                }
                catch { /* ラベル取得で例外になる環境は無視 */ }

                long size = 0;
                try { size = dv.TotalSize; } catch { /* 権限等で取れない場合は0 */ }

                yield return new FileSelectorItem
                {
                    DisplayName = display,
                    FullPath = dv.RootDirectory.FullName, // 例: "C:\\"
                    IsDir = true,
                    Size = size, // TotalSize を表示(不要なら 0 でもOK)
                    
                };
            }
            yield break;
        }

        // ここから通常ディレクトリ
        DirectoryInfo cur;
        try { cur = new DirectoryInfo(dir); }
        catch { yield break; }

        var parent = cur.Parent;
        if (parent != null)
        {
            // 通常の親へ戻る ".."
            yield return new FileSelectorItem
            {
                DisplayName = "..",
                FullPath = parent.FullName,
                IsDir = true,
                Size = 0,
                IsUpEntry = true,     // ← 追加
            };
        }
        else
        {
            // ドライブ直下 (例: C:\) は "__root__" へ戻せるようにする
            yield return new FileSelectorItem
            {
                DisplayName = "..",
                FullPath = parent != null ? parent.FullName : "__root__",
                IsDir = true,
                Size = 0,
                IsUpEntry = true,     // ← 追加
            };
        }
        IEnumerable<string> dirs = Array.Empty<string>();
        try { dirs = Directory.EnumerateDirectories(dir); } catch { /* アクセス不可などは無視 */ }

        foreach (var d in dirs)
        {
            var di = new DirectoryInfo(d);
            if ((di.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) continue;
            if ((di.Attributes & FileAttributes.System) == FileAttributes.System) continue;

            yield return new FileSelectorItem
            {
                DisplayName = di.Name,
                FullPath = di.FullName,
                IsDir = true,
                Size = 0,
            };
        }

        IEnumerable<string> files = Array.Empty<string>();
        try { files = Directory.EnumerateFiles(dir); } catch { }

        foreach (var f in files)
        {
            var fi = new FileInfo(f);
            if ((fi.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) continue;
            if ((fi.Attributes & FileAttributes.System) == FileAttributes.System) continue;

            yield return new FileSelectorItem
            {
                DisplayName = fi.Name,
                FullPath = fi.FullName,
                IsDir = false,
                Size = fi.Length,
            };
        }
    }
}

ファイル名:Service\IFileSystemService.cs


namespace FileSelector02;
public interface IFileSystemService
{
    IEnumerable<FileSelectorItem> Enumerate(string directoryPath);
}

ファイル名:View\ByteSizeConverter.cs



using System.Globalization;
using System.Windows.Data;

public sealed class ByteSizeConverter : IValueConverter
{
    // parameter 例:
    //  "KB"      … KB固定(少数0桁)
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        if (value is not long bytes) return "";
        if (bytes < 0) bytes = 0; // 念のため

        double size = bytes;

        size = bytes / 1024.0;

        string format = "N0";
        return $"{size.ToString(format, culture)} KB";
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        => throw new NotSupportedException();
}

ファイル名:View\FileSelector.cs


using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Media;

namespace FileSelector02;

public class FileSelector : ListView
{
    private static readonly IValueConverter ByteSizeConv = new ByteSizeConverter();

    public FileSelector()
    {
        // ItemsSource: ICollectionView
        SetBinding(ItemsControl.ItemsSourceProperty, new Binding(nameof(FileSelectorViewModel.View)));

        // 行の水平ストレッチ
        var style = new Style(typeof(ListViewItem));
        style.Setters.Add(new Setter(ListViewItem.HorizontalContentAlignmentProperty, HorizontalAlignment.Stretch));
        ItemContainerStyle = style;

        // Name列テンプレート(アイコン+名前)
        var nameTemplate = new DataTemplate();
        var nameStack = new FrameworkElementFactory(typeof(StackPanel));
        nameStack.SetValue(StackPanel.OrientationProperty, Orientation.Horizontal);

        // ① アイコン TextBlock(デフォルトはファイル📄)
        var iconTb = new FrameworkElementFactory(typeof(TextBlock));
        iconTb.Name = "Icon";
        iconTb.SetValue(TextBlock.TextProperty, "📄");
        iconTb.SetValue(TextBlock.FontSizeProperty, 12.0);
        iconTb.SetValue(FrameworkElement.MarginProperty, new Thickness(0, 0, 6, 0));
        nameStack.AppendChild(iconTb);

        // ② 名前 TextBlock
        var textTb = new FrameworkElementFactory(typeof(TextBlock));
        textTb.SetBinding(TextBlock.TextProperty, new Binding(nameof(FileSelectorItem.DisplayName)));
        nameStack.AppendChild(textTb);

        nameTemplate.VisualTree = nameStack;

        // ③ DataTriggerで上書き
        // UpEntry(..)は ⬆ を表示
        var upTrig = new DataTrigger { Binding = new Binding(nameof(FileSelectorItem.IsUpEntry)), Value = true };
        upTrig.Setters.Add(new Setter(TextBlock.TextProperty, "⬆", targetName: "Icon"));
        nameTemplate.Triggers.Add(upTrig);

        // ディレクトリは 📁 を表示(UpEntryより後に効かせたい場合は順序を入れ替えてください)
        var dirTrig = new DataTrigger { Binding = new Binding(nameof(FileSelectorItem.IsDir)), Value = true };
        dirTrig.Setters.Add(new Setter(TextBlock.TextProperty, "📁", targetName: "Icon"));
        nameTemplate.Triggers.Add(dirTrig);

        // Size列テンプレート(右寄せ、ディレクトリ非表示)
        var sizeTemplate = new DataTemplate();
        var sizeTB = new FrameworkElementFactory(typeof(TextBlock));
        sizeTB.Name = "SizeTB";
        sizeTB.SetBinding(TextBlock.TextProperty, new Binding(nameof(FileSelectorItem.Size)) { Converter = ByteSizeConv });
        sizeTB.SetValue(TextBlock.HorizontalAlignmentProperty, HorizontalAlignment.Right);
        sizeTemplate.VisualTree = sizeTB;
        var hideDir = new DataTrigger { Binding = new Binding(nameof(FileSelectorItem.IsDir)), Value = true };
        hideDir.Setters.Add(new Setter(UIElement.VisibilityProperty, Visibility.Collapsed, "SizeTB"));
        sizeTemplate.Triggers.Add(hideDir);

        // GridView 構築(ヘッダーは Button→SortByCommand)
        var gv = new GridView();

        gv.Columns.Add(new GridViewColumn
        {
            Header = MakeHeaderButton("Name", nameof(FileSelectorItem.DisplayName)),
            CellTemplate = nameTemplate,
            Width = 140
        });

        gv.Columns.Add(new GridViewColumn
        {
            Header = MakeHeaderButton("Size", nameof(FileSelectorItem.Size)),
            CellTemplate = sizeTemplate,
            Width = 60
        });

        View = gv;

        // 選択バインド
        SetBinding(SelectedItemProperty, new Binding(nameof(FileSelectorViewModel.SelectedItem)) { Mode = BindingMode.TwoWay });

        // ダブルクリックでフォルダ移動
        MouseDoubleClick += (_, __) =>
        {
            if (DataContext is FileSelectorViewModel vm && vm.OpenSelectedCommand.CanExecute(null))
                vm.OpenSelectedCommand.Execute(null);
        };
    }

    private static Button MakeHeaderButton(string text, string propertyName)
    {
        var btn = new Button
        {
            Content = text,
            Padding = new Thickness(4, 0, 4, 0),
            Background = Brushes.Transparent,
            BorderBrush = Brushes.Transparent
        };

        // マウスオーバー時の背景変更を抑制
        btn.Focusable = false;

        // コマンドバインド
        btn.SetBinding(Button.CommandProperty, new Binding(nameof(FileSelectorViewModel.SortByCommand)));
        btn.CommandParameter = propertyName switch
        {
            nameof(FileSelectorItem.DisplayName) => "Name",
            _ => propertyName
        };

        return btn;
    }
}

ファイル名:View\MainWindow.cs


using System.Windows;
using System.Windows.Controls;

namespace FileSelector02;

public class MainWindow : Window
{
    public MainWindow()
    {
        Title = "NoXAML FileSelector";
        Width = 400;
        Height = 300;

        var vm = new MainWindowViewModel();
        DataContext = vm;

        var root = new DockPanel();
        var fileSelector = new FileSelector { DataContext = vm.FileSelectorVM };
        root.Children.Add(fileSelector);
        Content = root;
    }
}

ファイル名:ViewModel\FileSelectorViewModel.cs


using System.ComponentModel;
using System.Windows.Data;
using System.Windows.Input;

namespace FileSelector02;

public class FileSelectorViewModel : SelectorViewModel<FileSelectorItem>
{
    private readonly IFileSystemService _fs;

    private string _currentDirectory;
    public string CurrentDirectory
    {
        get => _currentDirectory;
        set
        {
            if (_currentDirectory == value) return;
            _currentDirectory = value;
            OnPropertyChanged();
            Refresh();
        }
    }

    public ICommand OpenSelectedCommand { get; }

    public FileSelectorViewModel(IFileSystemService fs, string initialDir)
    {
        _fs = fs;
        _currentDirectory = initialDir;

        // 既定の固定キー: 「..」最上位 → ディレクトリ先頭
        using (View.DeferRefresh())
        {
            View.SortDescriptions.Clear();
            View.SortDescriptions.Add(new SortDescription(nameof(FileSelectorItem.IsUpEntry), ListSortDirection.Descending));
            View.SortDescriptions.Add(new SortDescription(nameof(FileSelectorItem.IsDir),     ListSortDirection.Descending));
            View.SortDescriptions.Add(new SortDescription(nameof(FileSelectorItem.DisplayName), ListSortDirection.Ascending));
            _lastSortProperty = nameof(FileSelectorItem.DisplayName);
            _lastDirection = ListSortDirection.Ascending;
        }

        OpenSelectedCommand = new RelayCommand(_ =>
        {
            var item = SelectedItem;
            if (item is null) return;
            if (item.IsDir) CurrentDirectory = item.FullPath;
            // else: 必要ならファイル起動処理をここに
        }, _ => SelectedItem is not null);

        Refresh();
    }

    public void Refresh()
    {
        Items.Clear();
        foreach (var x in _fs.Enumerate(CurrentDirectory))
            Items.Add(x);
    }

    // 列クリックでのトグル(固定キーは保持しつつ、クリック列を差し替え)
    protected override void ApplySort(string property, ListSortDirection direction)
    {
        // 見出し名 → 実プロパティ名解決
        property = property switch
        {
            "Name" => nameof(FileSelectorItem.DisplayName),
            "Size" => nameof(FileSelectorItem.Size),
            _ => property
        };

        using (View.DeferRefresh())
        {
            View.SortDescriptions.Clear();
            View.SortDescriptions.Add(new SortDescription(nameof(FileSelectorItem.IsUpEntry), ListSortDirection.Descending));
            View.SortDescriptions.Add(new SortDescription(nameof(FileSelectorItem.IsDir),     ListSortDirection.Descending));
            View.SortDescriptions.Add(new SortDescription(property, direction));
            if (property != nameof(FileSelectorItem.DisplayName))
                View.SortDescriptions.Add(new SortDescription(nameof(FileSelectorItem.DisplayName), ListSortDirection.Ascending));
        }
        _lastSortProperty = property;
        _lastDirection = direction;
    }
}

ファイル名:ViewModel\MainWindowViewModel.cs


namespace FileSelector02;

public class MainWindowViewModel
{
    public FileSelectorViewModel FileSelectorVM { get; }
    public MainWindowViewModel()
    {
        var fs = new FileSystemService();
        FileSelectorVM = new(fs, Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
    }
}

ファイル名:ViewModel\SelectorViewModel.cs


using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows.Data;

namespace FileSelector02;

public class SelectorViewModel<T> : ObservableObject
{
    public ObservableCollection<T> Items { get; } = [];
    public ICollectionView View { get; }

    private T? _selectedItem;
    public T? SelectedItem
    {
        get => _selectedItem;
        set => Set(ref _selectedItem, value);
    }

    public RelayCommand SortByCommand { get; }
    public RelayCommand ClearSortCommand { get; }
    public RelayCommand SetFilterCommand { get; }

    protected string? _lastSortProperty;
    protected ListSortDirection _lastDirection = ListSortDirection.Ascending;

    private Predicate<object?>? _filter;
    public Predicate<object?>? Filter
    {
        get => _filter;
        set { _filter = value; View.Refresh(); }
    }

    public SelectorViewModel()
    {
        View = CollectionViewSource.GetDefaultView(Items);
        View.Filter = o => _filter?.Invoke(o) ?? true;

        SortByCommand = new RelayCommand(p =>
        {
            var prop = p as string;
            if (string.IsNullOrWhiteSpace(prop)) return;

            var dir = _lastSortProperty == prop
                ? (_lastDirection == ListSortDirection.Ascending ? ListSortDirection.Descending : ListSortDirection.Ascending)
                : ListSortDirection.Ascending;

            ApplySort(prop, dir);
        });

        ClearSortCommand = new RelayCommand(_ =>
        {
            using (View.DeferRefresh()) View.SortDescriptions.Clear();
            _lastSortProperty = null;
        });

        SetFilterCommand = new RelayCommand(qobj =>
        {
            var q = (qobj as string)?.Trim();
            Filter = string.IsNullOrEmpty(q)
                ? null
                : (o => o?.ToString()?.Contains(q, System.StringComparison.OrdinalIgnoreCase) == true);
        });
    }

    protected virtual void ApplySort(string property, ListSortDirection direction)
    {
        using (View.DeferRefresh())
        {
            View.SortDescriptions.Clear();
            // ここでは「汎用」なので、派生で固定キーを差し込むことを想定
            View.SortDescriptions.Add(new SortDescription(property, direction));
        }
        _lastSortProperty = property;
        _lastDirection = direction;
    }
}

コメント