WPF Fluent テーマの TextBox でクリアボタンを非表示にする方法

Fluent テーマの TextBox がフォーカス時に表示するクリアボタンを、入力挙動を変えずに非表示化する。名前付きパーツを操作する方法と AcceptsReturn を使う方法を .NET 10・9 対応で整理する。

概要

Fluent テーマを適用した WPF の TextBox は、編集可能で単一行の入力欄にテキストがある状態でフォーカスが入ると、テキスト右端にクリアボタン(×)を表示する。
この既定挙動は入力補助として有効だが、独自のクリア操作を別に備えた検索欄やフィルター欄では不要であり、同じ役割のボタンが二重に並んで見える。
本記事では、テンプレートの入力挙動を実質的に変えずにこのクリアボタンだけを非表示にする方法を、.NET 10 を主対象として整理する。
アプローチは 2 つある。
1 つはテンプレート内のボタン要素(名前付きパーツ)を直接非表示にする方法、もう 1 つは AcceptsReturn プロパティの非表示トリガーを利用する方法である。
.NET 9 ではパーツ名が異なるため、その差分と対応コードも併記する。


前提・対象環境


問題

Fluent テーマの TextBox は、キーボードフォーカスが入るとテンプレート内のクリアボタンを自動表示する。
標準テーマ(Aero2)には存在しない要素のため、テーマを切り替えた後に意図せず現れる。
既に「×」ボタンやコマンドで入力値をクリアする UI を用意している画面では、同一機能のボタンが重複し、レイアウトの一貫性が損なわれる。

Fluent テーマを適用した WPF アプリの画面。テキストを入力した TextBox にフォーカスが当たり、右端にクリアボタンの × が表示されている。
.NET 10 の Fluent テーマで TextBox にフォーカスを入れた状態。テキスト右端に既定でクリアボタン(×)が現れる。

原因・背景

このクリアボタンは、Fluent テーマの TextBox コントロールテンプレートに名前付きパーツとして定義されている。
パーツ名はバージョンで異なり、.NET 10 では DeleteButton.NET 9 では ClearButton である(.NET 9 から .NET 10 への更新でボタン要素が改名された)。
.NET 10 のテンプレートでは、このボタンの既定の VisibilityCollapsed で、IsKeyboardFocusWithintrue のときだけ表示するトリガーを持つ。
このためフォーカスが外れているときは、既定値のまま非表示となる。
加えて、Text が空・IsReadOnly のとき、および AcceptsReturn=TrueTextWrappingWrap または WrapWithOverflow のときに非表示とするトリガーが定義されている。
一方 .NET 9 のテンプレート(パーツ名 ClearButton)には AcceptsReturnTextWrapping のトリガーは無く、フォーカスが外れているときは IsKeyboardFocusWithinfalse のトリガーで非表示にする。
このため、クリアボタンだけを消す公開プロパティは用意されておらず、パーツを直接操作するか、上記トリガーの非表示条件を満たすかのいずれかを取る。


解決方法

前述のとおり、アプローチは 2 系統ある。

いずれもパーツ取得には Template.FindName を用いる。
一連の操作を添付プロパティにまとめることで、XAML から属性 1 つを付けるだけで宣言的に適用できる。


実装例

方法 1: 名前付きパーツを非表示にする(.NET 10.NET 9 両対応)

添付プロパティ HideClearButton を定義し、True が設定されたらクリアボタンのパーツを Collapsed にする。
パーツ名はバージョンで異なるため、DeleteButton(.NET 10)と ClearButton(.NET 9)の両方を順に探索してフォールバックする。
プロパティ変更時点ではテンプレート未適用の場合があるため、未読み込みなら Loaded を待ってから処理する。
Loaded の購読は WeakEventManager による弱参照とし、ハンドラーが TextBox の生存を延ばさないようにする。

using System.Windows;
using System.Windows.Controls;

public static partial class TextBoxHelper
{
    // .NET 10 は "DeleteButton"、.NET 9 は "ClearButton"。実行環境に応じてフォールバックする。
    private static readonly string[] ClearButtonPartNames = ["DeleteButton", "ClearButton"];

    public static bool GetHideClearButton(DependencyObject obj) =>
        (bool)obj.GetValue(HideClearButtonProperty);

