WPF で TextBox の UpdateSource を View から呼び出すときの落とし穴と実装

TextBox の GetBindingExpression().UpdateSource() を View から呼ぶ際に起きる null・一括更新・方向の取り違え・MVVM 設計の落とし穴を整理し、BindingGroup と添付ビヘイビアによる一括確定の実装を示す。

概要

WPF で入力の確定を「送信」ボタンなどのタイミングまで遅らせるには、UpdateSourceTrigger=Explicit にしたうえで、View 側から BindingExpression.UpdateSource() を呼んでソースへ書き戻す。
この呼び出し自体は 1 行だが、実務では GetBindingExpression が null を返して NullReferenceException になったり、複数の入力欄を一括で書き戻せずに一部だけ古い値が残ったりと、呼ぶ側に固有の落とし穴がある。
本記事では、UpdateSource() を View から呼ぶ実装を軸に、null を返す条件・複数バインディングの一括更新・UpdateTarget() との方向の違い・MVVM を崩さない設計を、公式ドキュメントの挙動に基づいて整理する。
更新の「タイミング」を決める UpdateSourceTrigger 三値そのものの使い分けは扱わない。


前提・対象環境

UpdateSource() は、ターゲット(TextBox.Text)の現在値をソース(ViewModel)へ書き戻すメソッドである。
UpdateSourceTrigger=Explicit のバインディングでは、このメソッドを呼ばない限りソースは一切更新されない。


問題

Explicit にしたバインディングを View から書き戻そうとして、次のコードが期待どおり動かない場面がある。

// 送信ボタンのクリック時に呼ぶが、例外や無反応になることがある
BindingExpression be = amountBox.GetBindingExpression(TextBox.TextProperty);
be.UpdateSource();

典型的には 3 通りの失敗が起きる。
GetBindingExpression が null を返して 2 行目が NullReferenceException になる、UpdateSource() を呼んでもソースが更新されない、あるいはフォーム内の複数 TextBox のうち明示的に呼んだものだけが反映され残りが古いままになる、というものである。


原因・背景

第一の原因は、GetBindingExpression が「その依存プロパティに単一の Binding のアクティブなバインディングがある場合のみ」BindingExpression を返し、無ければ null を返す点にある。
公式リファレンスは、戻り値の null チェックがバインディングの有無を判定する手段であると明記している。
Text="固定文字列" のようにリテラルを代入している場合、Text が MultiBinding の場合(この場合は GetMultiBindingExpression を使う)、あるいは Text に TemplateBinding({TemplateBinding ...})を使っている場合(これは BindingExpression ではない)に null になる。
また、対象 TextBox が別コントロールのテンプレート内にあってその要素自体を外部から参照できない場合は、そもそも GetBindingExpression を呼び出す起点が得られない。

第二に、UpdateSource() はバインディングの Mode が TwoWay または OneWayToSource でないと何もしない。
OneWay や OneTime のバインディングに対して呼んでも、例外は出ないまま黙って無視される。
また、バインディングがターゲットからデタッチ済みの状態で呼ぶと InvalidOperationException を送出する。

第三に、UpdateSource() は呼び出したその 1 つの BindingExpression だけを書き戻す。
フォーム全体を確定したい場合は、対象の各 TextBox について個別に呼ぶか、後述する一括更新の仕組みを使う必要がある。

実測: 構成ごとの挙動

上記の条件を実際に動かして確かめた結果が次の表である。

Text の設定のしかた GetBindingExpression の状態 そのまま UpdateSource() Text を変えた後 / 入力した後
Text="literal" null 呼べない 呼べない
MultiBinding null 呼べない 呼べない
TemplateBinding null 呼べない 呼べない
Binding Mode=OneTime 取得できる 変化なし InvalidOperationException
Binding Mode=OneWay 取得できる 変化なし InvalidOperationException
Binding Mode=OneWayToSource 取得できる 変化なし ソースが更新される
Binding Mode=TwoWay 取得できる 変化なし ソースが更新される
OneWay、TextInput で入力 バインドされたまま 呼んでいない 変わらない
OneTime、TextInput で入力 バインドされたまま 呼んでいない 変わらない
TwoWay、TextInput で入力 バインドされたまま 呼んでいない 変わらない
TwoWay、続けて ClearBinding 取得できる - InvalidOperationException

