WPF TreeView で任意のノードをコードから選択・展開する方法と SelectedItem が読み取り専用である理由

TreeView.SelectedItem は読み取り専用で代入もバインドもできない。選択の実体が TreeViewItem 側にある点を踏まえ、ItemContainerStyle と ItemContainerGenerator の 2 方式を実測して比較する。

概要

検索でヒットしたフォルダーへのジャンプ、前回終了時の選択位置の復元、追加した項目の選択。
TreeView を使う画面では、こうした「コードから任意のノードを選ぶ」処理が求められる場面が多い。
ところが ListBoxDataGrid と同じ要領で treeView.SelectedItem = node; と書くとコンパイルエラーになり、XAML でバインドしても通常の構成ではビルドが通らない。

本記事では、この制約が TreeView の選択状態の持ち方に由来することを説明し、コードから選択を指示する 2 つの方式と、表示位置・フォーカスの制御を担う添付ビヘイビアを組み合わせた実装を示す。
記載した挙動と例外メッセージは、いずれも .NET 10 / Windows 11 で実際に動かして確認した結果である。


前提・対象環境


問題

階層データを HierarchicalDataTemplate で表示する、ごく一般的な TreeView を対象とする。

<TreeView x:Name="Tree" ItemsSource="{Binding Roots}">
    <TreeView.ItemTemplate>
        <HierarchicalDataTemplate ItemsSource="{Binding Children}">
            <TextBlock Text="{Binding Name}" />
        </HierarchicalDataTemplate>
    </TreeView.ItemTemplate>
</TreeView>

この TreeView に対して目的のノードをコードから選択しようとすると、想定される 3 つの経路がいずれも塞がっていることが分かる。

第 1 に、CLR プロパティへの代入はコンパイルできない。
TreeView.SelectedItem は getter しか持たないためである。

Tree.SelectedItem = target;
// error CS0200: Property or indexer 'TreeView.SelectedItem' cannot be assigned to
// -- it is read only

第 2 に、依存関係プロパティとして直接書き込む迂回も失敗する。
SetValue は実行時に例外を投げる。

Tree.SetValue(TreeView.SelectedItemProperty, target);
// InvalidOperationException: 'SelectedItem' property was registered as read-only
// and cannot be modified without an authorization key.

第 3 に、XAML からのバインドも通らない。
XAML をコンパイルする通常のプロジェクト構成では、次の記述はビルドの時点で失敗する。

<!-- ビルドエラー MC3065 になる -->
<TreeView ItemsSource="{Binding Roots}"
          SelectedItem="{Binding CurrentNode, Mode=TwoWay}" />
error MC3065: 'SelectedItem' property is read-only and cannot be set from markup.

同じ XAML を XamlReader.Parse で実行時に読み込む構成では、読み込みの時点で XamlParseException となり、内部例外に ArgumentException: 'SelectedItem' property cannot be data-bound. (Parameter 'dp') が入る。
コードから BindingOperations.SetBinding を呼んだ場合も同じ ArgumentException になる。

ModeOneWay にしても結果は変わらない点が、この制約を分かりにくくしている。
読み取り専用の依存関係プロパティは値の書き込み方向を問わずバインディングのターゲットにできず、Mode の指定では回避できない。

引用したエラー・例外メッセージは英語リソースのものである。
日本語環境では対応する日本語のメッセージが出力される。


原因・背景

TreeView は選択状態を自分では保持していない。
選択されているという状態を持つのは各 TreeViewItem であり、その IsSelected プロパティが実体である。
TreeView.SelectedItem は、選択されているコンテナに対応するデータ項目を外から読み出すための射影にすぎない。

このため SelectedItem は読み取り専用の依存関係プロパティとして登録されている。
実際に TreeView.SelectedItemProperty.ReadOnlytrue を返す。
読み取り専用の依存関係プロパティは、登録時に得られる DependencyPropertyKey を保持しているコードだけが値を書き込める。
外部からの SetValueInvalidOperationException になるのも、マークアップからの設定やバインドが拒否されるのも、この登録方法の帰結である。

