WPF の UserControl に定義した DependencyProperty へ内部からバインドできない原因と DataContext の設計

UserControl に依存関係プロパティを追加したのに、内部の {Binding Title} だけが空欄になる。DataContext の継承という原因を .NET 10 の実測で切り分け、RelativeSource・ElementName・内側ルートへの委譲を比較する。

概要

再利用する部品を UserControl として切り出し、外から値を受け取るために依存関係プロパティを追加する。
利用側の Title="{Binding HeaderText}" は正しく届いており、デバッガーで Title プロパティを見れば期待した文字列が入っている。
それにもかかわらず、コントロールの内部に書いた {Binding Title} だけが空欄のままになる。

原因は依存関係プロパティの登録方法ではない。
{Binding} の既定の起点が DataContext であること、そして UserControl 要素の DataContext が利用側から継承されることの 2 点が重なった結果である。
プロパティには値が届いているのに画面が空になるという非対称が、この症状の特定を難しくしている。

本記事では、値が届いていることと表示されないことが両立する理由を分解し、内部から自身の依存関係プロパティを参照する 3 つの書き方を比較する。
広く出回る DataContext = this という対処が、なぜ利用側の書き方によって効いたり効かなかったりするのかも併せて扱う。
本記事に「実測」と記した値は、すべて後述の環境で実際に動かして得た結果である。


前提・対象環境

掲載する XAML の ... は、標準の xmlns 宣言など本題に関係しない属性の省略を示す。
そのまま貼り付けても解析できないため、実際のファイルでは通常の宣言に置き換える。


問題

見出しの文字列を外から受け取る InfoCard を作る。
コードビハインドで Title を依存関係プロパティとして登録する。

public partial class InfoCard : UserControl
{
    public static readonly DependencyProperty TitleProperty =
        DependencyProperty.Register(
            nameof(Title), typeof(string), typeof(InfoCard), new PropertyMetadata(string.Empty));

    public InfoCard() => InitializeComponent();

    public string Title
    {
        get => (string)GetValue(TitleProperty);
        set => SetValue(TitleProperty, value);
    }
}

登録内容に誤りはなく、Title は外部から設定できる状態にある。
続けて、この値を表示する XAML を書く。

<UserControl x:Class="Sample.InfoCard" ...>
    <Border BorderBrush="Gray" BorderThickness="1" Padding="6">
        <TextBlock x:Name="TitleText" Text="{Binding Title}" />
    </Border>
</UserControl>

TextBlock に付けた x:Name は、後述するトレースの読み取りに使うためのものであり、表示自体には影響しない。

利用側では、ウィンドウの DataContext に設定した ViewModel の HeaderText を渡す。

<local:InfoCard Title="{Binding HeaderText}" />

この構成を動かすと、InfoCard の枠だけが描かれ、文字列は表示されない。
実測では InfoCard.TitleHeaderText の値を保持しており、内部の TextBlock.Text だけが空文字列であった。
Title の既定値を空文字列以外にしても表示は変わらない。
TextBlock が表示していたのは Title の既定値ではなく、解決に失敗したバインディングのターゲット側の既定値だからである。

出力ウィンドウには次のトレースが記録される。

System.Windows.Data Error: 40 : BindingExpression path error: 'Title' property not found on
'object' ''PageViewModel' (HashCode=18705942)'. BindingExpression:Path=Title;
DataItem='PageViewModel' (HashCode=18705942); target element is 'TextBlock' (Name='TitleText');
target property is 'Text' (type 'String')

DataItem に現れているのが InfoCard ではなく利用側の ViewModel である点が、原因を直接示している。
バインディングは InfoCard.Title ではなく、PageViewModel.Title を探していた。


原因・背景

{Binding Title} のように Path だけを書いたバインディングは、ソースを指定していない。
ソースを省略したバインディングは、ターゲット要素の DataContext を起点として Path を解決する。

