SE(たぶん)の雑感記

一応SEやっている筆者の思ったことを書き連ねます。会計学もやってたので、両方を生かした記事を書きたいと考えています。 でもテーマが定まってない感がすごい。

WPFのTreeViewで、ツリー展開時に検索を行って子要素を決める

ネタに窮したのでそういえば、昔作ったツールでそういうのあったなーと思い、書いてみます。
もはや何番煎じかわからないネタだと思いますが…

やりたいこと

ツリーでフォルダを表示する。
ツリーを展開した瞬間に、その配下フォルダを表示する。

環境

OS:Windows 10 Professional
IDEVisual Studio 2015 Community Edition
.NETVersion:.NET Framework 4.5.2

なお、補助ライブラリとして Livet を使用。

Livet - WPF4/4.5 MVVM インフラストラクチャ - Visual Studio Marketplace

注意点

  • Livet の解説はやりません。
  • MVVMパターンを使っていますが、筆者の技量不足により、「それ違うだろ」というものが出てくる可能性があります。
  • 例外処理は無視しています。

最終イメージ

f:id:hiroronn:20170501082310p:plain

チェックボックスとかついていますが、今回は関係ないので無視します。

実装が必要な内容

  • TreeViewが「初めて」展開されたタイミングで、当該フォルダ配下のフォルダ取得
  • 検索中、画面が固まらないような工夫(要するに、検索は別スレッドで)
  • 実際の検索結果が入る前に、ダミーとして入れておくオブジェクト(後述)
    ダミーオブジェクトがないと、そもそもツリーを展開できない

ツリーの項目クラス

WPFのデータバインドで、TreeViewに階層表示するには、HierarchicalDataTemplateを、TreeView.ItemTemplateにセットします。
まずは、そのバインドするためのクラスから見ます。

    /// <summary>
    /// ファイル、ディレクトリを表すオブジェクトの継承元です。
    /// </summary>
    public abstract class FileSystemModel : NotificationObject {
        /*
         * NotificationObjectはプロパティ変更通知の仕組みを実装したオブジェクトです。
         */

        /// <summary>
        /// 絶対パス及び当クラスの親オブジェクトを指定して、当クラスのインスタンスを初期化します。
        /// </summary>
        /// <param name="absolutePath">絶対パス</param>
        /// <remarks>存在しないファイルまたはディレクトリでも、インスタンスを生成できます。</remarks>
        public FileSystemModel(string absolutePath) {
            FullPath = absolutePath;
            Name = FileSystemService.GetName(absolutePath);

            Childs = new ObservableSynchronizedCollection<FileSystemModel>();
        }


        #region IsSearched変更通知プロパティ
        private bool _isSearched;

        /// <summary>
        /// 子孫ディレクトリを取得したかどうか判定した結果を取得します。
        /// </summary>
        public bool IsSearched {
            get { return _isSearched; }
            protected set {
                if (_isSearched == value)
                    return;
                _isSearched = value;
                RaisePropertyChanged();
            }
        }
        #endregion

        /// <summary>
        /// 当ディレクトリの子孫を取得します。
        /// </summary>
        public ObservableSynchronizedCollection<FileSystemModel> Childs { get; private set; }

        /// <summary>
        /// 当ディレクトリの子孫を検索します。
        /// </summary>
        public abstract Task<IEnumerable<FileSystemModel>> SearchChildDirectoryTaskAsync();
    }

これは、ツリーに表示する「ファイル」と「フォルダ」の継承元です。(なお、今回ファイルは利用しません)
子孫のプロパティ(Childs)と、実際に検索を行うメソッド(SearchChildDirectoryTaskAsync)があります。

