WPFでExplorerライクなUIを作成する。その2

コンピュータ

ソースコード

ファイル名:Converters.cs

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

namespace ExplorerModoki5;

public class BoolToVisibilityConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        => value is bool b && b ? Visibility.Visible : Visibility.Collapsed;

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        => value is Visibility v && v == Visibility.Visible;
}

public class InverseBoolToVisibilityConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        => value is bool b && b ? Visibility.Collapsed : Visibility.Visible;

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        => value is Visibility v && v != Visibility.Visible;
}

ファイル名:ExplorerModoki5.csproj

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

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

</Project>

ファイル名:FileItem.cs

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

namespace ExplorerModoki5;

public class FileItem : INotifyPropertyChanged
{
    string _name = "";
    string _originalName = "";
    bool _isEditing;

    public string Name
    {
        get => _name;
        set { if (_name != value) { _name = value; OnPropertyChanged(); } }
    }

    public bool IsEditing
    {
        get => _isEditing;
        set
        {
            if (_isEditing == value) return;

            _isEditing = value;

            // 編集開始時点の名前を保存
            if (_isEditing)
                _originalName = _name;

            OnPropertyChanged();
        }
    }

    public string OriginalName => _originalName;

    public bool IsFolder { get; set; }
    public long Size { get; set; }
    public DateTime Modified { get; set; }

