気まま研究所ブログ

ITとバイク、思ったことをてきとーに書きます。

WPF TreeViewで選択や展開を保持したまま再描写する

f:id:AonaSuzutsuki:20191125155058p:plain

こんにちは。
タイトル考えたけどいいのが思いつかなかった。
ただの雑記だしいいか。

本題ですが、TreeViewってあんまり使う機会思いつかないけど地味に結構使うコントロールだと思います。
私も最近XMLエディタを作ることがあって使ったんですが、普通にItemSourceに設定するだけじゃ再描写で選択していたアイテムや展開していたアイテムが元に戻ってしまいます。
XMLだとデータを変更した後だったり、再描写する機会がかなりあるのでそういった状態も保持したまま再描写したいものです。
今回はそんな状態を持たせたまま再描写するお話です。

検証環境

項目 詳細
OS Windows 10 Pro x64 1903
.Net .Net Core 3.0
Prism.Core 7.2.0.1422
ReactiveProperty 6.1.4

今回は試しに.Net Core 3.0でやってますが、XMLエディタは.Net Framework 4.7.1で作ったのでどちらでも動くはずです。

はじめに

f:id:AonaSuzutsuki:20191125155058p:plain

今回サンプルとして作ったアプリはTreeViewと下にクリアボタンと描写するボタンのみのテキトーなものです。
Clearを押した後にDrawを押すと再描写されます。

なお、記事書いた後にイベント通知用の処理も追加しているので若干ボタンが増えたり違うところがあります。

一応全コードをGitHubに置いてあるので適当に使ってください。
https://github.com/AonaSuzutsuki/TreeViewSample

XAML

MainWindow.xaml

まずはWPFXAMLコードから。
TreeViewにItemSourceにバインディングを設定し、さらにItemTemplateでアイテムのテンプレートを定義します。
DataTemplateとほぼ同じですが、HierarchicalDataTemplateでは木構造を表現するためにItemSourceに子要素のバインディングを設定します。

表示に関してはこれで問題ありませんが、問題の選択中を表すプロパティと展開を表すプロパティがHierarchicalDataTemplateでは設定できません。
そこでItemContainerStyleにてTreeViewItemのStyleを設定します。
Style内でIsSelectedとIsExpandedの両プロパティバインディングします。

<Window x:Class="TreeViewSample.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:models="clr-namespace:TreeViewSample.Models"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition />
            <RowDefinition Height="30" />
        </Grid.RowDefinitions>

        <TreeView ItemsSource="{Binding TreeViewItems}">
            <TreeView.ItemTemplate>
                <HierarchicalDataTemplate DataType="models:TreeViewItemInfo" ItemsSource="{Binding Children}">
                    <TextBlock Text="{Binding Name}" />
                </HierarchicalDataTemplate>
            </TreeView.ItemTemplate>

            <TreeView.ItemContainerStyle>
                <Style TargetType="{x:Type TreeViewItem}">
                    <Setter Property="IsSelected" Value="{Binding Path=IsSelected}" />
                    <Setter Property="IsExpanded" Value="{Binding Path=IsExpanded}"/>
                </Style>
            </TreeView.ItemContainerStyle>
        </TreeView>

        <StackPanel Grid.Row="1" Orientation="Horizontal">
            <Button Content="Clear" Height="30" Width="100" Command="{Binding ClearBtClicked}" />
            <Button Content="Draw" Height="30" Width="100" Command="{Binding DrawBtClicked}" />
        </StackPanel>

    </Grid>
</Window>

XAMLはこれで完了です。

Model

TreeViewItemInfo.cs

ModelにはTreeViewItemの各アイテム用のTreeViewItemInfoクラスを用意します。
HierarchicalDataTemplateの中に定義したバインディングはもちろんのこと、Style内で定義したバインディングもこのクラスのプロパティとバインディングされます

ちなみに双方向やビューに対する一方向が必要な場合は通知イベントを定義してあげればコードからの変更も適用されます。
今回はPrism.Coreを入れているのでBindableBaseを継承してSetPropertyメソッドを実行すればOKかな。

