TreeView が配置された UI で Model-View-ViewModel パターンを使う方法

TreeView にデータをバインドするにはどうすればいいのか?しかも、Model-View-ViewModel (以下 MVVM) パターンを適用したい。

調べてみたところ、TreeViewItem に対する ViewModel を作成し、HierarchyDataTemplate を使って表示すればいいことが分かりました。

実際に作成した画面は次の通り。

f:id:griefworker:20090810204214p:image

う〜ん、この画像では伝えられない…。以下、サンプルコードの説明です。

TreeViewItem に対応する ViewModel は次の通り。

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;

namespace MVVMTreeViewSample
{
    public class DirectoryViewModel : INotifyPropertyChanged
    {
        // サブディレクトリの取得を遅らせるときに使うダミー
        private static DirectoryViewModel Dummy = new DirectoryViewModel();

        // Dummy 専用のコンストラクタ
        private DirectoryViewModel()
        {
        }

        // コンストラクタ
        public DirectoryViewModel(DirectoryViewModel parent, string path)
        {
            if (Directory.Exists(path) == false)
            {
                throw new ArgumentException(path + " は存在しません。", "path");
            }
            _model = new DirectoryInfo(path);
            _parent = parent;
            _children = new ReadOnlyCollection<DirectoryViewModel>(new DirectoryViewModel[]
            {
                Dummy
            });
        }

        // モデル
        private DirectoryInfo _model;

        public string Name
        {
            get { return _model.Name; }
        }

        private bool _isExpanded;

        // ノードが開いているかどうか
        public bool IsExpanded
        {
            get { return _isExpanded; }
            set
            {
                if (_isExpanded == value)
                {
                    return;
                }
                _isExpanded = value;
                OnPropertyChanged("IsExpanded");
            }
        }

        private bool _isSelected;

        // ノードが選択されているかどうか
        public bool IsSelected
        {
            get { return _isSelected; }
            set
            {
                if (_isSelected == value)
                {
                    return;
                }
                _isSelected = value;
                OnPropertyChanged("IsSelected");
            }
        }

        private DirectoryViewModel _parent;

        // 親ディレクトリのビューモデル
        public DirectoryViewModel Parent
        {
            get { return _parent; }
        }

        // サブディレクトリを取得済みかどうかを判断
        public bool HasDummy
        {
            get
            {
                return (_children.Count == 1) && (_children[0] == Dummy);
            }
        }

        private ReadOnlyCollection<DirectoryViewModel> _children;

        // サブディレクトリの一覧。
        // 必要になるまで取得を遅らせている。
        public ReadOnlyCollection<DirectoryViewModel> Children
        {
            get
            {
                if (HasDummy)
                {
                    List<DirectoryViewModel> list = new List<DirectoryViewModel>();
                    foreach (var info in _model.GetDirectories())
                    {
                        list.Add(new DirectoryViewModel(this, info.FullName));
                    }
                    _children = new ReadOnlyCollection<DirectoryViewModel>(list);
                }
                return _children;
            }
        }

        public event PropertyChangedEventHandler PropertyChanged;

        protected void OnPropertyChanged(string propertyName)
        {
            var handler = PropertyChanged;
            if (handler != null)
            {
                var e = new PropertyChangedEventArgs(propertyName);
                handler(this, e);
            }
        }
    }
}

DirectoryInfo を内部で保持しておいて、TreeViewItem が開かれるまで、サブディレクトリの取得を遅らせています。すべて取得していては、メモリが足りなくなるので。

TreeView を持つ UI は次の通り。

<Window x:Class="MVVMTreeViewSample.Window1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:MVVMTreeViewSample"    
    Title="Window1" Height="300" Width="300">
    <Grid>
        <!--DirectoryViewModel の Children を TreeView にバインド-->
        <TreeView ItemsSource="{Binding Path=Children}">
            <!--TreeViewItem の状態を DirectoryViewModel に保存するためのスタイル-->
            <TreeView.ItemContainerStyle>
                <Style TargetType="{x:Type TreeViewItem}">
                    <Setter Property="IsExpanded" Value="{Binding Path=IsExpanded, Mode=TwoWay}"/>
                    <Setter Property="IsSelected" Value="{Binding Path=IsSelected, Mode=TwoWay}"/>
                    <Setter Property="FontWeight" Value="Normal"/>
                    <Style.Triggers>
                        <Trigger Property="IsSelected" Value="True">
                            <Setter Property="FontWeight" Value="Bold"/>
                        </Trigger>
                    </Style.Triggers>
                </Style>
            </TreeView.ItemContainerStyle>
            
            <!--DirectoryViewModel のデータを表示するときに使うテンプレート-->
            <TreeView.ItemTemplate>
                <HierarchicalDataTemplate ItemsSource="{Binding Path=Children}">
                    <TextBlock Text="{Binding Path=Name}"/>
                </HierarchicalDataTemplate>
            </TreeView.ItemTemplate>
        </TreeView>
    </Grid>
</Window>

View と ViewModel を結びつけるコードは次の通り。

using System.Windows;

namespace MVVMTreeViewSample
{
    public partial class App : Application
    {
        protected override void OnStartup(StartupEventArgs e)
        {
            var window = new Window1();

            // C ドライブ直下を表示する
            DirectoryViewModel viewModel = new DirectoryViewModel(null, @"C:\");
            window.DataContext = viewModel;

            MainWindow = window;
            MainWindow.Show();

            base.OnStartup(e);
        }
    }
}

今回は Window1 用の ViewModel は作らず、TreeViewItem 用のものを使い回しています。

TreeView を持つ UI で MVVM パターンを適用するのは、ちょっと面倒。TreeViewItem 用の ViewModel ベースクラスを用意した方がいいかも。