    public event PropertyChangedEventHandler? PropertyChanged;
    void OnPropertyChanged([CallerMemberName] string? name = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

ファイル名:MainWindow.xaml

<Window x:Class="ExplorerModoki5.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:ExplorerModoki5"
        Title="ExplorerModoki5" Height="520" Width="900">

    <Window.Resources>
        <local:BoolToVisibilityConverter x:Key="BoolToVis"/>
        <local:InverseBoolToVisibilityConverter x:Key="InvBoolToVis"/>

        <!-- FileItem 用メニュー -->
        <ContextMenu x:Key="FileItemMenu">
            <MenuItem Header="名前の変更" Click="Menu_Rename_Click"/>
            <Separator/>
            <MenuItem Header="削除(UIのみ)" Click="Menu_Delete_Click"/>
        </ContextMenu>

        <!-- 空白用メニュー -->
        <ContextMenu x:Key="BackgroundMenu">
            <MenuItem Header="更新(ダミー)" Click="Menu_Refresh_Click"/>
            <Separator/>
            <MenuItem Header="並び替え(ダミー)" Click="Menu_SortDummy_Click"/>
        </ContextMenu>
    </Window.Resources>

    <Grid>
        <ListView x:Name="ListView1"
                  ItemsSource="{Binding Items}"
                  SelectedItem="{Binding SelectedItem, Mode=TwoWay}"
                  KeyDown="ListView_KeyDown"
                  PreviewTextInput="ListView_PreviewTextInput"
                  ContextMenuOpening="ListView_ContextMenuOpening"
                  GridViewColumnHeader.Click="GridViewColumnHeader_Click">

            <ListView.View>
                <GridView>

                    <!-- Name (編集対応) -->
                    <GridViewColumn Width="260">
                        <GridViewColumn.Header>
                            <GridViewColumnHeader Tag="Name">
                                <StackPanel Orientation="Horizontal">
                                    <TextBlock Text="Name"/>
                                    <TextBlock x:Name="Arrow"
                                            Margin="4,0,0,0"
                                            Text=""
                                            FontSize="10"/>
                                </StackPanel>
                            </GridViewColumnHeader>
                        </GridViewColumn.Header>

                        <GridViewColumn.CellTemplate>
                            <DataTemplate>
                                <Grid>
                                    <TextBlock Text="{Binding Name}"
                                               Visibility="{Binding IsEditing, Converter={StaticResource InvBoolToVis}}"/>

                                    <TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
                                             Visibility="{Binding IsEditing, Converter={StaticResource BoolToVis}}"
                                             IsVisibleChanged="RenameTextBox_IsVisibleChanged"
                                             LostFocus="RenameTextBox_LostFocus"
                                             KeyDown="RenameTextBox_KeyDown"/>
                                </Grid>
                            </DataTemplate>
                        </GridViewColumn.CellTemplate>
                    </GridViewColumn>

                    <!-- Size -->
                    <GridViewColumn Width="100" DisplayMemberBinding="{Binding Size}">
                        <GridViewColumn.Header>
                            <GridViewColumnHeader Content="Size" Tag="Size"/>
                        </GridViewColumn.Header>
                    </GridViewColumn>

                    <!-- Modified -->
                    <GridViewColumn Width="160" DisplayMemberBinding="{Binding Modified}">
                        <GridViewColumn.Header>
                            <GridViewColumnHeader Content="Modified" Tag="Modified"/>
                        </GridViewColumn.Header>
                    </GridViewColumn>

                    <!-- IsFolder (デバッグ用に表示しても良い) -->
                    <GridViewColumn Width="90" DisplayMemberBinding="{Binding IsFolder}">
                        <GridViewColumn.Header>
                            <GridViewColumnHeader Content="IsFolder" Tag="IsFolder"/>
                        </GridViewColumn.Header>
                    </GridViewColumn>

                </GridView>
            </ListView.View>
        </ListView>
    </Grid>
</Window>

ファイル名:MainWindow.xaml.cs

using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;

namespace ExplorerModoki5;

public partial class MainWindow : Window, INotifyPropertyChanged
{
    public ObservableCollection<FileItem> Items { get; } = [];

    FileItem? _selectedItem;
    public FileItem? SelectedItem
    {
        get => _selectedItem;
        set { if (_selectedItem != value) { _selectedItem = value; OnPropertyChanged(); } }
    }

    // --- ソート状態 ---
    string? _sortProperty = null;
    ListSortDirection _sortDirection = ListSortDirection.Ascending;

    // --- タイプジャンプ ---
    string _typeBuffer = "";
    DateTime _lastTypeTime = DateTime.MinValue;
    const int TypeTimeoutMs = 700;

    public MainWindow()
    {
        InitializeComponent();
        DataContext = this;

        Loaded += (_, _) =>
        {
            // お好みの初期ディレクトリ
            LoadDirectory(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));

            // 初期ソート(Explorer っぽく Name)
            _sortProperty = nameof(FileItem.Name);
            _sortDirection = ListSortDirection.Ascending;
            ApplySort();
        };
    }

    // ----------------------------
    //  ファイル一覧取得(取得のみ)
    // ----------------------------
    void LoadDirectory(string path)
    {
        Items.Clear();

        try
        {
            var dir = new DirectoryInfo(path);

            // フォルダ
            foreach (var d in dir.GetDirectories())
            {
                Items.Add(new FileItem
                {
                    Name = d.Name,
                    IsFolder = true,
                    Size = 0,
                    Modified = d.LastWriteTime
                });
            }

            // ファイル
            foreach (var f in dir.GetFiles())
            {
                Items.Add(new FileItem
                {
                    Name = f.Name,
                    IsFolder = false,
                    Size = f.Length,
                    Modified = f.LastWriteTime
                });
            }
        }
        catch (Exception ex)
        {
            MessageBox.Show(ex.Message, "LoadDirectory");
        }
    }

    // ----------------------------
    //  KeyDown: F2 / Delete など
    // ----------------------------
    void ListView_KeyDown(object? sender, KeyEventArgs e)
    {
        if (e.Key == Key.F2)
        {
            BeginRename();
            e.Handled = true;
            return;
        }

        if (e.Key == Key.Delete)
        {
            // Rename中はTextBoxに任せる(Explorer風)
            if (Items.Any(x => x.IsEditing))
                return;

            DeleteSelectedUiOnly();
            e.Handled = true;
            return;
        }
    }

    void BeginRename()
    {
        if (SelectedItem == null) return;

        // 画面外ならまず可視化(仮想化対策)
        ListView1.ScrollIntoView(SelectedItem);

        // 他の編集を終了
        foreach (var item in Items)
            item.IsEditing = false;

        SelectedItem.IsEditing = true;
    }

    void DeleteSelectedUiOnly()
    {
        if (SelectedItem == null) return;

        var index = Items.IndexOf(SelectedItem);
        Debug.WriteLine($"[Delete(UI)] {SelectedItem.Name}");

        Items.Remove(SelectedItem);

        if (Items.Count == 0)
        {
            SelectedItem = null;
            return;
        }

        // Explorer風:次、なければ前
        if (index < Items.Count)
            SelectedItem = Items[index];
        else
            SelectedItem = Items[^1];
    }

    // --------------------------------
    //  Rename: TextBox Visible→Focus
    // --------------------------------
    void RenameTextBox_IsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
    {
        if (sender is not TextBox tb) return;
        if (!tb.IsVisible) return;

        // フォーカス競合に勝つ
        tb.Dispatcher.BeginInvoke(new Action(() =>
        {
            tb.Focus();
            Keyboard.Focus(tb);
            tb.SelectAll();
        }), DispatcherPriority.Input);
    }

    void RenameTextBox_KeyDown(object? sender, KeyEventArgs e)
    {
        if (sender is not TextBox tb) return;
        if (tb.DataContext is not FileItem item) return;

        if (e.Key == Key.Enter)
        {
            CommitRename(item);
            e.Handled = true;
            return;
        }

        if (e.Key == Key.Escape)
        {
            // キャンセル
            item.Name = item.OriginalName;
            item.IsEditing = false;
            e.Handled = true;
            return;
        }
    }

    void RenameTextBox_LostFocus(object sender, RoutedEventArgs e)
    {
        if (sender is TextBox tb &&
            tb.DataContext is FileItem item &&
            item.IsEditing)
        {
            CommitRename(item);
        }
    }

    void CommitRename(FileItem item)
    {
        item.IsEditing = false;

        if (item.Name != item.OriginalName)
        {
            // ここが「名前変更確定イベント」
            Debug.WriteLine($"[Rename] {item.OriginalName} -> {item.Name}");
        }
    }

    // ----------------------------
    //  ContextMenu: Item/空白で切替
    // ----------------------------
    void ListView_ContextMenuOpening(object sender, ContextMenuEventArgs e)
    {
        if (sender is not ListView lv) return;

        var element = e.OriginalSource as DependencyObject;
        var lvi = element != null
            ? ItemsControl.ContainerFromElement(lv, element) as ListViewItem
            : null;

        if (lvi?.DataContext is FileItem item)
        {
            // 右クリックで選択移動(Explorer風)
            lv.SelectedItem = item;

            // Rename中はメニュー無効でもOK(お好み)
            if (item.IsEditing)
            {
                e.Handled = true;
                return;
            }

            lv.ContextMenu = (ContextMenu)FindResource("FileItemMenu");
        }
        else
        {
            lv.ContextMenu = (ContextMenu)FindResource("BackgroundMenu");
        }
    }

    void Menu_Rename_Click(object sender, RoutedEventArgs e) => BeginRename();
    void Menu_Delete_Click(object sender, RoutedEventArgs e) => DeleteSelectedUiOnly();

    void Menu_Refresh_Click(object sender, RoutedEventArgs e)
        => MessageBox.Show("更新(ダミー)");

    void Menu_SortDummy_Click(object sender, RoutedEventArgs e)
        => MessageBox.Show("並び替え(ダミー)※ヘッダークリックでソートします");

    // ----------------------------
    //  Header click sort
    // ----------------------------
    void GridViewColumnHeader_Click(object sender, RoutedEventArgs e)
    {
        // Rename中は無視(任意)
        if (Items.Any(x => x.IsEditing))
            return;

        if (e.OriginalSource is not GridViewColumnHeader header)
            return;

        if (header.Tag is not string propertyName)
            return;

        // 同じ列→トグル、別列→昇順
        if (_sortProperty == propertyName)
        {
            _sortDirection =
                _sortDirection == ListSortDirection.Ascending
                    ? ListSortDirection.Descending
                    : ListSortDirection.Ascending;
        }
        else
        {
            _sortProperty = propertyName;
            _sortDirection = ListSortDirection.Ascending;
        }

        ApplySort();
    }

    void ApplySort()
    {
        if (_sortProperty == null) return;

        var view = CollectionViewSource.GetDefaultView(Items);
        view.SortDescriptions.Clear();

        // Explorer風:フォルダ優先
        view.SortDescriptions.Add(
            new SortDescription(nameof(FileItem.IsFolder), ListSortDirection.Descending));

        view.SortDescriptions.Add(
            new SortDescription(_sortProperty, _sortDirection));

        view.Refresh();

        Debug.WriteLine($"[Sort] {_sortProperty} {_sortDirection}");

        UpdateSortArrows();
    }

    // ----------------------------
    //  Type jump (keyboard search)
    // ----------------------------
    void ListView_PreviewTextInput(object sender, TextCompositionEventArgs e)
    {
        // Rename中はTextBoxに任せる
        if (Items.Any(x => x.IsEditing))
            return;

        var now = DateTime.Now;

        if ((now - _lastTypeTime).TotalMilliseconds > TypeTimeoutMs)
            _typeBuffer = "";

        _lastTypeTime = now;
        _typeBuffer += e.Text;

        var view = CollectionViewSource.GetDefaultView(Items);
        var match = view.Cast<FileItem>()
                        .FirstOrDefault(x =>
                            x.Name.StartsWith(_typeBuffer, StringComparison.OrdinalIgnoreCase));

        if (match != null)
        {
            SelectedItem = match;
            ListView1.ScrollIntoView(match);
        }

        e.Handled = true;
    }


    void UpdateSortArrows()
    {
        // ListView 内のすべての GridViewColumnHeader を探す
        foreach (var header in FindVisualChildren<GridViewColumnHeader>(ListView1))
        {
            if (header.Content is StackPanel sp &&
                sp.Children.OfType<TextBlock>().Skip(1).FirstOrDefault() is TextBlock arrow)
            {
                arrow.Text = ""; // 一旦クリア
            }

            // 現在のソート列だけ矢印を付ける
            if (header.Tag as string == _sortProperty)
            {
                if (header.Content is StackPanel sp2 &&
                    sp2.Children.OfType<TextBlock>().Skip(1).FirstOrDefault() is TextBlock arrow2)
                {
                    arrow2.Text = _sortDirection == ListSortDirection.Ascending ? "▲" : "▼";
                }
            }
        }
    }
    static IEnumerable<T> FindVisualChildren<T>(DependencyObject parent)
        where T : DependencyObject
    {
        for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
        {
            var child = VisualTreeHelper.GetChild(parent, i);
            if (child is T t)
                yield return t;

            foreach (var x in FindVisualChildren<T>(child))
                yield return x;
        }
    }
    // ----------------------------
    // INotifyPropertyChanged
    // ----------------------------
    public event PropertyChangedEventHandler? PropertyChanged;
    void OnPropertyChanged([CallerMemberName] string? name = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

コメント