したがって「コードから選択する」という操作は、実質的に「目的のノードに対応する TreeViewItem を存在させ、その IsSelectedtrue にする」という操作に置き換わる。
ここで 2 つ目の壁が現れる。
TreeViewItem は XAML に静的に並んでいるのではなく、コンテナが必要になった時点で ItemContainerGenerator が生成するためである。

生成のタイミングは実測すると次のようになる。

状態 ItemContainerGenerator.Status ContainerFromItem の戻り値
ルート項目(表示済み) ContainersGenerated コンテナ
折りたたまれた親の子 NotStarted null
IsExpanded = true の直後 NotStarted null
上記のあと UpdateLayout() 実行後 ContainersGenerated コンテナ

折りたたまれたノードの子はコンテナが 1 つも作られていない。
さらに、親を展開してもコンテナはその場では作られず、レイアウトパスが走ってはじめて生成される。
IsExpanded = true を実行した直後に ContainerFromItem を呼んでも null が返るのはこのためである。

以上から、この問題は「読み取り専用プロパティをどう書き換えるか」ではなく、「選択という状態をどこに置き、コンテナの生成タイミングにどう対処するか」の設計問題として扱うのが適切である。


解決方法

選択状態と展開状態を ViewModel 側のノードに持たせ、ItemContainerStyleSetterTreeViewItemIsSelected / IsExpanded と双方向にバインドする。

この方式が有効なのは、バインドの適用時点がコンテナの生成時点になるためである。
コンテナが存在しない状態で ViewModel のプロパティを変更しても、後からコンテナが生成された時点でスタイルの Setter が評価され、その時の ViewModel の値が反映される。
呼び出し側はコンテナの生成タイミングを気にする必要がなく、UpdateLayout も不要になる。

ItemContainerStyleSetter に書いた Binding は、コンテナの DataContext、すなわち対応するデータ項目を起点に解決される。
{Binding IsSelected} はノードの ViewModel の IsSelected を指す。
このため、ノードの型に IsSelected / IsExpanded を用意しておく必要がある。
ItemsControl 系のコントロールでコンテナの DataContext がデータ項目へ切り替わる仕組みは WPF の DataTemplate 内から親の DataContext にバインドできない原因と RelativeSource の使い分け で扱っている。


実装例

ノードの ViewModel には、表示用の情報に加えて選択状態・展開状態、および親への参照を持たせる。
親への参照は、目的のノードを表示するために祖先をすべて展開する処理で使う。

public sealed class FolderNode : INotifyPropertyChanged
{
    private bool isSelected;
    private bool isExpanded;

    public FolderNode(string name) => Name = name;

    public string Name { get; }

    public FolderNode? Parent { get; private set; }

    public ObservableCollection<FolderNode> Children { get; } = [];

    public bool IsSelected
    {
        get => isSelected;
        set
        {
            if (isSelected != value)
            {
                isSelected = value;
                OnPropertyChanged();
            }
        }
    }

    public bool IsExpanded
    {
        get => isExpanded;
        set
        {
            if (isExpanded != value)
            {
                isExpanded = value;
                OnPropertyChanged();
            }
        }
    }

    public FolderNode Add(FolderNode child)
    {
        child.Parent = this;
        Children.Add(child);
        return child;
    }

    /// <summary>ルートまでの祖先を展開し、自ノードを選択する。</summary>
    public void SelectAndReveal()
    {
        for (FolderNode? ancestor = Parent; ancestor is not null; ancestor = ancestor.Parent)
        {
            ancestor.IsExpanded = true;
        }

        IsSelected = true;
    }

    public event PropertyChangedEventHandler? PropertyChanged;