    public static void SetHideClearButton(DependencyObject obj, bool value) =>
        obj.SetValue(HideClearButtonProperty, value);

    public static readonly DependencyProperty HideClearButtonProperty =
        DependencyProperty.RegisterAttached(
            "HideClearButton",
            typeof(bool),
            typeof(TextBoxHelper),
            new FrameworkPropertyMetadata(false, OnHideClearButtonChanged));

    private static void OnHideClearButtonChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is not TextBox textBox || !(bool)e.NewValue)
        {
            return;
        }

        if (textBox.IsLoaded)
        {
            HideClearButtonPart(textBox);
        }
        else
        {
            // 多重登録防止のため一度解除してから登録する。
            WeakEventManager<FrameworkElement, RoutedEventArgs>.RemoveHandler(textBox, nameof(FrameworkElement.Loaded), OnLoaded);
            WeakEventManager<FrameworkElement, RoutedEventArgs>.AddHandler(textBox, nameof(FrameworkElement.Loaded), OnLoaded);
        }
    }

    private static void OnLoaded(object sender, RoutedEventArgs e)
    {
        if (sender is TextBox textBox)
        {
            WeakEventManager<FrameworkElement, RoutedEventArgs>.RemoveHandler(textBox, nameof(FrameworkElement.Loaded), OnLoaded);
            HideClearButtonPart(textBox);
        }
    }

    private static void HideClearButtonPart(TextBox textBox)
    {
        textBox.ApplyTemplate();

        foreach (string partName in ClearButtonPartNames)
        {
            if (textBox.Template?.FindName(partName, textBox) is UIElement clearButton)
            {
                clearButton.Visibility = Visibility.Collapsed;
            }
        }
    }
}

ApplyTemplate でテンプレートの適用を強制してからパーツを取得している。
Visibility をローカル値で設定しているため、フォーカス変化でトリガーが再評価されても非表示が保たれる。
AcceptsReturn を変更しないので、Enter キーやペーストの挙動には一切影響しない。

XAML 側では、対象の TextBox に添付プロパティを付与するだけでよい。

<TextBox xmlns:helper="clr-namespace:MyApp.Helpers"
         helper:TextBoxHelper.HideClearButton="True"
         Text="{Binding Keyword, UpdateSourceTrigger=PropertyChanged}" />

xmlns:helper は添付プロパティ TextBoxHelper を定義したクラスの名前空間(clr-namespace)を指す。
実際に定義した名前空間に合わせて置き換える。

適用後は、フォーカスが入りテキストが存在する状態でもクリアボタンが現れない。

方法 1 を適用した後の同じ画面。テキストを入力した TextBox にフォーカスが当たっているが、右端のクリアボタンは表示されていない。
方法 1 を適用した後の同じ画面。入力値もフォーカス状態も前掲の画像と同じだが、クリアボタンだけが消えている。テキストの折り返しやキャレット位置など、入力に関わる挙動は変わらない。

方法 2: AcceptsReturn の非表示トリガーを利用する(.NET 10 以降)

パーツ名に依存しない方法として、AcceptsReturn=True を設定してテンプレートの非表示トリガーを成立させる。
このままでは単一行の TextBox が複数行入力に変わるため、Enter による改行入力を抑止し、貼り付け時の改行を除去して単一行の挙動を保つ。
以下は、これらをまとめて有効化する添付プロパティ SingleLineHideClear の実装である。

using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;

public static partial class TextBoxHelper
{
    public static bool GetSingleLineHideClear(DependencyObject obj) =>
        (bool)obj.GetValue(SingleLineHideClearProperty);

    public static void SetSingleLineHideClear(DependencyObject obj, bool value) =>
        obj.SetValue(SingleLineHideClearProperty, value);

    public static readonly DependencyProperty SingleLineHideClearProperty =
        DependencyProperty.RegisterAttached(
            "SingleLineHideClear",
            typeof(bool),
            typeof(TextBoxHelper),
            new FrameworkPropertyMetadata(false, OnSingleLineHideClearChanged));

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

