Friday, December 28, 2007

TextBox with Placeholder Text

After seeking high and low for a WPF TextBox that will display placeholder text when the text is empty and has no focus, I came across allsorts46's implementation for WinForms. I cannot find a WPF version, so I seek to implement one that intercepts TextBox.OnRender event to draw the placeholder text. However, when drawing text in the OnRender event, the text always appears behind the textbox, making it invisible. Apparently, the textbox contains a ScrollViewer which paints over the TextBox. My approach is to set the TextBox's background to null, and paint a rectangle (with the background colour) and the placeholder text in the OnRender event. Setting the background to null makes it transparent and reveals the placeholder text, thus accomplishing what I wanted. The designer handles my inherited TextBox like a normal TextBox, not showing the placeholder text. But there is a problem — background becomes transparent in the designer when no background is set explicitly. Help to correct this is appreciated. And here's the code
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Media;
using WpfTextBox = System.Windows.Controls.TextBox;
 
namespace Huan.WhiteDwarf.UI
{
    public class TextBox : WpfTextBox
    {
        /// <summary>
        ///   Keeps track of whether placeholder text is visible to know when to call InvalidateVisual to show or hide it.
        /// </summary>
        private bool _isPlaceholderVisible;
 
        /// <summary>
        ///   Identifies the PlaceholderText dependency property.
        /// </summary>
        public static readonly DependencyProperty PlaceholderTextProperty = DependencyProperty.Register(
            "PlaceholderText",
            typeof(string),
            typeof(TextBox),
            new FrameworkPropertyMetadata(
                string.Empty,
                FrameworkPropertyMetadataOptions.AffectsRender));
 
        /// <summary>
        ///   Gets or sets the placeholder text to be shown when text box has no text and is not in focus. This is a dependency property.
        /// </summary>
        public string PlaceholderText
        {
            get { return (string)GetValue(PlaceholderTextProperty); }
            set { SetValue(PlaceholderTextProperty, value); }
        }
 
        // Shadowed BackgroundProperty to disassociate base.Backgrouond and this.Background.
        /// <summary>
        ///   Identifies the Background dependency property.
        /// </summary>
        public new static readonly DependencyProperty BackgroundProperty = DependencyProperty.Register(
            "Background",
            typeof(Brush),
            typeof(TextBox),
            new FrameworkPropertyMetadata(
                null,
                FrameworkPropertyMetadataOptions.AffectsRender));
 
        // Shadowed Background property to keep base.Background null.
        /// <summary>
        ///   Gets or sets a brush that describes the background of a control. This is a  dependency property.
        /// </summary>
        public new Brush Background
        {
            get { return GetValue(BackgroundProperty) as Brush; }
            set { SetValue(BackgroundProperty, value); }
        }
 
        // Sets the base.Background to null to make it transparent. New background is painted in OnRender.
        /// <summary>
        ///   Raises the Initialized event. This method is invoked whenever IsInitialized is set to true internally.
        /// </summary>
        /// <param name="e">
        ///   The EventArgs that contains the event data.
        /// </param>
        protected override void OnInitialized(EventArgs e)
        {
            base.OnInitialized(e);
            if (Background == null)
                Background = base.Background;
            base.Background = null;
        }
 
        // Listen to changes in IsFocusedProperty and TextProperty and invalidates visual when placeholder text needs to be shown or hidden.
        /// <summary>
        ///   Called when one or more of the dependency properties that exist on the element have had their effective values changed.
        ///   (Overrides FrameworkElement.OnPropertyChanged(DependencyPropertyChangedEventArgs).)
        /// </summary>
        /// <param name="e">
        ///   The DependencyPropertyChangedEventArgs that contains the event data.
        /// </param>
        protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e)
        {
            if ((e.Property == IsFocusedProperty || e.Property == TextProperty) && !string.IsNullOrEmpty(PlaceholderText))
                if (!IsFocused && string.IsNullOrEmpty(Text))
                {
                    // Need to show placeholder
                    if (!_isPlaceholderVisible)
                        InvalidateVisual();
                }
                else if (_isPlaceholderVisible)
                    // Need to hide placeholder
                    InvalidateVisual();
            base.OnPropertyChanged(e);
        }
 