.NET 10 / Windows 11 で、Text の設定方法を変えて GetBindingExpression と UpdateSource() を呼んだ結果。「GetBindingExpression の状態」の列は、行によって読み取った時点が違う。上の 7 行は Text を書き換える前、続く 3 行(「TextInput で入力」)は入力した後、最後の行(「TwoWay、続けて ClearBinding」)は ClearBinding で外す前に取得した状態である。「そのまま UpdateSource()」はバインドを張った直後にそのまま呼んだ場合、「Text を変えた後 / 入力した後」は、代入してから UpdateSource() を呼んだ場合の結果と、入力後のソースの値を兼ねる列である。「TextInput で入力」の 3 行は、代入ではなく TextInput イベント経由で 1 文字入力した場合である。「変化なし」はソースの値が変わらなかったことを示す。

この表で注意を要するのは、OneWay と OneTime の 2 列が食い違う点である。

バインドを張った直後にそのまま UpdateSource() を呼ぶと、前述のとおり例外は出ず何も起きない。
ところが TextBox.Text に値を代入してから呼ぶと InvalidOperationException になる。

理由は、OneWay / OneTime のターゲットにローカル値を代入すると、その時点でバインドがローカル値に置き換えられて外れるためである。
つまり「モードが対象外だから黙って無視される」状態と「デタッチ済みだから例外になる」状態は別々に存在するのではなく、実際のコードでは後者に行き着く。
ただし、ユーザーがキーボードで入力した場合は外れない。 TextBox のエディタは TextInput イベント経由で値を書き込み、この経路ではバインドが維持される。
実測すると、OneWay でキー入力した場合はバインドが残り、表示だけが変わってソースは更新されない。バインドを外すのは box.Text = ... のようなコードからの代入だけである。
「入力するとバインドが失われる」ではなく、「入力してもソースは更新されない」が正しい。

OneWayToSource と TwoWay では、Text を書き換えてから呼ぶとソースが更新される。
バインドが外れないのは、この 2 つがターゲットからソースへ値を流す経路を持つためである。

UpdateSource がターゲットからソースへ、UpdateTarget がソースからターゲットへ値を移すことを示す図。下段には GetBindingExpression が null を返す 3 つの条件が並んでいる。
更新の向きと、対象になるバインディングモードの対応。UpdateSource() はターゲットからソースへ書き戻すため OneWay や OneTime では何も起きない。逆向きの UpdateTarget() は OneTime でも効き、ソースの値を読み直した(後掲の表)。下段は GetBindingExpression が null を返す代表的な 3 条件で、いずれも例外ではなく null として現れる。

解決方法

まず取得した BindingExpression を必ず null チェックし、null の場合はバインドされていない・MultiBinding である・TemplateBinding で結ばれているといった原因を切り分ける。
単一要素の書き戻しは、null 条件演算子 ?. で安全に呼ぶ。
複数要素をまとめて確定するフォームでは、ビジュアルツリーを走査して各 TextBox に呼ぶ方法と、BindingGroup で一括更新する方法がある。
View の分離を保ちたい場合は、コードビハインドに直接書かず、添付プロパティ(ビヘイビア)として再利用可能にする。


実装例

以下のコード例は要点を示す断片である。
C# 例は System.Windows・System.Windows.Controls・System.Windows.Controls.Primitives・System.Windows.Data・System.Windows.Media・System.Windows.Input の各名前空間を using で参照している前提とする。
XAML 例は既定の WPF 名前空間(xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" と xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml")を省略しており、local: は添付ビヘイビアを定義した CLR 名前空間(例: xmlns:local="clr-namespace:YourApp.Behaviors")を指す。

単一の TextBox を書き戻す基本形は次のとおりである。
GetBindingExpression の戻り値を ?. で受け、null(未バインド等)のときは何もしないようにする。