        if ((bool)e.NewValue)
        {
            // AcceptsReturn を SetCurrentValue で設定し、.NET 10 の非表示トリガーを成立させる。
            textBox.SetCurrentValue(TextBox.AcceptsReturnProperty, true);
            WeakEventManager<TextBox, KeyEventArgs>.AddHandler(textBox, nameof(UIElement.PreviewKeyDown), OnPreviewKeyDown);
            DataObject.AddPastingHandler(textBox, OnPasting);
        }
        else
        {
            WeakEventManager<TextBox, KeyEventArgs>.RemoveHandler(textBox, nameof(UIElement.PreviewKeyDown), OnPreviewKeyDown);
            DataObject.RemovePastingHandler(textBox, OnPasting);
        }
    }

    private static void OnPreviewKeyDown(object sender, KeyEventArgs e)
    {
        // Enter による改行入力を抑止し、単一行の見た目を保つ。
        if (e.Key is Key.Enter)
        {
            e.Handled = true;
        }
    }

    private static void OnPasting(object sender, DataObjectPastingEventArgs e)
    {
        if (!e.SourceDataObject.GetDataPresent(DataFormats.UnicodeText))
        {
            return;
        }

        string text = (string)e.SourceDataObject.GetData(DataFormats.UnicodeText);
        if (text.Contains('\n') || text.Contains('\r'))
        {
            // 貼り付け文字列の改行を空白へ置換してから貼り付ける。
            string singleLine = text.Replace("\r\n", " ").Replace('\r', ' ').Replace('\n', ' ');
            DataObject data = new();
            data.SetData(DataFormats.UnicodeText, singleLine);
            e.DataObject = data;
        }
    }
}

AcceptsReturn=True の非表示トリガーはフォーカスの表示トリガーより後に宣言されており、両方が同時に成立した場合は後に宣言されたトリガーが優先されるため、フォーカス中でもクリアボタンは表示されない。
Enter の抑止と貼り付け改行の除去により、見た目と入力は単一行のまま保たれる。
AcceptsReturnSetCurrentValue で設定しているため、AcceptsReturn にバインディングやスタイルが指定されていても、それらをローカル値で上書きしない。
PreviewKeyDown は方法 1 と同じく WeakEventManager で購読し、ハンドラーが TextBox の生存を延ばさないようにしている。
DataObjectPasting は添付イベントで名前付きの CLR イベントを持たないため、汎用の WeakEventManager では購読できないが、OnPasting は静的メソッドで TextBox を保持しない。
無効化時は PreviewKeyDownPasting の両ハンドラーを確実に解除する。
このトリガーは .NET 10 で追加されたものであり、.NET 9 では非表示にならない点に注意する。

XAML 側では、対象の TextBoxSingleLineHideClear を付与する。

<TextBox xmlns:helper="clr-namespace:MyApp.Helpers"
         helper:TextBoxHelper.SingleLineHideClear="True"
         Text="{Binding Keyword, UpdateSourceTrigger=PropertyChanged}" />

方法 1 と同じく、xmlns:helperTextBoxHelper を定義したクラスの名前空間を指す。


注意点


代替案・比較

方法 メリット デメリット 適するケース
名前付きパーツを Collapse(方法 1) 表示を直接制御でき確実、.NET 9.NET 10 両対応が容易、入力挙動へ副作用がない 内部パーツ名に依存する(バージョンで改名の実績あり) 単一行のまま確実に非表示にしたい通常のケース
AcceptsReturn トリガー利用(方法 2) 公開プロパティ依存でパーツ名の変更に強い 複数行化の副作用の打ち消しが必要、.NET 9 では効かない パーツ名依存を避けたい .NET 10 以降のケース
コントロールテンプレート全面差し替え 構造を完全に制御できる 記述量が多く保守コストが高い テーマを大きくカスタムする場合

まとめ

Fluent テーマの TextBox のクリアボタンを消すには、対象パーツ(.NET 10DeleteButton.NET 9ClearButton)にローカル値で Visibility=Collapsed を設定する方法 1 が、入力挙動を保ったまま確実で、両バージョンにも対応しやすい。
パーツ名の変更に左右されにくくしたい場合は、AcceptsReturn の非表示トリガーを使う方法 2 を選ぶが、これは .NET 10 以降に限られ、複数行化の副作用を打ち消す必要がある。
単一行の検索欄・フィルター欄など大半のケースでは方法 1 を既定とし、パーツ名依存を避けたい .NET 10 以降の設計でのみ方法 2 を検討し、テーマ全体を再設計するならテンプレート差し替えを選ぶのが妥当である。


関連記事