        // Draws background and placeholder text of the TextBox.
        /// <summary>
        ///   When overridden in a derived class, participates in rendering operations that are directed by the layout system.
        ///   The rendering instructions for this element are not used directly when this method is invoked, and are instead
        ///   preserved for later asynchronous use by layout and drawing.
        /// </summary>
        /// <param name="drawingContext">
        ///   The drawing instructions for a specific element. This context is provided to the layout system.
        /// </param>
        protected override void OnRender(DrawingContext drawingContext)
        {
            base.OnRender(drawingContext);
 
            _isPlaceholderVisible = false;
            drawingContext.DrawRectangle(Background, null, new Rect(RenderSize));
 
            if (!IsFocused && string.IsNullOrEmpty(Text) && !string.IsNullOrEmpty(PlaceholderText))
            {
                // Draw placeholder
 
                _isPlaceholderVisible = true;
                TextAlignment computedTextAlignment = ComputedTextAlignment();
                // foreground brush does not need to be dynamic. OnRender called when SystemColors changes.
                Brush foreground = SystemColors.GrayTextBrush.Clone();
                foreground.Opacity = Foreground.Opacity;
                Typeface typeface = new Typeface(FontFamily, /*FontStyles.Italic*/ this.FontStyle, FontWeight, FontStretch);
                FormattedText formattedText = new FormattedText(PlaceholderText,
                                                  CultureInfo.CurrentCulture,
                                                  FlowDirection,
                                                  typeface,
                                                  FontSize,
                                                  foreground);
                formattedText.TextAlignment = computedTextAlignment;
                formattedText.MaxTextHeight = RenderSize.Height - BorderThickness.Top - BorderThickness.Bottom - Padding.Top - Padding.Bottom;
                formattedText.MaxTextWidth = RenderSize.Width - BorderThickness.Left - BorderThickness.Right - Padding.Left - Padding.Right - 4.0;
 
                double left;
                double top = 0.0;
                if (FlowDirection == FlowDirection.RightToLeft)
                    left = BorderThickness.Right + Padding.Right + 2.0;
                else
                    left = BorderThickness.Left + Padding.Left + 2.0;
                switch (VerticalContentAlignment)
                {
                    case VerticalAlignment.Top:
                    case VerticalAlignment.Stretch:
                        top = BorderThickness.Top + Padding.Top;
                        break;
                    case VerticalAlignment.Bottom:
                        top = RenderSize.Height - BorderThickness.Bottom - Padding.Bottom - formattedText.Height;
                        break;
                    case VerticalAlignment.Center:
                        top = (RenderSize.Height + BorderThickness.Top - BorderThickness.Bottom + Padding.Top - Padding.Bottom - formattedText.Height) / 2.0;
                        break;
                }
                if (FlowDirection == FlowDirection.RightToLeft)
                {
                    // Somehow everything got drawn reflected. Add a transform to correct.
                    drawingContext.PushTransform(new ScaleTransform(-1.0, 1.0, RenderSize.Width / 2.0, 0.0));
                    drawingContext.DrawText(formattedText, new Point(left, top));
                    drawingContext.Pop();
                }
                else
                    drawingContext.DrawText(formattedText, new Point(left, top));
            }
        }
 