ディレクトリのクラスはこうなります。

    /// <summary>
    /// ディレクトリを表します。
    /// </summary>
    public class DirectoryModel : FileSystemModel {
        /*
         * NotificationObjectはプロパティ変更通知の仕組みを実装したオブジェクトです。
         */

        public DirectoryModel(string absolutePath)
            : base(absolutePath) {

            Childs.Add(new EmptyFileSystemModel(absolutePath));
            IsSearched = false;
        }

        /// <summary>
        /// 当ディレクトリの子孫を検索します。
        /// </summary>
        public override async Task<IEnumerable<FileSystemModel>> SearchChildDirectoryTaskAsync() {

            if (IsSearched) {
                return Childs;
            }

            var dirs = await FileSystemService.GetDirectoriesAsync(FullPath);

            Childs.Clear();

            foreach (var path in dirs.OrderBy(f => f)) {
                Childs.Add(new DirectoryModel(path));
            }

            IsSearched = true;

            return Childs;
        }
    }

awaitしていることからわかるように、ディレクトリ検索は非同期で行います。 *1

コンストラクタでChildsに格納している、EmptyFileSystemModelですが、これは「検索していない状態」の場合に格納しておく、ダミーオブジェクトです。(後述)

サービスクラス

上で、非同期でディレクトリを検索している、と書いたので、そのクラスも書きます。

    /// <summary>
    /// ファイルシステムへのアクセスを行う場合のメソッドを提供します。
    /// </summary>
    public static class FileSystemService {

        /// <summary>
        /// 指定ディレクトリの子孫となっているディレクトリを、絶対パスで取得します。
        /// </summary>
        /// <param name="dir">絶対パスのディレクトリ</param>
        /// <returns></returns>
        public static Task<IEnumerable<string>> GetDirectoriesAsync(string dir) {
            var result = new Func<IEnumerable<string>>(() => {
                if (!Directory.Exists(dir)) {
                    return Enumerable.Empty<string>();
                }

                return Directory.EnumerateDirectories(dir);
            });

            return Task.Run(result);
        }
    //以下、省略
    }

単純な作りで、ディレクトリ名の列挙を返すメソッドを匿名メソッドにし、Task.Runで実行するだけです。 *2

View

TreeView関連

  • HierarchicalDataTemplate定義
    <Window.Resources>
        <Style TargetType="TreeViewItem">
            <Setter Property="IsExpanded" Value="{Binding Path=IsExpanded, Mode=TwoWay}" />
        </Style>
        <HierarchicalDataTemplate x:Key="selectTree" DataType="vm:FileSystemViewModel" ItemsSource="{Binding Path=Childs}">
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="Auto"/>
                </Grid.ColumnDefinitions>
                <CheckBox Grid.Column="0"  Content="{Binding Path=Name}" IsChecked="{Binding Path=IsSelected}" Margin="0,0,10,0" VerticalAlignment="Center"></CheckBox>
                <Button Grid.Column="1" Command="{Binding Path=SelectAllCommand}"  Margin="0,0,10,0">全選択</Button>
                <Button Grid.Column="2" Command="{Binding Path=DeselectAllCommand}">全解除</Button>
            </Grid>

        </HierarchicalDataTemplate>

        <v:BoolReverseConverter x:Key="ReverseConverter"/>
    </Window.Resources>

今回は、「TreeViewが展開されたときに検索する」必要があるため、IsExpandedプロパティとバインドが必要です。
各ツリー項目は、CheckBoxButtonで構成しています。
HierarchicalDataTemplate.DataTypeで指定している通り、FileSystemViewModelという型(上記FileSystemModelのViewModel)を対象とします。
なお、DataTemplateのKey(x:Key)を、下記のように割り当てます。

  • TreeView宣言
<TreeView Grid.Row="1" ItemsSource="{Binding Path=SourceItems}" ItemTemplate="{StaticResource selectTree}"></TreeView>

TreeView.ItemTemplateで、上記リソースで記述したキーを指定します。
TreeView.ItemsSourceは、FileSystemModelのコレクションです。

ViewModel

画面全体用