// amountBox は UpdateSourceTrigger=Explicit でバインドした TextBox
BindingExpression be = amountBox.GetBindingExpression(TextBox.TextProperty);
be?.UpdateSource();

?. により、バインディングが存在しないケースでも NullReferenceException を避けられる。
null を「異常」として扱いたい場合は、明示的に分岐してログ出力や早期 return を行う。

フォーム内の複数 TextBox をまとめて書き戻すには、ビジュアルツリーを再帰的に走査し、各 TextBox.Text のバインディングに UpdateSource() を呼ぶ。

// 指定要素配下のすべての TextBox.Text バインディングを書き戻す
static void UpdateAllTextSources(DependencyObject root)
{
    int count = VisualTreeHelper.GetChildrenCount(root);
    for (int i = 0; i < count; i++)
    {
        DependencyObject child = VisualTreeHelper.GetChild(root, i);
        if (child is TextBox textBox)
        {
            textBox.GetBindingExpression(TextBox.TextProperty)?.UpdateSource();
        }
        UpdateAllTextSources(child);
    }
}

この走査はビジュアルツリー生成後(Loaded 以降)に実行する。
仮想化されたリスト内の未生成要素は走査対象に含まれない点に注意する。

検証を伴って複数バインディングを一括で書き戻すなら、BindingGroup を使う。
親要素に BindingGroup を設定すると、配下のバインディングはグループに参加し、既定では UpdateSources() を呼ぶまでソースを更新しない。

<StackPanel x:Name="formPanel">
    <StackPanel.BindingGroup>
        <BindingGroup />
    </StackPanel.BindingGroup>
    <TextBox Text="{Binding Street}" />
    <TextBox Text="{Binding City}" />
</StackPanel>

各バインディングがグループに参加する条件は 2 通りある。
BindingGroupName を指定しない暗黙参加では、StackPanel の DataContext がそれらのバインディングのソースと同一オブジェクトである必要がある。
一方、BindingGroupName を明示的に指定したバインディングは、DataContext が異なっていても同名の BindingGroup へ参加できる。
コードビハインドからは UpdateSources() を 1 回呼ぶだけでよい。
このメソッドは各バインディングの ValidationRule(検証ステップが RawProposedValue・ConvertedProposedValue・UpdatedValue のもの)を実行し、すべて成功した場合に true を返す。ドキュメントによれば、グループ自体に設定したルール(BindingGroup.ValidationRules)も実行される。本記事の計測では、個々のバインディングに設定したルールを使った。

// すべての参加バインディングを検証し、成功時のみまとめて書き戻す
bool committed = formPanel.BindingGroup.UpdateSources();

UpdateSources() は検証が 1 つでも失敗すると false を返す。
ただし、書き戻しの前に走る段階(RawProposedValue など)のルールが失敗した場合はソースへ書き込まれないが、UpdatedValue の段階のルールは書き戻しの後に走るため、失敗して false が返っても値はソースへ書き込まれた後である(下の表)。

条件 結果
TwoWay、TextBox をツリーから外してから UpdateSource() 例外なし、Status PathError、ソース = before
OneTime: ソースを変えてから UpdateTarget() Text before -> before(呼ぶ前) -> changed(UpdateTarget の後)
BindingGroup.UpdateSources()、規則なし 戻り値 True、ソース = after
BindingGroup.UpdateSources()、RawProposedValue の規則が失敗 戻り値 False、ソース = before
BindingGroup.UpdateSources()、UpdatedValue の規則が失敗 戻り値 False、ソース = after

.NET 10 / Windows 11 で実測。BindingGroup の行は、Text に after を代入してから UpdateSources() を呼んだ結果である。

ただし IEditableObject の編集トランザクションは終了しないため、確定まで行うには CommitEdit() を使う。

ソースからターゲットへ表示を戻したい場合は、UpdateSource() ではなく UpdateTarget() を呼ぶ。
両者は方向が逆であり、取り違えると「保存したのに入力欄が変わらない」「入力を破棄したいのにソースへ書き込む」といった誤動作になる。