        /// <summary>
        ///   Computes changes in text alignment caused by HorizontalContentAlignment. TextAlignment has priority over HorizontalContentAlignment.
        /// </summary>
        /// <returns>
        ///   Returns the effective text alignment.
        /// </returns>
        private TextAlignment ComputedTextAlignment()
        {
            if (DependencyPropertyHelper.GetValueSource(this, TextBox.HorizontalContentAlignmentProperty).BaseValueSource == BaseValueSource.Local
                && DependencyPropertyHelper.GetValueSource(this, TextBox.TextAlignmentProperty).BaseValueSource != BaseValueSource.Local)
            {
                // HorizontalContentAlignment dominates
                switch (HorizontalContentAlignment)
                {
                    case HorizontalAlignment.Left:
                        return TextAlignment.Left;
                    case HorizontalAlignment.Right:
                        return TextAlignment.Right;
                    case HorizontalAlignment.Center:
                        return TextAlignment.Center;
                    case HorizontalAlignment.Stretch:
                        return TextAlignment.Justify;
                }
            }
            return TextAlignment;
        }
    }
}

3 comments:

Lena said...

Try to set background explicitly to white:
drawingContext.DrawRectangle(Brushes.White, null, new Rect(RenderSize));
It should do the trick.

Anonymous said...

I know that you posted this a long time ago, but I believe that I have a much simpler answer to your problem. The addition of a default text property is much simpler to implement as a derived class of TextBox with an additional property to keep track of the default text. The code below shows a very simple implementation that avoids as much overhead as possible (Sorry that the code looks like a mess!):

public class DefaultTextBox : TextBox
{
public String DefaultText { get; private set; }
public FontWeight DefaultFontWeight { get; private set; }
public FontStyle DefaultFontStyle { get; private set; }

public DefaultTextBox(String defaultText, FontWeight defaultFontWeight, FontStyle defaultFontStyle)
{
DefaultText = defaultText;
DefaultFontWeight = defaultFontWeight;
DefaultFontStyle = defaultFontStyle;

GotKeyboardFocus += SetTextOnGotFocus;
LostKeyboardFocus += SetTextOnLostFocus;

SetDefaultText();
}

private void SetTextOnGotFocus(Object sender, RoutedEventArgs args)
{
DefaultTextBox dtb = (DefaultTextBox)sender;
if (IsDefaultText())
InitializeForTextEntry();
}

private void SetTextOnLostFocus(Object sender, RoutedEventArgs args)
{
DefaultTextBox dtb = (DefaultTextBox)sender;
//Focus was obtained but no text was entered so set text back to default
if (dtb.Text == String.Empty)
SetDefaultText();
}

private void SetDefaultText()
{
Text = DefaultText;
FontWeight = DefaultFontWeight;
FontStyle = DefaultFontStyle;
}

private void InitializeForTextEntry()
{
Text = "";
FontWeight = FontWeights.Normal;
FontStyle = FontStyles.Normal;
}

private Boolean IsDefaultText()
{
if (Text == DefaultText && FontWeight == DefaultFontWeight && FontStyle == DefaultFontStyle)
return true;
else
return false;
}
}

This uses the fact that the default text and the entered text will have different formats. If this is not true then you will need to add a field to keep track of whether the user has entered text or not just in case he enters the same text as the default text (unless the default text isn't a valid value in which case you wouldn't have to worry about it). You could update this field using an event such as KeyDown. Of course, KeyDown occurs rather frequently so it would add some overhead.

I am not sure of your overall goals, but the implementation that you provided seems fairly complicated. The major problem that I see with your solution outside of its complexity is that you use OnPropertyChanged. This method is called any time any dependency property is changed which can be fairly often. You really don't want to override this unless there is a lot of shared logic or some other very compelling reason.

HerbF said...

I ran into this post while searching for this for WinForms. For anyone else looking for that, here is a simple Winform solution:

[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = false)]
private static extern IntPtr SendMessage(IntPtr hWnd, uint msg, uint wParam, [MarshalAs(UnmanagedType.LPWStr)] string lParam);

public static void SetHint(TextBox textBox, string hintText)
{
const uint EmSetCueBanner = 0x1501;
SendMessage(textBox.Handle, EmSetCueBanner, 0, hintText);
}