DataContext は要素ツリーを下方向へ継承される値である。
InfoCard を配置した時点で、InfoCard 要素の DataContext には利用側のウィンドウが持つ ViewModel が流れ込む。
InfoCard の内部要素はさらにそれを継承する。
実測でも、内部の TextBlock から見た DataContextInfoCard ではなく PageViewModel であった。

一方、TitleInfoCard という要素のプロパティであり、DataContext に載っているオブジェクトのプロパティではない。
依存関係プロパティとして登録しても、この関係は変わらない。
外から Title="{Binding HeaderText}" と書いたバインディングが成立するのは、そのバインディングのターゲットが InfoCard 要素で、ソースが利用側の DataContext だからである。
値が届くことと、内部から参照できることは別の経路の話である。

記法ごとの起点を整理すると次のようになる。

記法 値を探す起点 内部から自身の DP への到達可否
{Binding Title} その要素の DataContext(継承値) 到達しない
{Binding Title, RelativeSource={RelativeSource AncestorType=...}} 要素の親チェーンをさかのぼった祖先要素 到達する
{Binding Title, ElementName=Root} 同じ名前スコープ内の名前付き要素 到達する
{Binding Title, Source=...} 明示したオブジェクト 到達しない(マークアップ上で解決できる固定のオブジェクトに限られる)

この症状が厄介なのは、失敗の現れ方が状況によって変わる点にある。

利用側の DataContext に同名のプロパティがある場合、エラーは一切出ない。
ViewModel 側にも Title という名前のプロパティが存在すると、内部の {Binding Title} はそちらへ解決される。
実測では、InfoCard.TitleVM-TITLE を渡しているにもかかわらず、内部の TextBlock には ViewModel 側の値である VM-OWN-TITLE が表示され、トレースは 1 行も出力されなかった。
値が出ている以上、バインディングの誤りとして疑われにくい。

DataContextnull の場合も、エラーは一切出ない。
DataContext を設定していない親の下に InfoCard を置いた実測では、Title に値を設定していても内部は空欄のままで、出力ウィンドウに追加されたトレースは 0 行であった。
バインディングはソースが未確定の状態で待機し、失敗として報告されない。
出力ウィンドウにエラーが出ないことは、バインディングが正しいことの証明にならない。
エラーが出た場合のメッセージの読み方は WPF バインディングエラーの読み方と出力ウィンドウを使った原因特定 で扱っている。


解決方法

内部のバインディングに、DataContext 以外の起点を明示する。
選択肢は 3 つある。

  1. RelativeSource で祖先をたどる — 要素の親チェーンを上方向に探索し、UserControl 自身を起点にする。
    バインディングごとに指定する。
  2. ElementName で自身を名前で指す — ルート要素に x:Name を付け、その名前を参照する。
    バインディングごとに指定する。
  3. 内側のルート要素へ DataContext を委譲するUserControl の直下に置いたパネルの DataContext だけを UserControl 自身へ切り替える。
    以降は内部のすべてのバインディングを {Binding Title} のまま書ける。

いずれも UserControl 要素そのものの DataContext には手を触れない。
ここを書き換えると利用側からのバインディングが壊れる。
その詳細は「注意点」で扱う。

参照箇所が少ない場合や、内部で利用側の DataContext も併用したい場合は 1 を採る。
2 は 1 とほぼ等価であり、記述を短くしたい場合に選ぶ(両者の差は「代替案・比較」で扱う)。
内部で参照するプロパティが 3 つ以上あるコントロールでは 3 を採る。


実装例

まず 1 と 2 を、同じコントロールの中に並べて確認する。
ルート要素へ x:Name="Root" を付け、Title を 3 通りの書き方で表示する。

<UserControl x:Class="Sample.InfoCard" x:Name="Root" ...>
    <StackPanel>
        <TextBlock Text="{Binding Title}" />
        <TextBlock Text="{Binding Title, RelativeSource={RelativeSource AncestorType=UserControl}}" />
        <TextBlock Text="{Binding Title, ElementName=Root}" />
    </StackPanel>
