こんにちは、働く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#コードから上書きが可能
 - 名前スコープを自動で伝搬させるビヘイビアを作成
 
最後までお読みいただきありがとうございました。
コメント
メチャクチャ参考になりました!
「Viewだけで完結するはずのBindingに、ViewModelのプロパティを介したりしていました。」
まさにこれ!
うまくいかない理由はわかっていたのですが、こんな解決方法があったとは。。。もっと早く知りたかった。。
ありがとうございました!
記載されているコードですが、コンパイル通りますか?
NameScope.SetNameScope(menu, NameScope.GetNameScope(this));
FindAncestor
これらはビルドエラーで失敗するのですが。。。
ご指摘ありがとうございます。
たしかに、他のページで紹介しているクラスを利用してしまっていました。
ページ内にビルドに必要なコードを追記いたしました。
ご参考になれば幸いです。