// ソース→ターゲット方向を強制する(UpdateSource の逆。入力の破棄・再表示に使う)
amountBox.GetBindingExpression(TextBox.TextProperty)?.UpdateTarget();

MVVM でコードビハインドを避けたい場合は、UpdateSource() の呼び出しを添付プロパティに閉じ込める。
以下は「Enter 押下で書き戻す」挙動を添付ビヘイビアとして再利用可能にした例である。

public static class TextBoxBehavior
{
    public static readonly DependencyProperty UpdateSourceOnEnterProperty =
        DependencyProperty.RegisterAttached(
            "UpdateSourceOnEnter",
            typeof(bool),
            typeof(TextBoxBehavior),
            new PropertyMetadata(false, OnChanged));

    public static bool GetUpdateSourceOnEnter(DependencyObject obj) =>
        (bool)obj.GetValue(UpdateSourceOnEnterProperty);

    public static void SetUpdateSourceOnEnter(DependencyObject obj, bool value) =>
        obj.SetValue(UpdateSourceOnEnterProperty, value);

    static void OnChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is not TextBox textBox)
        {
            return;
        }

        textBox.KeyDown -= OnKeyDown;
        if ((bool)e.NewValue)
        {
            textBox.KeyDown += OnKeyDown;
        }
    }

    static void OnKeyDown(object sender, KeyEventArgs e)
    {
        if (e.Key == Key.Enter && sender is TextBox textBox)
        {
            textBox.GetBindingExpression(TextBox.TextProperty)?.UpdateSource();
            e.Handled = true; // 既定ボタン等、別の Enter 処理の重複起動を抑止する
        }
    }
}

XAML 側では添付プロパティを付けるだけで、コードビハインドに View 固有の処理を書かずに済む。

<TextBox Text="{Binding Amount, UpdateSourceTrigger=Explicit}"
         local:TextBoxBehavior.UpdateSourceOnEnter="True" />

イベント購読を付け外しする際は、上記のように名前付きハンドラで -= してから += し、二重購読を避ける。
ラムダ式で購読すると解除できず、購読が積み重なる。

MVVM を保ったまま、1 つの送信ボタンで複数の TextBox をまとめて確定するには、BindingGroup と添付ビヘイビアを組み合わせる。
BindingGroup は継承される依存プロパティであり、親要素に設定するとボタンを含む配下の要素が同一グループを共有する。
そこで、ボタンのクリック時に「継承したグループの UpdateSources() で全入力を書き戻し、成功したら ViewModel のコマンドを実行する」添付ビヘイビアを用意する。

public static class SubmitBehavior
{
    // 書き戻しに成功したら実行する ViewModel のコマンド
    public static readonly DependencyProperty SubmitCommandProperty =
        DependencyProperty.RegisterAttached(
            "SubmitCommand",
            typeof(ICommand),
            typeof(SubmitBehavior),
            new PropertyMetadata(null, OnChanged));

    public static ICommand GetSubmitCommand(DependencyObject obj) =>
        (ICommand)obj.GetValue(SubmitCommandProperty);

    public static void SetSubmitCommand(DependencyObject obj, ICommand value) =>
        obj.SetValue(SubmitCommandProperty, value);

    static void OnChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is not ButtonBase button)
        {
            return;
        }

        button.Click -= OnClick;
        if (e.NewValue is not null)
        {
            button.Click += OnClick;
        }
    }

    static void OnClick(object sender, RoutedEventArgs e)
    {
        var button = (ButtonBase)sender;

        // 1. 継承した BindingGroup 配下の全入力をまとめて書き戻す
        BindingGroup group = button.BindingGroup;
        if (group is null || !group.UpdateSources())
        {
            return; // グループ未設定または検証失敗。コマンドは実行しない
        }

        // 2. 確定済みの値で ViewModel のコマンドを実行する
        ICommand command = GetSubmitCommand(button);
        if (command?.CanExecute(null) == true)
        {
            command.Execute(null);
        }
    }
}

