WPF TreeViewを簡単にExplorer風にしたかったのでModelを作ってみた

過去に何度かTreeViewをいじってExplorer風にしたりしてみたが、汎用的に使えるようにしたかったので改めて作ってみた
このModelを使うと簡単にこんなのが作れる

![2017-07-13_10h19_05.jpg](https://qiita-image-store.s3.amazonaws.com/0/145898/f5d6f6cd-adfa-4f18-d4f6-38aeaa143218.jpeg)

# Model

```csharp
    /// <summary>
    /// ルートディレクトリ用クラス
    /// <para>_RootDirectoryに追加したNodeがルートになる</para>
    /// <para>コンストラクタでは1つのルートだけ追加するか、コレクションで複数のルートを追加できる</para>
    /// </summary>
    public class RootDirectoryNode
    {
        /// <summary>
        /// ルートNodeのリスト
        /// こいつをバインドする
        /// </summary>
        public ObservableCollection<BaseNode> _RootDirectory { get; private set; } = new ObservableCollection<BaseNode>();

        /// <summary>
        /// 1つのルートノードを持たせるコンストラクタ(string)
        /// </summary>
        /// <param name="rootDirectoryPath"></param>
        /// <param name="helper"></param>
        public RootDirectoryNode(string rootDirectoryPath, TreeViewHelper helper)
        {
            _RootDirectory.Add(TryCreateDirectoryNode(rootDirectoryPath, helper));
        }

        /// <summary>
        /// 1つのルートノードを持たせるコンストラクタ(DirectoryInfo)
        /// </summary>
        /// <param name="rootDirectoryPath"></param>
        /// <param name="helper"></param>
        public RootDirectoryNode(DirectoryInfo rootDirectory, TreeViewHelper helper)
        {
            _RootDirectory.Add(new DirectoryNode(rootDirectory, helper));
        }

        /// <summary>
        /// 複数のルートノードを持たせるコンストラクタ(string)
        /// </summary>
        /// <param name="rootDirectoryPathList"></param>
        /// <param name="helper"></param>
        public RootDirectoryNode(IEnumerable<string> rootDirectoryPathList, TreeViewHelper helper)
        {
            CommonSetRootDirectories(rootDirectoryPathList.Select(x => TryCreateDirectoryNode(x, helper)).ToList());
        }

        /// <summary>
        /// 複数のルートノードを持たせるコンストラクタ(DirectoryInfo)
        /// </summary>
        /// <param name="rootDirectoryPathList"></param>
        /// <param name="helper"></param>
        public RootDirectoryNode(IEnumerable<DirectoryInfo> rootDirectoryList, TreeViewHelper helper)
        {
            CommonSetRootDirectories(rootDirectoryList.Select(x => new DirectoryNode(x,helper)).ToList());
        }

        /// <summary>
        /// 1つのルートノードを再度セットする(string)
        /// </summary>
        /// <param name="rootDirectoryPath"></param>
        /// <param name="helper"></param>
        public void SetRootDirectory(string rootDirectoryPath, TreeViewHelper helper)
        {
            CommonSetRootDirectory(TryCreateDirectoryNode(rootDirectoryPath, helper));
        }

        /// <summary>
        /// 1つのルートノードを再度セットする(DirectoryInfo)
        /// </summary>
        /// <param name="rootDirectory"></param>
        /// <param name="helper"></param>
        public void SetRootDirectory(DirectoryInfo rootDirectory,TreeViewHelper helper)
        {
            CommonSetRootDirectory(new DirectoryNode(rootDirectory, helper));
        }

        /// <summary>
        /// 複数のルートノードを再度セットする(string)
        /// </summary>
        /// <param name="rootDirectoryPaths"></param>
        /// <param name="helper"></param>
        public void SetRootDirectories(IEnumerable<string> rootDirectoryPaths, TreeViewHelper helper)
        {
            CommonSetRootDirectories(rootDirectoryPaths.Select(x => TryCreateDirectoryNode(x, helper)).ToList());
        }

        /// <summary>
        /// 複数のルートノードを再度セットする(DirectoryInfo)
        /// </summary>
        /// <param name="rootDirectoryPathList"></param>
        /// <param name="helper"></param>
        public void SetRootDirectories(IEnumerable<DirectoryInfo> rootDirectories, TreeViewHelper helper)
        {
            CommonSetRootDirectories(rootDirectories.Select(x => new DirectoryNode(x, helper)).ToList());
        }

        /// <summary>
        /// ルートノードに指定パスのDirectoryNodeを追加する
        /// </summary>
        /// <param name="rootDirectoryPath"></param>
        /// <param name="helper"></param>
        public void AddRootDirectory(string rootDirectoryPath,TreeViewHelper helper)
        {
            _RootDirectory.Add(TryCreateDirectoryNode(rootDirectoryPath, helper));
        }

        /// <summary>
        /// ルートノードに指定ディレクトリのDirectoryNodeを追加する
        /// </summary>
        /// <param name="rootDirectoryPath"></param>
        /// <param name="helper"></param>
        public void AddRootDirectory(DirectoryInfo rootDirectoryPath, TreeViewHelper helper)
        {
            _RootDirectory.Add(new DirectoryNode(rootDirectoryPath, helper));
        }

        /// <summary>
        /// ルートノードに指定パスのFileNodeを追加する
        /// </summary>
        /// <param name="filePath"></param>
        /// <param name="helper"></param>
        public void AddFileNode(string filePath,TreeViewHelper helper)
        {
            _RootDirectory.Add(TryCreateFileNode(filePath, helper));
        }

        /// <summary>
        /// ルートノードに指定ファイルのFileNodeを追加する
        /// </summary>
        /// <param name="file"></param>
        /// <param name="helper"></param>
        public void AddFileNode(FileInfo file,TreeViewHelper helper)
        {
            _RootDirectory.Add(new FileNode(file, helper));
        }

        /// <summary>
        /// ルートノードを一旦クリアし、引数のノードを追加する
        /// </summary>
        /// <param name="dirNode"></param>
        private void CommonSetRootDirectory(BaseNode dirNode)
        {
            _RootDirectory.Clear();
            _RootDirectory.Add(dirNode);
        }

        /// <summary>
        /// ルートノードを一旦クリアし、引数のノード全てを追加する
        /// </summary>
        /// <param name="dirNodes"></param>
        private void CommonSetRootDirectories(IEnumerable<BaseNode> dirNodes)
        {
            _RootDirectory.Clear();
            foreach (var dirNode in dirNodes)
            {
                _RootDirectory.Add(dirNode);
            }
        }

        /// <summary>
        /// 指定パスにディレクトリが存在するならDirectoryNodeを返す
        /// <para>存在しなければBaseNodeを返す</para>
        /// </summary>
        /// <param name="path"></param>
        /// <param name="helper"></param>
        /// <returns></returns>
        private BaseNode TryCreateDirectoryNode(string path, TreeViewHelper helper)
        {
            if (Directory.Exists(path))
            {
                return new DirectoryNode(new DirectoryInfo(path), helper);
            }
            else
            {
                return new BaseNode() { Header = "フォルダが見つかりません" };
            }
        }

        /// <summary>
        /// 指定パスにファイルが存在するならDirectoryNodeを返す
        /// <para>存在しなければBaseNodeを返す</para>
        /// </summary>
        /// <param name="path"></param>
        /// <param name="helper"></param>
        /// <returns></returns>
        private BaseNode TryCreateFileNode(string path, TreeViewHelper helper)
        {
            if (File.Exists(path))
            {
                return new FileNode(new FileInfo(path), helper);
            }
            else
            {
                return new BaseNode() { Header = "ファイルが見つかりません" };
            }
        }
    }

    /// <summary>
    /// 各情報を参照型として共有するためのヘルパークラス
    /// </summary>
    public class TreeViewHelper
    {
        /// <summary>
        /// ファイル検索時のフィルター
        /// </summary>
        public string _SearchPattern { get; set; } = "*";
        /// <summary>
        /// Headerに表示するアイコンの幅
        /// </summary>
        public double _ImageWidth { get; set; } = 18;
        /// <summary>
        /// Headerに表示するアイコンの高さ
        /// </summary>
        public double _ImageHeight { get; set; } = 15;
        /// <summary>
        /// フォルダ未展開時にHeaderに表示するアイコン
        /// </summary>
        public ImageSource _CloseFolderIcon { get; set; } = new BitmapImage();
        /// <summary>
        /// フォルダ展開時にHeaderに表示するアイコン
        /// </summary>
        public ImageSource _OpenFolderIcon { get; set; } = new BitmapImage();
        /// <summary>
        /// ファイルのHeaderに表示するアイコン
        /// </summary>
        public ImageSource _FileIcon { get; set; } = new BitmapImage();

        public TreeViewHelper(ImageSource closeFolderIcon, ImageSource openFolderIcon, ImageSource fileIcon, string searchPattern = "*", double imageWidth = 18, double imageHeight = 15)
        {
            _SearchPattern = searchPattern;
            _ImageWidth = imageWidth;
            _ImageHeight = imageHeight;
            _CloseFolderIcon = closeFolderIcon;
            _OpenFolderIcon = openFolderIcon;
            _FileIcon = fileIcon;
        }
    }

    /// <summary>
    /// TreeViewItemを継承し、Nodeのベースとなるクラス
    /// </summary>
    public class BaseNode : TreeViewItem
    {
        /// <summary>
        /// 自身が持つFileSystemInfo(DirectoryInfoかFileInfo)
        /// </summary>
        public FileSystemInfo _Info { get; set; }
        /// <summary>
        /// Headerに表示するアイコン
        /// </summary>
        public Image _HeaderImage { get; private set; } = new Image();
        /// <summary>
        /// Headerに表示するテキスト
        /// </summary>
        public TextBlock _HeaderText { get; private set; } = new TextBlock();
        /// <summary>
        /// Headerに表示するアイコンとテキストを持つパネル
        /// </summary>
        public StackPanel _HeaderPanel { get; private set; } = new StackPanel() { Orientation = Orientation.Horizontal };
        public BaseNode()
        {
            _HeaderPanel.Children.Add(_HeaderImage);
            _HeaderPanel.Children.Add(_HeaderText);
            this.Header = _HeaderPanel;

            //これを設定しとかないとバインドエラーが出る
            this.HorizontalContentAlignment = HorizontalAlignment.Left;
            this.VerticalContentAlignment = VerticalAlignment.Center;
        }
    }

    /// <summary>
    /// Directory用のNode
    /// </summary>
    public class DirectoryNode : BaseNode
    {
        /// <summary>
        /// 共有のためのTreeViewHelper
        /// </summary>
        public TreeViewHelper Helper;
        /// <summary>
        /// 1度でも展開したかどうか
        /// </summary>
        private bool hasExpandedOnce = false;

        /// <summary>
        /// 自身のDirectory内にサブDirectoryか_SearchPatternに一致するファイルがあれば、
        /// 展開できることを表示するためにダミーノードを追加する
        /// </summary>
        /// <param name="dir"></param>
        /// <param name="helper"></param>
        public DirectoryNode(DirectoryInfo dir, TreeViewHelper helper)
        {
            this._Info = dir;
            this.Helper = helper;
            this._HeaderImage.Source = Helper._CloseFolderIcon;
            this._HeaderImage.Width = Helper._ImageWidth;
            this._HeaderImage.Height = Helper._ImageHeight;
            this._HeaderText.Text = dir.Name;

            if (dir.Exists)
            {
                try
                {
                    if (dir.EnumerateDirectories().Any() || dir.EnumerateFiles(Helper._SearchPattern).Any())
                    {
                        this.Items.Add(new BaseNode());//ダミーノードの追加
                    }
                }
                //アクセス拒否、ディレクトリ・ファイルが見つからないエラーをスキップ
                catch (Exception e) when (e is UnauthorizedAccessException || e is DirectoryNotFoundException || e is FileNotFoundException)
                {
                    Console.WriteLine(e.Source + "：" + e.Message);
                }
            }
            this.Expanded += DirNode_Expanded;
            this.Collapsed += DirNode_Collapsed;
        }

        /// <summary>
        /// 展開した時、子ノードがあれば_HeaderImageを_OpenFolderIconに変更する
        /// また、はじめて展開した時ならサブDirectoryとFileを探査して子ノードに追加する
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void DirNode_Expanded(object sender, RoutedEventArgs e)
        {
            var directory = (DirectoryInfo)this._Info;

            if (this.Items.Count > 0)
            {
                this._HeaderImage.Source = this.Helper._OpenFolderIcon;
                if (!hasExpandedOnce)
                {
                    this.Items.Clear();
                    foreach (var dir in directory.GetDirectories())
                    {
                        this.Items.Add(new DirectoryNode(dir, Helper));
                    }
                    foreach (var file in directory.GetFiles(Helper._SearchPattern))
                    {
                        this.Items.Add(new FileNode(file, Helper));
                    }
                    hasExpandedOnce = true;
                }
            }
        }

        /// <summary>
        /// 展開を閉じた時、_HeaderImageを_CloseFolderIconに変更する
        /// このイベントは親Nodeまで伝播するのでIsExpandedプロパティで判定する
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void DirNode_Collapsed(object sender, RoutedEventArgs e)
        {
            if (!this.IsExpanded) this._HeaderImage.Source = this.Helper._CloseFolderIcon;
        }
    }

    /// <summary>
    /// File用のNode
    /// </summary>
    public class FileNode : BaseNode
    {
        public FileNode(FileInfo file, TreeViewHelper helper)
        {
            this._Info = file;
            this._HeaderImage.Source = helper._FileIcon;
            this._HeaderImage.Width = helper._ImageWidth;
            this._HeaderImage.Height = helper._ImageHeight;
            this._HeaderText.Text = file.Name;
        }
    }
```

# 使い方

## ViewModel

```csharp
    public class VM
    {
        public RootDirectoryNode _Root { get; private set; }

        public VM()
        {
            var closeFolderIcon = new BitmapImage(new Uri(@"アイコンのパス", UriKind.Relative));
            var openFolderIcon = new BitmapImage(new Uri(@"アイコンのパス", UriKind.Relative));
            var fileIcon = new BitmapImage(new Uri(@"アイコンのパス", UriKind.Relative));

            _Root = new RootDirectoryNode
                (
                new DirectoryInfo(@"C:\新しいフォルダー"),
                new TreeViewHelper(closeFolderIcon,openFolderIcon,fileIcon,"*.csv")
                );
        }
    }
```

Modelのsummary見てもらえばだいたい分かると思うけど、RootDirectoryNodeクラスをVMでインスタンス化して、コンストラクタでDirectoryInfoとTreeViewHelperを渡す
TreeViewHelperはフォルダ・ファイルのアイコンを指定する
また、ファイルの検索パターン、アイコンのサイズも指定できる(省略可)

## View

```xml
<TreeView x:Name="treeView" ItemsSource="{Binding _Root._RootDirectory}" SelectedValuePath="_Info"/>
<TextBlock Text="{Binding SelectedValue, ElementName=treeView, Mode=OneWay}"/>
```

TreeViewのItemsSourceにインスタンス化したRootDirectoryのList<BaseNode>をバインドするだけ
選択したNodeのDirectoryInfoかFileInfoは、SelectedValuePathに`_Info`を指定することでSelectedValueから取得できる
FileSystemInfoで取得されるので、実際に使う際は型判定が必要だと思う

## Nodeを選択した時になにかしたい

Behaviorを使えばいろいろできる
私は選択したNodeをダブルクリックした時にCommandを実行したかったのでこんな感じで書いてみた

```csharp
    public class ExplorerStyleTreeView_Behavior : Behavior<TreeView>
    {

        #region Command添付プロパティ
        public ICommand Command
        {
            get { return (ICommand)GetValue(CommandProperty); }
            set { SetValue(CommandProperty, value); }
        }

        // Using a DependencyProperty as the backing store for Message.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty CommandProperty =
            DependencyProperty.Register("Command", typeof(ICommand), typeof(ExplorerStyleTreeView_Behavior), new PropertyMetadata(null));
        #endregion

        protected override void OnAttached()
        {
            this.AssociatedObject.MouseDoubleClick += this.ItemDoubleClicked;
        }

        protected override void OnDetaching()
        {
            this.AssociatedObject.MouseDoubleClick -= this.ItemDoubleClicked;
        }

        /// <summary>
        /// DoubleClick時、DoubleClickした場所がTreeViewの選択したItem内であればCommandを実行する
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void ItemDoubleClicked(object sender, MouseButtonEventArgs e)
        {
            var treeView = e.Source as TreeView;
            if (treeView == null) return;
            var mousePosition = e.GetPosition(treeView);
            var selectedItem = treeView.SelectedItem as BaseNode;
            if (selectedItem == null) return;
            if (selectedItem._Info is DirectoryInfo) return;
            //選択中Itemの始点設定
            var selectedItemStartingPoint = (Point)(selectedItem.PointToScreen(new Point(0.0d, 0.0d)) - treeView.PointToScreen(new Point(0.0d, 0.0d)));
            //選択中Itemの終点設定
            var selectedItemEndPoint = new Point(selectedItemStartingPoint.X + selectedItem.ActualWidth, selectedItemStartingPoint.Y + selectedItem.ActualHeight);
            //選択中のItem内でダブルクリックが発生したならCommand実行
            if (inRange(mousePosition, selectedItemStartingPoint, selectedItemEndPoint))
            {
                Command.Execute(treeView);
            }
        }

        /// <summary>
        /// targetPointがstartingPointからendPointまでの範囲にあるかを判定する
        /// </summary>
        /// <param name="targetPoint">判定対象のPoint</param>
        /// <param name="startingPoint">始点</param>
        /// <param name="endPoint">終点</param>
        /// <returns>bool</returns>
        private bool inRange(Point targetPoint, Point startingPoint, Point endPoint)
        {
            var x = targetPoint.X;
            var y = targetPoint.Y;
            if (startingPoint.X <= x && startingPoint.Y <= y && x <= endPoint.X && y <= endPoint.Y)
            {
                return true;
            }
            else
            {
                return false;
            }
        }
    }
```

Behaviorを使うためには
・System.Windows.Interactivity
・Microsoft.Expression.Interactions
を参照設定に追加する必要がある
参考：[かずきのBlog@hatena/WPF4.5入門 その59「Behaviorの自作」](http://blog.okazuki.jp/entry/2014/12/22/235048)

Commandは添付プロパティとして作り、ViewからBindさせる
MouseDoubleClickイベントは、TreeViewのどこをダブルクリックしても発生する
そのため、発生したPointがTreeViewのSelectedItem内かを判定し、範囲内であればCommandを実行するようにしている
Commandには引数としてTreeViewを渡し、ViewModelで処理するようにしている
ViewModel側のCommandはこんな感じ(ReactiveCommandを使用)

```csharp
public ReactiveCommand C_Load { get; private set; } = new ReactiveCommand();

    //コンストラクタ内
    C_Load.Subscribe(x => Load(x as TreeView));

private void Load(TreeView treeView)
{
    //処理
}
```

Viewのバインドはこんな感じ

```xml
<Window>
    xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
    xmlns:be="clr-namespace:ExplorerStyleTreeView.Behavior"

    <TreeView x:Name="treeView"
              SelectedValuePath="_Info" 
              ItemsSource="{Binding _Root._RootDirectory}">
        <i:Interaction.Behaviors>
            <be:ExplorerStyleTreeView_Behavior Command="{Binding C_Load}"/>
        </i:Interaction.Behaviors>
    </TreeView>
<Window/>
```

# 終わりに

いい感じにModelが書けたかなと思う・ω・
軽くするために参照型でアイコンとか共有してるからファイルとかに関連付けられたアイコンを使うのは無理
でも実際気軽に使おうとしたらアイコンは勝手に付けてくれたほうが使いやすそうだからそのうち勝手にアイコンも付けてくれるやつも作るかも
dllにしたらもっと使いやすいだろうけどライセンス周りは全く詳しくないから配布は気が引ける(´・ω・｀)