private FileCompressModel _model = new FileCompressModel();
public MainWindowViewModel() {
    SourceItems = ViewModelHelper.CreateReadOnlyDispatcherCollection(
        _model.SourceItems,
        (child) => new FileSystemViewModel(child),
        DispatcherHelper.UIDispatcher);
}

/// <summary>
/// 圧縮対象として選択できる項目を表します。
/// </summary>
public IEnumerable<FileSystemViewModel> SourceItems {
    get;
    private set;
}

すみません、思いっきりLivet専用コードです。
ViewModelHelper.CreateReadOnlyDispatcherCollectionですが、非常にざっくり言うと、ModelのコレクションとViewModelのコレクションを、型変換した上で同期してくれます。さらに、UIスレッドへの変更通知も発行してくれます。
ObservableCollection<T>に、型変換が付与された感じです。

ツリー用

private FileSystemModel _model;

/// <summary>
/// 当クラスが利用するModelを指定して、当クラスのインスタンスを初期化します。
/// </summary>
/// <param name="model"></param>
public FileSystemViewModel(FileSystemModel model) {
    _model = model;

    Childs = ViewModelHelper.CreateReadOnlyDispatcherCollection(
        _model.Childs,
        (child) => new FileSystemViewModel(child),
        DispatcherHelper.UIDispatcher);
}

/// <summary>
/// 子ディレクトリ等を取得します。
/// </summary>
public IEnumerable<FileSystemViewModel> Childs { get; private set; }

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

#region IsExpanded変更通知プロパティ
private bool _isExpanded;

/// <summary>
/// 子要素が展開されているかどうか示す値を取得または設定します。
/// </summary>
public bool IsExpanded {
    get { return _isExpanded; }
    set {
        if (_isExpanded == value)
            return;
        _isExpanded = value;
        RaisePropertyChanged();

        if (!_model.IsSearched) {

            Search();
        }
    }
}
#endregion

/// <summary>
/// 子供の検索
/// </summary>
private async void Search() {

    await _model.SearchChildDirectoryTaskAsync();

    SelectAllCommand.RaiseCanExecuteChanged();
    DeselectAllCommand.RaiseCanExecuteChanged();
}

ViewでバインドしたIsExpandedプロパティの変更時、Modelが検索済みでなかったら、検索を行っています。*3

ダミーオブジェクト

TreeViewを使ったことがあるなら、分かると思いますが、そもそも子要素がないと、ツリーを開くことができません。
今回のケースで言うと、FileSystemViewModel.Childsプロパティが空だと、ツリー左に「▷」が表示されません。(下記赤囲み)

f:id:hiroronn:20170501205336p:plain

それを避けるために、ダミーで項目を入れる必要があります。

/// <summary>
/// 空のファイル等を表します。ディレクトリの子孫を検索していない場合は、これが入っています。
/// </summary>
public class EmptyFileSystemModel : FileSystemModel {
    public EmptyFileSystemModel(string absolutePath, FileSystemModel parent)
        : base(Path.Combine(absolutePath, "検索中..."), parent) {

        //検索とかさせない
        IsSearched = true;
    }

    /// <summary>
    /// 実行されません。
    /// </summary>
    /// <returns></returns>
    public override Task<IEnumerable<FileSystemModel>> SearchChildDirectoryTaskAsync() {
        return null;
    }
}

この項目を、初期状態では格納していますが、検索実行後にChildsの中身を削除しているため、ユーザーには「検索中…」の文字列を表示し、終わったら消えます。*4

おわりに

いまどきWPFというのも微妙かもしれませんが、ちょっとでも参考になれば、と思います。
このアプリを、UWPで作り直してみたくなった今日この頃。

*1:今見ると、そもそも公開メソッドをoverrideするなよ、とは思う。継承を前提にするなら、protectedにしておくべき

*2:今見ると、これでstaticはないわー、と思ったり

*3:Modelの状態をここで参照するのは、微妙だと思われる

*4:とはいえ、フォルダ一つ程度の検索なら一瞬で終わるため、ほぼ見えない