</UserControl>

3 つとも同じコントロールの同じプロパティを指す意図で書いており、差は起点の指定方法だけである。
これを Title="{Binding HeaderText}" として配置すると、値が現れるのは下 2 つに限られる。

UserControl を 1 つ配置した画面。最上部に利用側のマークアップが 1 行あり、その下の枠の中に 3 組の記法ラベルと表示欄が縦に並ぶ。素の Binding Title を使った一番上の欄だけが空欄で、RelativeSource を使った欄と ElementName を使った欄には Report と表示されている。
同じ Title を 3 通りの書き方で表示した結果。InfoCard の範囲を示す外枠、各欄の上の記法ラベル、最上部の利用側マークアップは、いずれも対応関係を示すために図へ付加したものである(.NET 10 / Windows 11 で生成)。

AncestorType=UserControl は最も近い UserControl を探す。
そのため、バインディングを書いた要素が別の UserControl の内側に入っている構成では、意図した対象を外す。
これを避けるには、探索対象を自身の型で指定する。

<TextBlock Text="{Binding Title,
           RelativeSource={RelativeSource AncestorType={x:Type local:InfoCard}}}" />

型を指定すると探索はその型(およびその派生型)に一致する祖先で止まるため、内部の入れ子構成が変わっても対象は変わらない。
この書き方では local 名前空間の宣言(xmlns:local="clr-namespace:Sample")が必要になる。

次に 3 の書き方を示す。
UserControl の直下に置いた GridDataContext だけを自身へ向ける。

<UserControl x:Class="Sample.InfoCard" x:Name="Root"
             xmlns:local="clr-namespace:Sample" ...>
    <Grid DataContext="{Binding RelativeSource={RelativeSource AncestorType={x:Type local:InfoCard}}}">
        <StackPanel>
            <TextBlock Text="{Binding Title}" />
            <TextBox Text="{Binding Title, UpdateSourceTrigger=PropertyChanged}" />
        </StackPanel>
    </Grid>
</UserControl>

DataContext を設定する対象が UserControl 要素ではなく、その子である点が重要である。
実測では、この構成で Grid 配下の DataContextInfoCard になる一方、InfoCard 要素自身の DataContext は利用側の ViewModel のままであった。
外からのバインディングと内部のバインディングが、互いに干渉せずに成立する。

内部から値を書き戻すには、外からのバインディングが双方向で成立している必要がある。
この条件は、依存関係プロパティ側のメタデータで既定の転送方向を変えるか、利用側で Mode=TwoWay を指定するかのいずれかで満たせる。
DependencyProperty.RegisterPropertyMetadata を渡した場合、外からのバインディングは既定で片方向になる。

