【WPF】ContextMenuにElementNameでBinding可能にするには

C#

こんにちは、働くC#プログラマーのさんさめです。

今回はWPFの中でもちょっとニッチな話です。

ContextMenuのMenuItemに、
ElementNameを使ったBindingを行おうとしても、
見つからないと言われてしまいます。

この解決法が分からず、さんさめは
Viewだけで完結するはずのBindingに、
ViewModelのプロパティを介したりしていました。

しかし、
実はこの問題はViewだけでも解決できました。

というわけで、本記事では、
この問題を解決できるようにするための
方法について解説します。

スポンサーリンク

なんで見つからないのか?

さて、そもそもどうしてElementNameで
同じxaml内の要素を指定しているにもかかわらず
Bindingに失敗してしまうのでしょうか。

それは、ContextMenuが
独立したVisualTreeを持っていることに起因しています。

ContextMenuは厳密にはPopupであり、
元のWindowとVisualTree上のつながりがありません。

それゆえに、名前スコープが別物となり、
ElementNameで指定しても、
そんなものは無いと言われてしまうのです。

ただし、この時DataContextは伝搬しています。

つまり、ViewModelとのBindingは正常に行えるのです。
このため、私は長らく
ContextMenuで他のUI要素のプロパティと
Bindingしたい時は仕方なくViewModelにプロパティを生やして、
プロキシ的な役割を持たせていました。

NameScopeを設定してやれば見つかるように

しかし、この名前スコープという代物、
実はC#コードから設定することができます。

ContextMenuのNameScopeは、
何もしなければnullになっています。

そこで、NameScope.SetNameScopeメソッドを使って、
名前スコープを設定してみます。

public MainWindow()
{
    InitializeComponent();

    NameScope.SetNameScope(menu, NameScope.GetNameScope(this));
    NameScope.SetNameScope(tooltip, NameScope.GetNameScope(this));
}

さて、実行してみます。

Bindingエラーが起きず、
きちんとテキストがでるようになっています。

デフォルトでNameScopeを引き継ぐようにする

しかし、いちいちコードビハインドで書くのは大変ですね。
このためだけにUI要素に名前を付けるのも面倒です。

ここはビヘイビアにしてみましょう。

ビヘイビアを使うための準備については、
DataGridのSelectedItemsをどんな時でも取得する方法
に載せているので良く分からない方はこちらをどうぞ。

といっても、Nugetから
Microsoft.xaml.Behaviors.Wpfを
インストールするだけです。

そして、以下のようなクラスを作ります。

public class NameScopeInheritBehavior : Behavior<DependencyObject>
{
    protected override void OnAttached()
    {
        base.OnAttached();

        if (!(AssociatedObject is FrameworkElement elem)) { return; }

        elem.Dispatcher?.BeginInvoke((Action)(async () =>
        {
            // VisualTreeにつながるのを待つ
            await Task.Delay(100);

            var sourceNameScope = NameScope.GetNameScope(elem.FindAncestor<Window>());

            var cm = elem.ContextMenu;
            if (cm != null)
            {
                NameScope.SetNameScope(cm, sourceNameScope);
            }

            var tt = elem.ToolTip;
            if (tt is DependencyObject dObj)
            {
                NameScope.SetNameScope(dObj, sourceNameScope);
            }
        }));
    }
}

上記のコードは
【WPF】Viewの祖先要素をC#コードから取得する方法
で紹介しているコードを利用しています。
その中身は以下のものとなります。

    public static class VisualTreeHelperWrapper
    {
        /// <summary>
        /// VisualTreeを親側にたどって、
        /// 指定した型の要素を探す
        /// </summary>
        public static T FindAncestor<T>(this DependencyObject depObj)
            where T : DependencyObject
        {
            while (depObj != null)
            {
                if (depObj is T target)
                {
                    return target;
                }
                depObj = VisualTreeHelper.GetParent(depObj);
            }
            return null;
        }
    }

やっていることは単純で、
ビヘイビアが設定されたUI要素の
ルートオブジェクト(今回はWindow限定)を探索し、
その名前スコープを、
ContextMenuおよびToolTipに伝搬させています。

xaml上での使い方は以下の通りです。

ElementNameによるBindingを使いたい
コンテキストメニューを持つUI要素に設定します。

これで、UI要素に名前を付けたり、
コードビハインドにコードを書く必要が無くなりました。

まとめ

まとめです。

  • ContextMenuやToolTipでは、
    ElementNameによるBindingが失敗する
  • 失敗する理由は名前スコープが異なるせい
  • 名前スコープはC#コードから上書きが可能
  • 名前スコープを自動で伝搬させるビヘイビアを作成

最後までお読みいただきありがとうございました。

コメント

  1. 通りすがり より:

    メチャクチャ参考になりました!

    「Viewだけで完結するはずのBindingに、ViewModelのプロパティを介したりしていました。」

    まさにこれ!
    うまくいかない理由はわかっていたのですが、こんな解決方法があったとは。。。もっと早く知りたかった。。

    ありがとうございました!

  2. へたれえんじにあ より:

    記載されているコードですが、コンパイル通りますか?

    NameScope.SetNameScope(menu, NameScope.GetNameScope(this));

    FindAncestor

    これらはビルドエラーで失敗するのですが。。。

    • さんさめ さんさめ より:

      ご指摘ありがとうございます。
      たしかに、他のページで紹介しているクラスを利用してしまっていました。
      ページ内にビルドに必要なコードを追記いたしました。
      ご参考になれば幸いです。

タイトルとURLをコピーしました