XAML では親に BindingGroup を置き、各 TextBox を通常どおりバインドし、ボタンに SubmitCommand を付ける。
BindingGroup に参加したバインディングは既定で UpdateSources() まで書き戻されないため、各 TextBox に UpdateSourceTrigger=Explicit を個別指定する必要はない。

<StackPanel x:Name="formPanel">
    <StackPanel.BindingGroup>
        <BindingGroup />
    </StackPanel.BindingGroup>
    <TextBox Text="{Binding Street}" />
    <TextBox Text="{Binding City}" />
    <Button Content="保存"
            local:SubmitBehavior.SubmitCommand="{Binding SaveCommand}" />
</StackPanel>

このビヘイビアはコミットとコマンド実行の順序を自ら制御するため、Click と Command の発火順に依存しない。
ボタンには標準の Command を別途バインドせず、SubmitCommand に寄せて二重実行を避ける。
BindingGroup が未設定(継承されていない)、または UpdateSources() が検証失敗で false を返した場合はコマンドを実行しないため、未確定・不正な入力のまま保存処理へ進むことがない。ただし UpdatedValue の段階のルールで失敗した値は、ソースには書き込まれている。
保存処理そのものは SaveCommand(ViewModel)に残り、View 側にはコミットの配線だけが乗る。
なお、BindingGroupName を指定しない暗黙参加では、前掲のとおり親の DataContext が各バインディングのソースに設定されている必要がある。


注意点


代替案・比較

View から書き戻す手段は、対象範囲と設計方針に応じて選ぶ。

方法 対象範囲 メリット デメリット 適するケース
GetBindingExpression().UpdateSource() 単一要素 明快で制御しやすい 要素ごとに呼ぶ必要がある 特定の 1 入力欄だけを確定する
VisualTreeHelper 走査で一括 配下の全 TextBox 一度で多数を書き戻せる 未生成要素は対象外・走査コスト 生成済みの入力群をまとめて確定
BindingGroup.UpdateSources() グループ参加バインディング 検証と一体で一括確定できる 検証設計が前提・戻り値の考慮が必要 複数項目をまとめて検証・保存するフォーム
添付ビヘイビア(Enter で単一確定) 付与した要素 View の分離を保てる・再利用可能 実装量が増える MVVM でコードビハインドを避けたい
BindingGroup + 送信ビヘイビア グループ参加の全 TextBox MVVM を保ちボタン 1 つで一括確定+検証+コマンド実行 実装量・BindingGroup 前提 MVVM で送信ボタンにより複数入力を確定

単一の確定には UpdateSource() を直接呼ぶのが最も明快である。
フォーム全体を検証付きで確定するなら BindingGroup が適し、MVVM の分離を保つなら添付ビヘイビアに寄せる。


まとめ

View から TextBox のバインディングを書き戻す実装は、GetBindingExpression(TextBox.TextProperty)?.UpdateSource() が基本形である。
GetBindingExpression はバインディングが無ければ null を返すため、?. で保護しつつ、本来バインド済みの要素で null が返る場合は MultiBinding・TemplateBinding・名前解決を疑う。
UpdateSource() は TwoWay / OneWayToSource でのみ機能し、ClearBinding などでバインディングが外れた後は例外になる点も踏まえる。
確定範囲が単一なら直接呼び出し、複数を検証付きで確定するなら BindingGroup.UpdateSources()、View の分離を優先するなら添付ビヘイビアを選ぶ。
MVVM を保ったまま送信ボタンで複数入力を一括確定するには、BindingGroup を継承したボタンにコミット用の添付ビヘイビアを組み合わせ、コミット成功後に ViewModel のコマンドを実行する。
方向を戻したい場合は UpdateSource() ではなく UpdateTarget() を使い、取り違えによる誤動作を避ける。

なお、更新をいつ発火させるか(LostFocus / PropertyChanged / Explicit の使い分け)は別の観点であり、WPF TextBox の UpdateSourceTrigger で入力がソースへ反映されるタイミングを制御するで扱う。