using System.Collections.Generic;

namespace TreeViewSample.Models
{
    public class TreeViewItemInfo
    {
        public IEnumerable<TreeViewItemInfo> Children { get; set; }
        public string Name { get; set; }

        public bool IsExpanded { get; set; }

        public bool IsSelected { get; set; }
    }
}

MainWindowModel.cs

GUI用のMainWindowModelは特に込み入った処理はありません。
サンプル用なのでコンストラクタがキモいことになってますが、それ以外はプロパティを置いてクリア処理と描写処理を置いただけです。
ちなみに、例外処理は置いていないのでDrawを先に押すと例外でます。

using System.Collections.Generic;
using System.Collections.ObjectModel;
using Prism.Mvvm;

namespace TreeViewSample.Models
{
    public class MainWindowModel : BindableBase
    {
        #region Fields

        private IEnumerable<TreeViewItemInfo> _savedItemInfos;
        private ObservableCollection<TreeViewItemInfo> _items;
        #endregion

        #region Properties
        public ObservableCollection<TreeViewItemInfo> Items
        {
            get => _items;
            set => SetProperty(ref _items, value);
        }
        #endregion

        public MainWindowModel()
        {
            Items = new ObservableCollection<TreeViewItemInfo>
            {
                new TreeViewItemInfo
                {
                    Name = "Item1"
                },
                new TreeViewItemInfo
                {
                    Name = "Item2",
                    Children = new TreeViewItemInfo[]
                    {
                        new TreeViewItemInfo
                        {
                            Name = "SubItem1"
                        },
                        new TreeViewItemInfo
                        {
                            Name = "SubItem2",
                            Children = new TreeViewItemInfo[]
                            {
                                new TreeViewItemInfo
                                {
                                    Name = "SubSubItem1"
                                }
                            }
                        }
                    }
                },
                new TreeViewItemInfo
                {
                    Name = "Item3"
                }
            };
        }

        public void Clear()
        {
            _savedItemInfos = new List<TreeViewItemInfo>(Items);
            Items.Clear();
        }

        public void DrawItems()
        {
            foreach (var item in _savedItemInfos)
            {
               Items.Add(item); 
            }
        }
    }
}

ViewModel

MainWindowViewModel.cs

ViewModelも特に込み入った処理はありません。

using System.Windows.Input;
using Prism.Commands;
using Reactive.Bindings;
using TreeViewSample.Models;

namespace TreeViewSample.ViewModels
{
    public class MainWindowViewModel
    {
        #region Fields
        private readonly MainWindowModel model;
        #endregion

        #region Properties
        public ReadOnlyReactiveCollection<TreeViewItemInfo> TreeViewItems { get; set; }
        #endregion

        #region Event Properties
        public ICommand ClearBtClicked { get; set; }
        public ICommand DrawBtClicked { get; set; }
        #endregion

        public MainWindowViewModel(MainWindowModel model)
        {
            this.model = model;
            TreeViewItems = model.Items.ToReadOnlyReactiveCollection(item => item);

            ClearBtClicked = new DelegateCommand(ClearBt_Clicked);
            DrawBtClicked = new DelegateCommand(DrawBt_Clicked);
        }

        #region Event Methods
        public void ClearBt_Clicked()
        {
            model.Clear();
        }
        public void DrawBt_Clicked()
        {
            model.DrawItems();
        }
        #endregion
    }
}

コードビハインド

MainWindow.xaml.cs

コードビハインドも...。

using System.Windows;
using TreeViewSample.Models;
using TreeViewSample.ViewModels;

namespace TreeViewSample.Views
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();

            var model = new MainWindowModel();
            var vm = new MainWindowViewModel(model);
            DataContext = vm;
        }
    }
}

こういうのってTreeViewItemを継承してそれをItemSourceにデータバインディングすればいけそうなもんですが、うまくいかないんですよね。
事前に基盤作っちゃえばあとは簡単なんですが、そこまでたどり着くまでがこういうTreeViewやListViewのGUIコントロールはめんどくさい。
特にデータバインディングだとどこが問題なのかわかりづらいから頭痛くなる。