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

2025年1月22日水曜日

C#

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

ホームページの入力欄によくある「検索する」などの透かし文字(Placeholder)をWPFのTextBoxで実現する方法を考えます。

1. Adornerを作る

最初に、TextBox上に透かし文字を表示するためのAdornerを作ります。テキストの描画はTextBlockに任せています。透かし文字のフォントを調整するためのプロパティをたくさん定義していますが、例ではほとんど使用していません。

C#:

// 指定したテキストをコントロール上に表示する Adorner
public class PlaceholderAdorner : Adorner
{
    public PlaceholderAdorner(UIElement adornedElement) : base(adornedElement)
    {
        TextBlock textBlock = new();
        textBlock.Foreground = Brushes.Gray;
        visualChildren.Add(textBlock);
    }

    public PlaceholderAdorner(UIElement adornedElement, string text) : this(adornedElement)
    {
        Text = text;
    }
    
    private List<Visual> visualChildren = [];
    
    protected override int VisualChildrenCount
    {
        get { return visualChildren.Count; }
    }

    public string Text
    {
        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; } = new(3, 0);
    
    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) 
            ? 0 
            : (finalSize.Height - textBlock.DesiredSize.Height) / 2;
        textBlock.Arrange(new Rect(Offset.X, y, textBlock.DesiredSize.Width, textBlock.DesiredSize.Height));
        return finalSize;
    }
}

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

次に、添付ビヘイビアを作ります。指定したテキストをAdornerに設定して、TextBox上に重なり合わせて表示するPlaceholder添付プロパティを定義します。TextBoxのイベントを監視してテキストが空のときにAdornerを表示する動作にします。

C#:

public static class TextBoxExtensions
{
    private static Dictionary<TextBox, PlaceholderAdorner> attachingTargets = [];

    // 透かし文字を表示する添付プロパティ
    public static readonly DependencyProperty PlaceholderProperty = DependencyProperty.RegisterAttached(
        "Placeholder",
        typeof(string),
        typeof(TextBoxExtensions),
        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 void OnPlaceholderChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        if (d is TextBox textBox)
        {
            // (1) 添付プロパティを設定した場合
            if ((e.NewValue is string placeholder) && (placeholder.Length > 0))
            {
                // (1-1) 既に Adorner を作成している場合
                if (attachingTargets.TryGetValue(textBox, out PlaceholderAdorner? adorner))
                {
                    adorner.Text = placeholder;
                }

                // (1-2) Adorner を作成していない場合
                else
                {
                    attachingTargets[textBox] = new(textBox, placeholder);
                    textBox.Loaded += TextBox_Loaded;  // ロード前は Adorner を設定できない
                    textBox.Unloaded += TextBox_Unloaded;  // TextBox を削除した後の後始末
                    textBox.TextChanged += TextBox_TextChanged;

                    if (textBox.IsLoaded)
                    {
                        UpdateAdorner(textBox);
                    }
                }
            }

            // (2) 添付プロパティを設定解除した場合
            else
            {
                if (attachingTargets.ContainsKey(textBox))
                {
                    textBox.Loaded -= TextBox_Loaded;
                    textBox.Unloaded -= TextBox_Unloaded;
                    textBox.TextChanged -= TextBox_TextChanged;
                    RemoveAdorner(textBox);
                    attachingTargets.Remove(textBox);
                }
            }
        }
    }

    // Adorner の表示を更新する
    private static void UpdateAdorner(TextBox textBox)
    {
        if (textBox.Text.Length == 0)
        {
            ShowAdorner(textBox);
        }
        else
        {
            HideAdorner(textBox);
        }
    }

    // Adorner を表示する
    private static void ShowAdorner(TextBox textBox)
    {
        if (attachingTargets[textBox].IsLoaded)
        {
            attachingTargets[textBox].Visibility = Visibility.Visible;
        }
        else
        {
            if (AdornerLayer.GetAdornerLayer(textBox) is AdornerLayer adornerLayer)
            {
                adornerLayer.Add(attachingTargets[textBox]);
                attachingTargets[textBox].IsClipEnabled = true;
            }
        }
    }

    // Adorner を隠す
    private static void HideAdorner(TextBox textBox)
    {
        attachingTargets[textBox].Visibility = Visibility.Hidden;
    }

    // Adorner を削除する
    private static void RemoveAdorner(TextBox textBox)
    {
        if (AdornerLayer.GetAdornerLayer(textBox) is AdornerLayer adornerLayer)
        {
            adornerLayer.Remove(attachingTargets[textBox]);
        }
    }

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

        UpdateAdorner(textBox);
    }

    private static void TextBox_Unloaded(object sender, RoutedEventArgs e)
    {
        if (sender is not TextBox textBox)
        {
            return;
        }

        textBox.Loaded -= TextBox_Loaded;
        textBox.Unloaded -= TextBox_Unloaded;
        textBox.TextChanged -= TextBox_TextChanged;
        attachingTargets.Remove(textBox);
    }

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

        UpdateAdorner(textBox);
    }
}

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

TextBoxにPlaceholder添付プロパティを設定します。

XAML:

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

XAML:

<TextBox local:TextBoxExtensions.Placeholder="検索する" />