WinUI NumberBox Control

By Fons Sonnemans, posted on
11376 Views 2 Comments

If you write business apps, there are a lot of controls for data input: TextBox, CheckBox, ComboBox, DatePicker, ToggleSwitch, TimePicker, RadioButton, Slider, etc. I always wondered why there was no NumberBox. Numeric input is very common so it needs its own control for it. Luckily there is the Windows UI Library (WinUI), an open source project from Microsoft. I proposed the NumberBox last year and the team did a great job implementing it. It is released in version 2.3 and most of the issues are now solved. Time for a blog post showing of its features.

This animated GIF demonstrates a few of the features like AcceptsExpression, PlaceholderText, SpinButtons and NumberFormatter. I 'stole' it from this tweet.

NumberBox in action

Demo Project

The next screenshot is taken from my NumberBoxTest app/project. The project can be found in this GitHub repository.

MainPage with 5 NumberBox controls

On the MainPage there are five NumberBox controls. They are databound to the properties of a Product and an Employee object which are created in the codebehind of the MainPage.

<Page x:Class="NumberBoxTest.MainPage"
      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
      xmlns:behaviors="using:NumberBoxTest.Behaviors"
      xmlns:converters="using:NumberBoxTest.Converters"
      xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
      xmlns:interactivity="using:Microsoft.Xaml.Interactivity"
      xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
      xmlns:winui="using:Microsoft.UI.Xaml.Controls"
      Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"
      mc:Ignorable="d">

    <Page.Resources>
        <converters:DecimalToDoubleConverter x:Key="Decimal2Double" />
        <converters:IntToDoubleConverter x:Key="Int2Double" />
        <converters:NullableDoubleToStringConverter x:Key="NullableDouble2String" />
    </Page.Resources>

    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="1*" />
            <ColumnDefinition Width="1*" />
        </Grid.ColumnDefinitions>
        <StackPanel Width="300"
                    Margin="8"
                    x:DefaultBindMode="TwoWay"
                    Spacing="8">

            <TextBlock Style="{ThemeResource TitleTextBlockStyle}"
                       Text="Product" />

            <TextBox Header="Product Name"
                     MaxLength="10"
                     Text="{x:Bind Product.ProductName}"
                     TextWrapping="Wrap" />

            <winui:NumberBox AcceptsExpression="True"
                             Header="Unit Price"
                             Minimum="0"
                             NumberFormatter="{x:Bind Path=DutchDecimalFormatter, Mode=OneTime}"
                             PlaceholderText="You can also use expressions like '5 + 7'"
                             ValueChanged="NumberBox_ValueChanged"
                             Value="{x:Bind Product.UnitPrice}" />

            <winui:NumberBox Header="Units in Stock"
                             Maximum="10000"
                             Minimum="0"
                             NumberFormatter="{x:Bind Path=IntFormatter, Mode=OneTime}"
                             SpinButtonPlacementMode="Inline"
                             ValueChanged="NumberBox_ValueChanged"
                             Value="{x:Bind Product.UnitsInStock, Converter={StaticResource Int2Double}}" />

            <StackPanel Margin="0,8"
                        Padding="8"
                        x:DefaultBindMode="OneWay"
                        BorderBrush="{ThemeResource SystemErrorTextColor}"
                        BorderThickness="2"
                        Spacing="8">
                <TextBlock Text="{x:Bind Product.ProductName}" />
                <TextBlock Text="{x:Bind Product.UnitPrice}" />
                <TextBlock Text="{x:Bind Product.UnitsInStock}" />
            </StackPanel>

        </StackPanel>

        <StackPanel Grid.Column="1"
                    Width="300"
                    Margin="8"
                    x:DefaultBindMode="TwoWay"
                    Spacing="8">

            <TextBlock Style="{ThemeResource TitleTextBlockStyle}"
                       Text="Employee" />

            <TextBox Header="Name"
                     MaxLength="10"
                     Text="{x:Bind Employee.Name}"
                     TextWrapping="Wrap" />

            <winui:NumberBox AcceptsExpression="True"
                             Header="Salary"
                             IsWrapEnabled="true"
                             Minimum="100"
                             NumberFormatter="{x:Bind Path=DutchDecimalFormatter, Mode=OneTime}"
                             SpinButtonPlacementMode="Hidden"
                             Value="{x:Bind Employee.Salary, Converter={StaticResource Decimal2Double}}">
                <interactivity:Interaction.Behaviors>
                    <behaviors:NanToMinimumBehavior />
                </interactivity:Interaction.Behaviors>
            </winui:NumberBox>

            <winui:NumberBox Header="Bonus"
                             Minimum="0"
                             NumberFormatter="{x:Bind Path=DutchDecimalFormatter, Mode=OneTime}"
                             SmallChange="100"
                             SpinButtonPlacementMode="Compact"
                             Text="{x:Bind Employee.Bonus, ConverterLanguage=nl-NL, Converter={StaticResource NullableDouble2String}}" />

            <winui:NumberBox Header="Age"
                             Maximum="100"
                             Minimum="16"
                             SpinButtonPlacementMode="Inline"
                             Value="{x:Bind Employee.Age, Converter={StaticResource Int2Double}}">
                <interactivity:Interaction.Behaviors>
                    <behaviors:NanToMinimumBehavior />
                    <behaviors:IntNumberFormatterBehavior />
                </interactivity:Interaction.Behaviors>
            </winui:NumberBox>

            <StackPanel Margin="0,8"
                        Padding="8"
                        x:DefaultBindMode="OneWay"
                        BorderBrush="{ThemeResource SystemErrorTextColor}"
                        BorderThickness="2"
                        Spacing="8">
                <TextBlock Text="{x:Bind Employee.Name}" />
                <TextBlock Text="{x:Bind Employee.Salary}" />
                <TextBlock Text="{x:Bind Employee.Bonus}" />
                <TextBlock Text="{x:Bind Employee.Age}" />
            </StackPanel>

        </StackPanel>
    </Grid>