    private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

INotifyPropertyChanged の実装は省略できない。
双方向バインドのうち UI からの書き戻しは通知が無くても働くが、ViewModel からコンテナへ値を反映する方向は変更通知に依存するためである。

XAML 側では ItemContainerStyle に 2 つの Setter を置く。
ItemTemplate はノードの見た目を、ItemContainerStyle はコンテナの状態を担当する、という役割の分離になる。

<DockPanel Margin="12">
    <TextBlock DockPanel.Dock="Bottom" Margin="4,10,0,0"
               FontFamily="Consolas, Courier New" FontSize="12" Foreground="#333D4D"
               Text="{Binding SelectedItem.Name, ElementName=Tree,
                      StringFormat='TreeView.SelectedItem = {0}'}" />

    <TreeView x:Name="Tree" ItemsSource="{Binding Roots}"
              behaviors:RevealSelectedItemBehavior.IsEnabled="True">
        <TreeView.ItemContainerStyle>
            <Style TargetType="TreeViewItem">
                <Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
                <Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
            </Style>
        </TreeView.ItemContainerStyle>
        <TreeView.ItemTemplate>
            <HierarchicalDataTemplate ItemsSource="{Binding Children}">
                <TextBlock Text="{Binding Name}" />
            </HierarchicalDataTemplate>
        </TreeView.ItemTemplate>
    </TreeView>
</DockPanel>

behaviors は、後述する添付ビヘイビアを定義した名前空間へ割り当てた XAML 名前空間の接頭辞である(xmlns:behaviors="clr-namespace:(名前空間)")。
StringFormat の値を引用符で囲んでいるのは、マークアップ拡張の中で = を含む文字列をそのまま書くとパーサーが名前付き引数の区切りと解釈するためである。
また、この TextBlock は読み取り専用の SelectedItem をバインドのソースとして読んでいる。
読み取り専用の依存関係プロパティに書き込めないのはターゲットになる場合であり、値を読み出す用途では制約を受けない。

選択の呼び出しは ViewModel のノードに対して行う。
TreeView にもコンテナにも触れない。
Roots はウィンドウの DataContext に設定した ViewModel が持つルートノードのコレクション、FindNode はアプリケーション側で用意した探索処理である。

FolderNode target = FindNode(root, "drivers");
target.SelectAndReveal();

SelectAndReveal の中で祖先を展開する順序は、ルート方向・末端方向のどちらでもよい。
ViewModel のプロパティを設定しているだけであり、コンテナが生成された時点でその値が読み出されるためである。

選択されたノードを表示範囲へスクロールし、フォーカスを移す処理は添付ビヘイビアに切り出す。
TreeViewItem.Selected はバブルする RoutedEvent であるため、TreeView 側で 1 か所に登録すればすべてのノードを扱える。

public static class RevealSelectedItemBehavior
{
    public static readonly DependencyProperty IsEnabledProperty =
        DependencyProperty.RegisterAttached(
            "IsEnabled",
            typeof(bool),
            typeof(RevealSelectedItemBehavior),
            new PropertyMetadata(false, OnIsEnabledChanged));

    public static void SetIsEnabled(DependencyObject element, bool value)
        => element.SetValue(IsEnabledProperty, value);

    public static bool GetIsEnabled(DependencyObject element)
        => (bool)element.GetValue(IsEnabledProperty);

    private static void OnIsEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is not TreeView treeView)
        {
            return;
        }

        if ((bool)e.NewValue)
        {
            treeView.AddHandler(TreeViewItem.SelectedEvent, new RoutedEventHandler(OnItemSelected));
        }
        else
        {
            treeView.RemoveHandler(TreeViewItem.SelectedEvent, new RoutedEventHandler(OnItemSelected));
        }
    }

    private static void OnItemSelected(object sender, RoutedEventArgs e)
    {
        if (e.OriginalSource is not TreeViewItem item)
        {
            return;
        }

        item.Dispatcher.BeginInvoke(
            DispatcherPriority.Loaded,
            new Action(() =>
            {
                item.BringIntoView();
                item.Focus();
            }));
    }
}

