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

2025年1月22日水曜日

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

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

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

1. Adornerを作る

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

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

C#:

  1. // 透かし文字を TextBox の上に表示する Adorner
  2. public class PlaceholderAdorner : Adorner
  3. {
  4. public PlaceholderAdorner(TextBox textBox) : this(textBox, string.Empty) {}
  5. public PlaceholderAdorner(TextBox textBox, string text) : base(textBox)
  6. {
  7. if ((textBox.Template.FindName("PART_ContentHost", textBox) is ScrollViewer contentHost)
  8. && (contentHost.Content is UIElement textBoxView))
  9. {
  10. Offset = new(textBoxView.TranslatePoint(new(), textBox).X, 0.0);
  11. }
  12. else
  13. {
  14. Offset = new(textBox.BorderThickness.Left + textBox.Padding.Left, 0.0);
  15. }
  16.  
  17. TextBlock textBlock = new();
  18. textBlock.Foreground = SystemColors.GrayTextBrush;
  19. textBlock.Text = placeholder;
  20. visualChildren.Add(textBlock);
  21. }
  22.  
  23. private List<Visual> visualChildren = [];
  24. protected override int VisualChildrenCount
  25. {
  26. get { return visualChildren.Count; }
  27. }
  28.  
  29. // 透かし文字のプロパティ
  30. public string Placeholder
  31. {
  32. get { return ((TextBlock)visualChildren[0]).Text; }
  33. set { ((TextBlock)visualChildren[0]).Text = value; }
  34. }
  35.  
  36. // 以下、文字の色やフォントを調整するプロパティ (カスタマイズ用)
  37. public Brush Foreground
  38. {
  39. get { return ((TextBlock)visualChildren[0]).Foreground; }
  40. set { ((TextBlock)visualChildren[0]).Foreground = value; }
  41. }
  42.  
  43. public FontFamily FontFamily
  44. {
  45. get { return ((TextBlock)visualChildren[0]).FontFamily; }
  46. set { ((TextBlock)visualChildren[0]).FontFamily = value; }
  47. }
  48.  
  49. public double FontSize
  50. {
  51. get { return ((TextBlock)visualChildren[0]).FontSize; }
  52. set { ((TextBlock)visualChildren[0]).FontSize = value; }
  53. }
  54.  
  55. public FontStretch FontStretch
  56. {
  57. get { return ((TextBlock)visualChildren[0]).FontStretch; }
  58. set { ((TextBlock)visualChildren[0]).FontStretch = value; }
  59. }
  60.  
  61. public FontStyle FontStyle
  62. {
  63. get { return ((TextBlock)visualChildren[0]).FontStyle; }
  64. set { ((TextBlock)visualChildren[0]).FontStyle = value; }
  65. }
  66.  
  67. public FontWeight FontWeight
  68. {
  69. get { return ((TextBlock)visualChildren[0]).FontWeight; }
  70. set { ((TextBlock)visualChildren[0]).FontWeight = value; }
  71. }
  72.  
  73. public Vector Offset { get; set; }
  74. protected override Visual GetVisualChild(int index)
  75. {
  76. return visualChildren[index];
  77. }
  78. protected override Size ArrangeOverride(Size finalSize)
  79. {
  80. TextBlock textBlock = (TextBlock)visualChildren[0];
  81. double y = (textBlock.DesiredSize.Height < finalSize.Height)
  82. ? (finalSize.Height - textBlock.DesiredSize.Height) / 2.0 : 0.0;
  83. textBlock.Arrange(new Rect(Offset.X, y, textBlock.DesiredSize.Width, textBlock.DesiredSize.Height));
  84. return finalSize;
  85. }
  86. }

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

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