</Page>

In the codebehind of the MainPage (MainPage.xaml.cs) the Product and Employee objects are created. It also contains two DecimalFormatter properties and a NumberBox_ValueChanged eventhandler.

using Microsoft.UI.Xaml.Controls;
using NumberBoxTest.Models;
using Windows.Globalization.NumberFormatting;
using Windows.UI.Xaml.Controls;

namespace NumberBoxTest {
    public sealed partial class MainPage : Page {

        public MainPage() {
            this.InitializeComponent();
        }

        private DecimalFormatter DutchDecimalFormatter { get; } =
            new DecimalFormatter(new[] { "nl-NL" }, "NL") {
                IsGrouped = true,
                FractionDigits = 2,
                NumberRounder = new IncrementNumberRounder {
                    Increment = 0.01,
                    RoundingAlgorithm = RoundingAlgorithm.RoundHalfUp,
                }
            };

        private DecimalFormatter IntFormatter { get; } =
            new DecimalFormatter(new[] { "nl-NL" }, "NL") {
                IsGrouped = false,
                FractionDigits = 0,
                NumberRounder = new IncrementNumberRounder(),
            };

        internal Employee Employee { get; } = new Employee {
            Name = "Fons",
            Salary = 2000,
            Bonus = 100,
            Age = 50
        };

        internal Product Product { get; } = new Product {
            ProductName = "Surface Pro",
            UnitPrice = 1299,
            UnitsInStock = 50
        };

        private void NumberBox_ValueChanged(NumberBox sender, 
                                            NumberBoxValueChangedEventArgs args) {
            if (double.IsNaN(args.NewValue)) {
                sender.Value = sender.Minimum;
            }
        }

    }
}

NumberFormatter

The DutchDecimalFormatter and IntFormatter properties in the MainPage.xaml.cs are used to format the 'money' NumberBoxes (UnitPrice, Salary and Bonus) and the 'int' NumberBoxes (UnitsInStock and Age). The NumberFormatter property of each NumberBox is set to these properties using x:Bind or a Behavior. The DutchDecimalFormatter will format the 'money' using the 'nl' region. This region uses a comma for fractions and a dot for grouping. It has FractionDigits wich is set to 2 and the NumberRounder is set to 0.01. So if the users enters the value 1,006 it is converted to 1,01. A value of 1,004 is converted to 1,00. The IntFormatter has a FractionDigits of 0 because it is an int. I think you should always set the NumberFormatter property of every NumberBox. I like behaviors so I created this IntNumberFormatterBehavior to set the NumberFormmater. You have to reference the Microsoft.Xaml.Behaviors.Uwp.Managed NuGet package to make it work. You can easily assign it to a NumberBox in Blend for VisualStudio. Just Drag & Drop it from the Assets panel onto a NumberBox.

class IntNumberFormatterBehavior : Behavior<NumberBox> {

    private static DecimalFormatter IntFormatter { get; } =
        new DecimalFormatter(new[] { "nl-NL" }, "NL") {
            IsGrouped = false,
            FractionDigits = 0,
            NumberRounder = new IncrementNumberRounder(),
        };

    protected override void OnAttached() {
        base.OnAttached();
        this.AssociatedObject.NumberFormatter = IntFormatter;
    }
}

 

SpinButtons

The NumberBox supports spin buttons to increase or decrease the value. They can be placed 'Compact' or 'Inline'. You can also use the Up/Down cursor keys for a small change and the PageUp/PageDown for a large change.

<winui:NumberBox Header="Bonus"
                 Minimum="0"
                 NumberFormatter="{x:Bind Path=DutchDecimalFormatter, Mode=OneTime}"
                 SmallChange="10"
                 LargeChange="100"
                 SpinButtonPlacementMode="Compact"
                 Text="{x:Bind Employee.Bonus, ConverterLanguage=nl-NL, Converter={StaticResource NullableDouble2String}}" />

 

AcceptsExpression & PlaceholderText

AcceptsExpression

If the AcceptsExpression property is set to True you can use the NumberBox as a calculator. You can type an expression for example '5 + 7'. The value (12) is calculated when you hit Enter or leave the NumberBox. The PlaceholderText can be used to explain this feature to the user.