BringIntoViewDispatcherPriority.Loaded へ遅延させているのは、コンテナが生成された直後はレイアウトが確定しておらず、スクロール位置を計算できないためである。
Focus を併せて呼ぶと選択がアクティブな配色で描画される。
フォーカスを移したくない画面では Focus の行を外す。

上記の XAML とコードをそのまま実行し、3 階層下の drivers に対して SelectAndReveal を呼んだ結果が次の図である。

TreeView で C: / Windows / System32 が展開され、その下の drivers が選択色で強調表示されている。下部のテキストに TreeView.SelectedItem = drivers と表示されている。
ViewModel の IsExpanded / IsSelected を変更しただけで、祖先が展開され目的のノードが選択された状態。下部の行は読み取り専用の TreeView.SelectedItem をバインドのソースとして読み出したもので、コンテナ側の選択に追従していることを示す。選択部分の配色は OS のアクセントカラー設定に従う(.NET 10 / Windows 11 で生成)。

注意点


代替案・比較

選択を指示する 2 方式と添付ビヘイビアに、選択結果を読み出すだけの SelectedItemChanged を加えて比較する。

方法 選択の指示 仮想化との相性 メリット デメリット
ItemContainerStyle で双方向バインド ViewModel のプロパティ 実体化した時点で反映 コンテナの生成タイミングを意識しない。MVVM から離れない ノードの型に状態を持たせる必要がある
ItemContainerGenerator を辿る コンテナへの直接代入 画面外は実体化させないと取得できない ViewModel を変更せずに済む 各階層で UpdateLayout が必要。双方向バインドを併用しない構成では設定した値がローカル値になる
添付ビヘイビア 上記のいずれかを内包 内包した方式に従う 表示位置やフォーカスの制御を再利用できる 単体では選択の指示手段にならない
SelectedItemChanged 指示できない(読み出しのみ) 影響なし UI 上の選択を ViewModel へ渡せる 逆方向、すなわちコードからの選択には使えない

ItemContainerGenerator を辿る方式は、ViewModel を変更できない場合や、既存のコードビハインドへ最小の追加で対応する場合の選択肢になる。
ルートから目的のノードまでのパスを受け取り、各階層でコンテナを取得しながら展開していく。

public static bool SelectByPath(TreeView treeView, IReadOnlyList<object> path)
{
    ItemsControl parent = treeView;

    for (int i = 0; i < path.Count; i++)
    {
        // 直前に展開した階層(初回はルート)のコンテナを生成させる。
        parent.UpdateLayout();

        if (parent.ItemContainerGenerator.ContainerFromItem(path[i]) is not TreeViewItem container)
        {
            return false;
        }

        if (i == path.Count - 1)
        {
            container.IsSelected = true;
            container.BringIntoView();
            return true;
        }

        container.IsExpanded = true;
        parent = container;
    }

    return false;
}

UpdateLayout の呼び出しがこの実装の要である。
同じコードから UpdateLayout を取り除くと、ルートの子を取得する時点で ContainerFromItemnull を返し、メソッドは false を返して選択に失敗した。
展開してからコンテナを取得するまでの間に、レイアウトパスを 1 回挟む必要がある。

なお UpdateLayout は同期的にレイアウトパス全体を実行するため、階層が深いツリーや項目数の多いツリーでは相応のコストになる。
このメソッドの呼び出し回数はパスの階層数に比例するため、常時走る処理からではなく、選択を移動する操作の中でのみ呼び出す。


まとめ

TreeView.SelectedItem が読み取り専用なのは実装上の制限ではなく、選択の状態を保持しているのが TreeViewItem 側であることの反映である。
コードから選択するには、目的のノードのコンテナを存在させ、その IsSelectedtrue にするという経路をとる。

方式の選択基準は次のとおりである。