1
votes

My application has tons of TextBox controls where the user can enter numeric values. Most of these values are in some physical unit. This unit indicator is displayed at the right side of the TextBox control.

That looks like the following sketch: [________] km (where the unit is "km")

Currently I have done this with StackPanel instances everywhere. It's always the same pattern. That makes the XAML less readable than it should be.

I'm looking for a TextBox control that already includes that TextBlock at its side to display the unit.

My first try was a class derived from TextBox, with a XAML file that replaces the Template property like this:

<TextBox
    x:Class="WpfApplication1.UnitTextBox"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    x:Name="_this"
    KeyboardNavigation.IsTabStop="False"
    Style="{StaticResource {x:Type TextBox}}">
    <TextBox.Template>
        <ControlTemplate>
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="Auto"/>
                </Grid.ColumnDefinitions>
                <TextBox
                    Foreground="{TemplateBinding Foreground}"
                    IsEnabled="{TemplateBinding IsEnabled}"
                    IsReadOnly="{Binding IsReadOnly, ElementName=_this}"
                    Style="{TemplateBinding Style}"
                    Text="{Binding Text, ElementName=_this}"
                    Width="{TemplateBinding Width}"
                    ... lots more ...
                    VerticalAlignment="Center"/>
                <TextBlock
                    Grid.Column="1"
                    Text="{Binding Unit, ElementName=_this}"
                    Margin="4,0,0,0"
                    VerticalAlignment="Center"/>
            </Grid>
        </ControlTemplate>
    </TextBox.Template>
</TextBox>

Unit is a dependency property in my UnitTextBox code-behind class:

public partial class UnitTextBox : TextBox
{
    public static DependencyProperty UnitProperty = DependencyProperty.Register(
        name: "Unit",
        propertyType: typeof(string),
        ownerType: typeof(UnitTextBox));

    public string Unit
    {
        get { return (string) GetValue(UnitProperty); }
        set { SetValue(UnitProperty, value); }
    }

    public UnitTextBox()
    {
        InitializeComponent();
    }
}

Unfortunately, there's a number of issues with this approach. I need to pass through virtually all properties to the inner TextBox as you can see (I abbreviated it here). Also, I'd like the Width property to apply to the inner TextBox as usual, not to the outer Grid. Think I need a separate property for that and bind the inner TextBox instance to that. And currently, the style that I set when using the UnitTextBox class is ignored. I don't even know how to solve that.

Is there any possibility to create such a combined control with WPF? It should act like a TextBox with all its event handlers, bindable properties etc., but already include that unit string in its appearance, assignable by an additional property.

Could I instead use a custom Style that adds the TextBlock somewhere around (but I think I need the outer Grid for aligning things), and declare the unit with an attached property?

1
this approach is too complex. Just create a MyUnitTextBox class derived from UserControl, and place a TextBox besides a TextBlock in a 2-column Grid.deafjeff
Your scenarion by the way gives the best reason to create a UserControl to encapsulate StackPanel, TextBlock and TextBox.deafjeff
And how can I access all the other TextBox events and properties through the UserControl? Or apply TextBox-targeted styles to it?ygoe
For that you usually add Dependency properties to your UserControl, to communicate with its owning elements. A textBox style would be part of the UserControl, since the TextBox belongs to the UserControl.deafjeff

1 Answers

1
votes

The annoying thing about extending a templated control is that you typically need to define a new template for each system theme, or your customized TextBox will look out of place next to a regular TextBox. However, since your "enhancement" is fairly simple, we can avoid this entirely by simply overriding the layout and rendering code to include the unit text:

public class UnitTextBox : TextBox
{
    private FormattedText _unitText;
    private Rect _unitTextBounds;

    public static DependencyProperty UnitTextProperty =
        DependencyProperty.Register(
            name: "UnitText",
            propertyType: typeof(string),
            ownerType: typeof(UnitTextBox),
            typeMetadata: new FrameworkPropertyMetadata(
                default(string),
                FrameworkPropertyMetadataOptions.AffectsMeasure |
                FrameworkPropertyMetadataOptions.AffectsArrange |
                FrameworkPropertyMetadataOptions.AffectsRender));

    public string UnitText
    {
        get { return (string)GetValue(UnitTextProperty); }
        set { SetValue(UnitTextProperty, value); }
    }