<winui:NumberBox AcceptsExpression="True"
                 Header="Unit Price"
                 Minimum="0"
                 NumberFormatter="{x:Bind Path=DutchDecimalFormatter, Mode=OneTime}"
                 PlaceholderText="You can also use expressions like '5 + 7'"
                 ValueChanged="NumberBox_ValueChanged"
                 Value="{x:Bind Product.UnitPrice}" />

 

Minimum and Maximum

Minimum and Maximum

The NumberBox has Minimum and Maximum properties that ensure the user cannot set a Value below the Minimum or above the Maximum. You can, although, clear the content of a NumberBox. The (double) Value property will get the value NAN (not a number). This is correct because NAN is not above or below anything. I don't like this so I came up with a solution where the Value of an empty NumberBox is set to the Minimum. I used two solutions for this. The first is using the ValueChanged event of the NumberBox. In the NumberBox_ValueChanged method the NewValue is tested for NAN and then the Value is set to the Minimum. The second solution is using my NanToMinimumBehavior. You can easily assign it to a NumberBox in Blend for VisualStudio. Just Drag & Drop it from the Assets panel onto a NumberBox.

class NanToMinimumBehavior : Behavior<NumberBox> {

    protected override void OnAttached() {
        base.OnAttached();
        this.AssociatedObject.ValueChanged += AssociatedObject_ValueChanged;
    }

    protected override void OnDetaching() {
        base.OnDetaching();
        this.AssociatedObject.ValueChanged -= AssociatedObject_ValueChanged;
    }

    private void AssociatedObject_ValueChanged(NumberBox sender, NumberBoxValueChangedEventArgs args) {
        if (double.IsNaN(args.NewValue)) {
            sender.Value = sender.Minimum;
        }
    }
}

 

Value & Binding

The Value of the NumberBox controls are databound to the properties of a Product or Employee. I have chosen all kind of datatypes for these properties: double, double?, decimal and int. Binding to a double is simple because the Value property is also of the type double.

ClassDiagram

I have created a few ValueConverter classes to bind to the decimal, int and nullable double (double?).

class DecimalToDoubleConverter : IValueConverter {

    public object Convert(object value, Type targetType, object parameter, string language) {
        return System.Convert.ToDouble(value);
    }

    public object ConvertBack(object value, Type targetType, object parameter, string language) {
        return double.IsNaN((double)value)
            ? (parameter is object ? System.Convert.ToDecimal(parameter) : 0)
            : System.Convert.ToDecimal(value);
    }
}

class IntToDoubleConverter : IValueConverter {

    public object Convert(object value, Type targetType, object parameter, string language) {
        return System.Convert.ToDouble(value);
    }

    public object ConvertBack(object value, Type targetType, object parameter, string language) {
        return double.IsNaN((double)value) 
            ? (parameter is object ? System.Convert.ToInt32(parameter) : 0) 
            : System.Convert.ToInt32(value);
    }
}

class NullableDoubleToStringConverter : IValueConverter {

    public object Convert(object value, Type targetType, object parameter, string language) {
        return value is object ? value.ToString() : string.Empty;
    }

    public object ConvertBack(object value, Type targetType, object parameter, string language) {
        return string.IsNullOrWhiteSpace((string)value) ? 
            (double?)null : 
            double.Parse((string)value, CultureInfo.GetCultureInfo(language));
    }
}

The Bonus property of the Employee class is a double?. The Value property of the NumberBox is a normal double. You can't use x:Bind for this and I don't like old-school binding. The solution I came up with is to use the Text property of the NumberBox in combination with the NullableDoubleToStringConverter converter. The converter will convert an string to/from a double?. If the text is empty the value will be null.

Closure

Writing the NumberBox proposal and working with the WinUI team to get specs right was a great experience. I can recommend everyone to get involved and suggest new controls or features.

Here are some other links you might be interesting:

Last, it may also be interesting for you to note that integrated input validation is planned for NumberBox and should be unblocked with the release of WinUI 3. 😊

 Fons

All postings/content on this blog are provided "AS IS" with no warranties, and confer no rights. All entries in this blog are my opinion and don't necessarily reflect the opinion of my employer or sponsors. The content on this site is licensed under a Creative Commons Attribution By license.

Leave a comment

Blog comments

AzureGulf

15-Mar-2021 3:57
Good work! Wish you'd added an example using a PercentFormatter, as it looks like I can only get this to work by using the NumberBox Small/Large increments, as any keyboard input is ignored (or rejected as invalid?).

NuAphex

21-Jun-2021 12:38
Just fidling around with the NumberBox and got a problem. I'll try to create something like a spinner with Values "00, 15, 30, 45". For that I set the min and max values to -15 and 60 and changes to 15. I'm databinding the value and when it hits the -15, I set it to 45 and on 60 to 0. This doesnt work if I use the Up/Down Buttons. the bound value gets set but the UI doesnt update. If I change the bound value by an extra button all works like expected. Its like the Up/Down Buttons just set the value on the UI and doesnt respect an INotifyPropertyChanged in that change. Any hints on how to fix that?