C#:

  1. public static class PlaceholderBehavior
  2. {
  3. // 透かし文字を TextBox の上に表示する添付プロパティ
  4. public static readonly DependencyProperty PlaceholderProperty = DependencyProperty.RegisterAttached(
  5. "Placeholder", typeof(string), typeof(PlaceholderBehavior), new FrameworkPropertyMetadata(null, OnPlaceholderChanged));
  6.  
  7. public static string? GetPlaceholder(DependencyObject obj) { return (string)obj.GetValue(PlaceholderProperty); }
  8. public static void SetPlaceholder(DependencyObject obj, string? value) { obj.SetValue(PlaceholderProperty, value); }
  9.  
  10. // イベント購読に使う内部用の添付プロパティ
  11. private static readonly DependencyProperty IsAttachedProperty = DependencyProperty.RegisterAttached(
  12. "IsAttached", typeof(bool), typeof(PlaceholderBehavior), new FrameworkPropertyMetadata(false, OnIsAttachedChanged));
  13.  
  14. private static bool GetIsAttached(DependencyObject obj) { return (bool)obj.GetValue(IsAttachedProperty); }
  15. private static void SetIsAttached(DependencyObject obj, bool value) { obj.SetValue(IsAttachedProperty, value); }
  16.  
  17. // Adorner を保存する内部用の添付プロパティ
  18. private static readonly DependencyProperty AdornerProperty = DependencyProperty.RegisterAttached(
  19. "Adorner", typeof(PlaceholderAdorner), typeof(PlaceholderBehavior), new FrameworkPropertyMetadata(null));
  20.  
  21. private static PlaceholderAdorner? GetAdorner(DependencyObject obj) { return (PlaceholderAdorner)obj.GetValue(AdornerProperty); }
  22. private static void SetAdorner(DependencyObject obj, PlaceholderAdorner? value) { obj.SetValue(AdornerProperty, value); }
  23. private static void OnPlaceholderChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
  24. {
  25. if (obj is not TextBox textBox)
  26. {
  27. return;
  28. }
  29. if (e.NewValue is string { Length: > 0 })
  30. {
  31. UpdateAdorner(textBox);
  32. SetIsAttached(textBox, true); // イベントを購読する
  33. }
  34. else
  35. {
  36. SetIsAttached(textBox, false); // イベントを購読解除する
  37. }
  38. }
  39.  
  40. private static void OnIsAttachedChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
  41. {
  42. if (obj is not TextBox textBox)
  43. {
  44. return;
  45. }
  46.  
  47. if (e.NewValue is true)
  48. {
  49. WeakEventManager<TextBox, RoutedEventArgs>.AddHandler(textBox, nameof(textBox.Loaded), TextBox_Loaded);
  50. WeakEventManager<TextBox, RoutedEventArgs>.AddHandler(textBox, nameof(textBox.Unloaded), TextBox_Unloaded);
  51. WeakEventManager<TextBox, TextChangedEventArgs>.AddHandler(textBox, nameof(textBox.TextChanged), TextBox_TextChanged);
  52. }
  53. else
  54. {
  55. WeakEventManager<TextBox, RoutedEventArgs>.RemoveHandler(textBox, nameof(textBox.Loaded), TextBox_Loaded);
  56. WeakEventManager<TextBox, RoutedEventArgs>.RemoveHandler(textBox, nameof(textBox.Unloaded), TextBox_Unloaded);
  57. WeakEventManager<TextBox, TextChangedEventArgs>.RemoveHandler(textBox, nameof(textBox.TextChanged), TextBox_TextChanged);
  58. DeleteAdorner(textBox, true);
  59. }
  60. }
  61. // Adorner を削除する
  62. private static void DeleteAdorner(TextBox textBox, bool completely)
  63. {
  64. if ((GetAdorner(textBox) is PlaceholderAdorner adorner)
  65. && (adorner.Parent is AdornerLayer adornerLayer))
  66. {
  67. adornerLayer.Remove(adorner);
  68. }
  69.  
  70. if (completely)
  71. {
  72. SetAdorner(textBox, null);
  73. }
  74. }
  75. // Adorner の表示を更新する
  76. private static void UpdateAdorner(TextBox textBox)
  77. {
  78. if (!textBox.IsLoaded)
  79. {
  80. return;
  81. }
  82.  
  83. string placeholder = (string)GetPlaceholder(textBox)!; // null のときは到達しない
  84.  
  85. // Adorner が未作成なら作成する
  86. if (GetAdorner(textBox) is PlaceholderAdorner adorner)
  87. {
  88. adorner.Placeholder = placeholder;
  89. }
  90. else
  91. {
  92. adorner = new(textBox, placeholder) { FontSize = textBox.FontSize, IsClipEnabled = true };
  93. SetAdorner(textBox, adorner);
  94. }
  95.  
  96. // Adorner が AdornerLayer に追加されていないなら追加する
  97. if (!adorner.IsLoaded)
  98. {
  99. if (AdornerLayer.GetAdornerLayer(textBox) is not AdornerLayer adornerLayer)
  100. {
  101. return;
  102. }
  103.  
  104. adornerLayer.Add(adorner);
  105. }
  106.  
  107. // TextBox の入力状態に応じて Adorner を表示、または非表示にする
  108. adorner.Visibility = (textBox.Text.Length == 0) ? Visibility.Visible : Visibility.Hidden;
  109. }
  110.  
  111. private static void TextBox_Loaded(object? sender, RoutedEventArgs e)
  112. {
  113. if (sender is TextBox textBox)
  114. {
  115. UpdateAdorner(textBox);
  116. }
  117. }
  118.  
  119. private static void TextBox_Unloaded(object? sender, RoutedEventArgs e)
  120. {
  121. if (sender is TextBox textBox)
  122. {
  123. DeleteAdorner(textBox, false);
  124. }
  125. }
  126.  
  127. private static void TextBox_TextChanged(object? sender, TextChangedEventArgs e)
  128. {
  129. if (sender is TextBox textBox)
  130. {
  131. UpdateAdorner(textBox);
  132. }
  133. }
  134. }

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

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

XAML:

  1. <Window
  2. ...
  3. xmlns:local="clr-namespace:WpfApp1"
  4. ...>

XAML:

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

更新履歴

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