    public static DependencyProperty UnitPaddingProperty =
        DependencyProperty.Register(
            name: "UnitPadding",
            propertyType: typeof(Thickness),
            ownerType: typeof(UnitTextBox),
            typeMetadata: new FrameworkPropertyMetadata(
                new Thickness(5d, 0d, 0d, 0d),
                FrameworkPropertyMetadataOptions.AffectsMeasure |
                FrameworkPropertyMetadataOptions.AffectsArrange |
                FrameworkPropertyMetadataOptions.AffectsRender));

    public Thickness UnitPadding
    {
        get { return (Thickness)GetValue(UnitPaddingProperty); }
        set { SetValue(UnitPaddingProperty, value); }
    }

    public static DependencyProperty TextBoxWidthProperty =
        DependencyProperty.Register(
            name: "TextBoxWidth",
            propertyType: typeof(double),
            ownerType: typeof(UnitTextBox),
            typeMetadata: new FrameworkPropertyMetadata(
                double.NaN,
                FrameworkPropertyMetadataOptions.AffectsMeasure));

    public double TextBoxWidth
    {
        get { return (double)GetValue(TextBoxWidthProperty); }
        set { SetValue(TextBoxWidthProperty, value); }
    }

    protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e)
    {
        base.OnPropertyChanged(e);

        if (e.Property == ForegroundProperty)
            EnsureUnitText(invalidate: true);
    }

    protected override Size MeasureOverride(Size constraint)
    {
        var textBoxWidth = this.TextBoxWidth;
        var unit = EnsureUnitText(invalidate: true);
        var padding = this.UnitPadding;

        if (unit != null)
        {
            var unitWidth = unit.Width + padding.Left + padding.Right;
            var unitHeight = unit.Height + padding.Top + padding.Bottom;

            constraint = new Size(
                constraint.Width - unitWidth,
                Math.Max(constraint.Height, unitHeight));
        }

        var hasFixedTextBoxWidth = !double.IsNaN(textBoxWidth) &&
                                   !double.IsInfinity(textBoxWidth);

        if (hasFixedTextBoxWidth)
            constraint = new Size(textBoxWidth, constraint.Height);

        var baseSize = base.MeasureOverride(constraint);
        var baseWidth = hasFixedTextBoxWidth ? textBoxWidth : baseSize.Width;

        if (unit != null)
        {
            var unitWidth = unit.Width + padding.Left + padding.Right;
            var unitHeight = unit.Height + padding.Top + padding.Bottom;

            return new Size(
                baseWidth + unitWidth,
                Math.Max(baseSize.Height, unitHeight));
        }

        return new Size(baseWidth, baseSize.Height);
    }

    protected override Size ArrangeOverride(Size arrangeBounds)
    {
        var textSize = arrangeBounds;
        var unit = EnsureUnitText(invalidate: false);
        var padding = this.UnitPadding;

        if (unit != null)
        {
            var unitWidth = unit.Width + padding.Left + padding.Right;
            var unitHeight = unit.Height + padding.Top + padding.Bottom;

            textSize.Width -= unitWidth;

            _unitTextBounds = new Rect(
                textSize.Width + padding.Left,
                (arrangeBounds.Height - unitHeight) / 2 + padding.Top,
                textSize.Width,
                textSize.Height);
        }

        var baseSize = base.ArrangeOverride(textSize);

        if (unit != null)
        {
            var unitWidth = unit.Width + padding.Left + padding.Right;
            var unitHeight = unit.Height + padding.Top + padding.Bottom;

            return new Size(
                baseSize.Width + unitWidth,
                Math.Max(baseSize.Height, unitHeight));
        }

        return baseSize;
    }

    protected override void OnRender(DrawingContext drawingContext)
    {
        base.OnRender(drawingContext);

        var unitText = EnsureUnitText(invalidate: false);
        if (unitText != null)
            drawingContext.DrawText(unitText, _unitTextBounds.Location);
    }

    private FormattedText EnsureUnitText(bool invalidate = false)
    {
        if (invalidate)
            _unitText = null;

        if (_unitText != null)
            return _unitText;

        var unit = this.UnitText;

        if (!string.IsNullOrEmpty(unit))
        {
            _unitText = new FormattedText(
                unit,
                CultureInfo.InvariantCulture,
                this.FlowDirection,
                new Typeface(
                    this.FontFamily,
                    this.FontStyle,
                    this.FontWeight,
                    this.FontStretch),
                this.FontSize,
                this.Foreground);
        }

        return _unitText;
    }
}

The TextBoxWidth property lets you set a fixed width for just the TextBox. The behavior of Width remains unchanged, as it should: it governs the size of the entire control, e.g., the TextBox and the unit text.

No custom style or template is necessary.