TextBoxに透かし文字(Placeholder)を表示する

2025年1月22日水曜日

C# WPF カスタムコントロール

記事のカテゴリー: C#、.NET9、WPF

Webサイトの検索欄や入力フォームなどで見かける透かし文字(Placeholder)をWPFのTextBoxで実現する方法を考えます。

1. Adornerを作る

最初にTextBoxの上に透かし文字を表示するためのAdornerを作ります。Adornerはコントロールの手前のレイヤーを使ってコントロールに関連付いた装飾を表示するための手法です(詳しくは装飾の概要を参照してください)。テキストの描画は難しいことをしないで、シンプルにTextBlockを配置します。

Adornerの表示位置はTextBoxの内部で実際にテキストの表示を担当するTextBoxViewの位置を取得して設定しています。

C#:

// 透かし文字を TextBox の上に表示する Adorner
public class PlaceholderAdorner : Adorner
{
    public PlaceholderAdorner(TextBox textBox) : this(textBox, string.Empty) {}
    
    public PlaceholderAdorner(TextBox textBox, string text) : base(textBox)
    {
        if ((textBox.Template.FindName("PART_ContentHost", textBox) is ScrollViewer contentHost)
            && (contentHost.Content is UIElement textBoxView))
        {
            Offset = new(textBoxView.TranslatePoint(new(), textBox).X, 0.0);
        }
        else
        {
            Offset = new(textBox.BorderThickness.Left + textBox.Padding.Left, 0.0);
        }

        TextBlock textBlock = new();
        textBlock.Foreground = SystemColors.GrayTextBrush;
        textBlock.Text = placeholder;
        visualChildren.Add(textBlock);
    }

    private List<Visual> visualChildren = [];
    
    protected override int VisualChildrenCount
    {
        get { return visualChildren.Count; }
    }

    // 透かし文字のプロパティ
    public string Placeholder
    {
        get { return ((TextBlock)visualChildren[0]).Text; }
        set { ((TextBlock)visualChildren[0]).Text = value; }
    }

    // 以下、文字の色やフォントを調整するプロパティ (カスタマイズ用)
    public Brush Foreground
    {
        get { return ((TextBlock)visualChildren[0]).Foreground; }
        set { ((TextBlock)visualChildren[0]).Foreground = value; }
    }

    public FontFamily FontFamily
    {
        get { return ((TextBlock)visualChildren[0]).FontFamily; }
        set { ((TextBlock)visualChildren[0]).FontFamily = value; }
    }

    public double FontSize
    {
        get { return ((TextBlock)visualChildren[0]).FontSize; }
        set { ((TextBlock)visualChildren[0]).FontSize = value; }
    }

    public FontStretch FontStretch
    {
        get { return ((TextBlock)visualChildren[0]).FontStretch; }
        set { ((TextBlock)visualChildren[0]).FontStretch = value; }
    }

    public FontStyle FontStyle
    {
        get { return ((TextBlock)visualChildren[0]).FontStyle; }
        set { ((TextBlock)visualChildren[0]).FontStyle = value; }
    }

    public FontWeight FontWeight
    {
        get { return ((TextBlock)visualChildren[0]).FontWeight; }
        set { ((TextBlock)visualChildren[0]).FontWeight = value; }
    }

    public Vector Offset { get; set; }
    
    protected override Visual GetVisualChild(int index)
    {
        return visualChildren[index];
    }
    
    protected override Size ArrangeOverride(Size finalSize)
    {
        TextBlock textBlock = (TextBlock)visualChildren[0];
        double y = (textBlock.DesiredSize.Height < finalSize.Height)
            ? (finalSize.Height - textBlock.DesiredSize.Height) / 2.0 : 0.0;
        textBlock.Arrange(new Rect(Offset.X, y, textBlock.DesiredSize.Width, textBlock.DesiredSize.Height));
        return finalSize;
    }
}

2. 添付ビヘイビアを作る

次に添付ビヘイビアを作ります。Adornerを作成してTextBoxの上に表示するPlaceholder添付プロパティを定義します。TextBoxTextChangedイベントを購読して、テキストが空のときにのみAdornerを表示します。

C#:

public static class PlaceholderBehavior
{
    // 透かし文字を TextBox の上に表示する添付プロパティ
    public static readonly DependencyProperty PlaceholderProperty = DependencyProperty.RegisterAttached(
        "Placeholder", typeof(string), typeof(PlaceholderBehavior), new FrameworkPropertyMetadata(null, OnPlaceholderChanged));

    public static string? GetPlaceholder(DependencyObject obj) { return (string)obj.GetValue(PlaceholderProperty); }
    public static void SetPlaceholder(DependencyObject obj, string? value) { obj.SetValue(PlaceholderProperty, value); }

    // イベント購読に使う内部用の添付プロパティ
    private static readonly DependencyProperty IsAttachedProperty = DependencyProperty.RegisterAttached(
        "IsAttached", typeof(bool), typeof(PlaceholderBehavior), new FrameworkPropertyMetadata(false, OnIsAttachedChanged));