public static readonly DependencyProperty TitleProperty =
    DependencyProperty.Register(
        nameof(Title), typeof(string), typeof(InfoCard),
        new FrameworkPropertyMetadata(
            string.Empty, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

実測では、この指定を加えた Title に対して内部の TextBox を編集すると、値が ViewModel の HeaderText まで戻った。
上の TextBox のように内部から Title を更新する構成では、この指定を省略すると外側のバインディングが壊れる。
その詳細は次節で扱う。
利用側で Mode=TwoWay を明示しても同じ結果になるが、双方向が前提の入力用コントロールでは、利用側の書き忘れを防げる BindsTwoWayByDefault が扱いやすい。


注意点

コンストラクターで DataContext = this と書くと、内部の {Binding Title} は動くようになる。
しかし UserControl 要素の DataContext が自身に固定されるため、今度は利用側の Title="{Binding HeaderText}"InfoCard の中に HeaderText を探して失敗する。
実測では System.Windows.Data Error: 40 が記録され、Title は既定値のままであった。
<UserControl DataContext="{Binding RelativeSource={RelativeSource Self}}"> と XAML で書いた場合も結果は同じである。

この破綻は、利用側の書き方によって現れたり隠れたりする。
リテラルを渡した Title="Report" はバインディングを介さないため、DataContext の内容に関係なく成立する。
同じコントロールでも、渡し方がリテラルかバインディングかで結果が分かれる。

DataContext = this を設定したコントロールを 2 つ並べた画面。各コントロールの上に利用側のマークアップが 1 行ずつ添えられ、リテラルで Title を渡した上の表示欄には Report と表示され、Binding で渡した下の表示欄は空欄になっている。
コンストラクターで DataContext = this を設定した同一のコントロール。上下の差は利用側の渡し方だけである。各コントロールの上のマークアップは、その渡し方を示すために図へ付加したものである(.NET 10 / Windows 11 で生成)。

残りの落とし穴を以下に挙げる。


代替案・比較

内部から自身の依存関係プロパティを参照する 4 つの方法を比較する。

方法 記述量 利用側からのバインディング ContextMenu 適するケース
RelativeSource AncestorType バインディングごとに指定 影響なし 解決しない 参照箇所が少なく、内部で利用側の DataContext も併用する場合
ElementName + ルートの x:Name バインディングごとに指定 影響なし 解決しない 記述を短くしたい場合
内側ルートへ DataContext を委譲 1 箇所のみ 影響なし 解決する 内部で参照するプロパティが 3 つ以上ある場合
DataContext = this 1 箇所のみ 壊れる 解決する 該当なし(採用しない)

RelativeSourceElementName は通常の構成では結果が等価であり、差が出るのは次の 2 つの構成に限られる。

1 つは、実装例で触れた「バインディングを書いた要素が別の UserControl の内側に入っている構成」である。
ElementName は名前で直接指すため、この影響を受けない。
実測でも、別の UserControl の内側に置いた要素から AncestorType=UserControl を評価すると、外側のコントロールではなく内側のコントロールが選ばれた。
AncestorType={x:Type local:InfoCard} と自身の型を指定すれば RelativeSource でも対象は安定するが、その型の派生型が祖先にある場合はやはり近い方が選ばれる。

もう 1 つは、テンプレートを別のコントロールへ再利用する構成である。
ElementName は使用側の名前スコープに Root が無い時点で解決できなくなるのに対し、AncestorType は祖先の型さえ一致すれば成立する。

内側ルートへの委譲は、内部の記述が {Binding Title} のまま済む点が最大の利点である。
一方で、内部から利用側の DataContext を参照するには一手間が要る。
UserControl 要素自身の DataContext は利用側の ViewModel のままであるため、{Binding DataContext.HeaderText, RelativeSource={RelativeSource AncestorType={x:Type local:InfoCard}}} と書けば到達でき、実測でも解決した。
ただし、この参照を必要とする設計は再利用可能な部品として成立していない可能性が高く、必要なデータは依存関係プロパティとして明示的に受け取る形へ変えるのが妥当である。

DataContext = this は、内部の記述量という点では委譲と同じ利点を持つが、利用側からのバインディングを壊す。
コントロールを自分の 1 画面でしか使わない段階では症状が出ないため、再利用を始めた時点で問題が表面化する。


まとめ

内部の {Binding} が空欄になったときは、まず DataContext の中身を確認する。
値が届いているかどうかは、依存関係プロパティを直接読めば切り分けられる。

構成の選択は次を基準とする。
内部で参照するプロパティが 3 つ以上あるコントロールでは、内側のルート要素へ DataContext を委譲し、内部の記述を {Binding Title} のまま保つ。
参照箇所が 1、2 か所にとどまる場合や、内部で利用側の DataContext も併せて参照する場合は、RelativeSource AncestorType={x:Type local:InfoCard} を個別に指定する。
記述を短くしたい場合は ElementName を使う。
いずれの場合も UserControl 要素自身の DataContext には代入しない。
内部から値を書き戻す入力用のコントロールでは、FrameworkPropertyMetadataOptions.BindsTwoWayByDefault を併せて指定する。