    private static bool GetIsAttached(DependencyObject obj) { return (bool)obj.GetValue(IsAttachedProperty); }
    private static void SetIsAttached(DependencyObject obj, bool value) { obj.SetValue(IsAttachedProperty, value); }

    // Adorner を保存する内部用の添付プロパティ
    private static readonly DependencyProperty AdornerProperty = DependencyProperty.RegisterAttached(
        "Adorner", typeof(PlaceholderAdorner), typeof(PlaceholderBehavior), new FrameworkPropertyMetadata(null));

    private static PlaceholderAdorner? GetAdorner(DependencyObject obj) { return (PlaceholderAdorner)obj.GetValue(AdornerProperty); }
    private static void SetAdorner(DependencyObject obj, PlaceholderAdorner? value) { obj.SetValue(AdornerProperty, value); }
    
    private static void OnPlaceholderChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
    {
        if (obj is not TextBox textBox)
        {
            return;
        }
        
        if (e.NewValue is string { Length: > 0 })
        {
            UpdateAdorner(textBox);
            SetIsAttached(textBox, true);  // イベントを購読する
        }
        else
        {
            SetIsAttached(textBox, false);  // イベントを購読解除する
        }
    }

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

        if (e.NewValue is true)
        {
            WeakEventManager<TextBox, RoutedEventArgs>.AddHandler(textBox, nameof(textBox.Loaded), TextBox_Loaded);
            WeakEventManager<TextBox, RoutedEventArgs>.AddHandler(textBox, nameof(textBox.Unloaded), TextBox_Unloaded);
            WeakEventManager<TextBox, TextChangedEventArgs>.AddHandler(textBox, nameof(textBox.TextChanged), TextBox_TextChanged);
        }
        else
        {
            WeakEventManager<TextBox, RoutedEventArgs>.RemoveHandler(textBox, nameof(textBox.Loaded), TextBox_Loaded);
            WeakEventManager<TextBox, RoutedEventArgs>.RemoveHandler(textBox, nameof(textBox.Unloaded), TextBox_Unloaded);
            WeakEventManager<TextBox, TextChangedEventArgs>.RemoveHandler(textBox, nameof(textBox.TextChanged), TextBox_TextChanged);
            DeleteAdorner(textBox, true);
        }
    }
    
    // Adorner を削除する
    private static void DeleteAdorner(TextBox textBox, bool completely)
    {
        if ((GetAdorner(textBox) is PlaceholderAdorner adorner)
            && (adorner.Parent is AdornerLayer adornerLayer))
        {
            adornerLayer.Remove(adorner);
        }

        if (completely)
        {
            SetAdorner(textBox, null);
        }
    }
    
    // Adorner の表示を更新する
    private static void UpdateAdorner(TextBox textBox)
    {
        if (!textBox.IsLoaded)
        {
            return;
        }

        string placeholder = (string)GetPlaceholder(textBox)!;  // null のときは到達しない

        // Adorner が未作成なら作成する
        if (GetAdorner(textBox) is PlaceholderAdorner adorner)
        {
            adorner.Placeholder = placeholder;
        }
        else
        {
            adorner = new(textBox, placeholder) { FontSize = textBox.FontSize, IsClipEnabled = true };
            SetAdorner(textBox, adorner);
        }

        // Adorner が AdornerLayer に追加されていないなら追加する
        if (!adorner.IsLoaded)
        {
            if (AdornerLayer.GetAdornerLayer(textBox) is not AdornerLayer adornerLayer)
            {
                return;
            }

            adornerLayer.Add(adorner);
        }

        // TextBox の入力状態に応じて Adorner を表示、または非表示にする
        adorner.Visibility = (textBox.Text.Length == 0) ? Visibility.Visible : Visibility.Hidden;
    }

    private static void TextBox_Loaded(object? sender, RoutedEventArgs e)
    {
        if (sender is TextBox textBox)
        {
            UpdateAdorner(textBox);
        }
    }

    private static void TextBox_Unloaded(object? sender, RoutedEventArgs e)
    {
        if (sender is TextBox textBox)
        {
            DeleteAdorner(textBox, false);
        }
    }

    private static void TextBox_TextChanged(object? sender, TextChangedEventArgs e)
    {
        if (sender is TextBox textBox)
        {
            UpdateAdorner(textBox);
        }
    }
}

3. TextBoxに添付プロパティを設定する

TextBoxPlaceholder添付プロパティを設定します。

XAML:

<Window
    ...
    xmlns:local="clr-namespace:WpfApp1"
    ...>
    

XAML:

<TextBox local:PlaceholderBehavior.Placeholder="設定を検索" />

更新履歴

  • : 初稿
  • : イベント購読を多重登録してしまう問題に対応しました。PlaceholderBehaviorクラスにIsAttached添付プロパティを追加して再構成しています。
  • : TextBoxを破棄してもAdornerが表示されたままになってしまう問題に対応しました。PlaceholderBehaviorクラスでTextBoxUnloadedイベントの購読を追加しています。