diff --git a/samples/ControlCatalog/ControlCatalog.csproj b/samples/ControlCatalog/ControlCatalog.csproj
index a3d7a0cdce..5d8f661990 100644
--- a/samples/ControlCatalog/ControlCatalog.csproj
+++ b/samples/ControlCatalog/ControlCatalog.csproj
@@ -78,6 +78,9 @@
Designer
+
+ Designer
+
Designer
@@ -169,6 +172,9 @@
ButtonSpinnerPage.xaml
+
+
+ NumericUpDownPage.xaml
diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml
index 142d0d42b1..a2e0980d6a 100644
--- a/samples/ControlCatalog/MainView.xaml
+++ b/samples/ControlCatalog/MainView.xaml
@@ -19,6 +19,7 @@
+
diff --git a/samples/ControlCatalog/Pages/NumericUpDownPage.xaml b/samples/ControlCatalog/Pages/NumericUpDownPage.xaml
new file mode 100644
index 0000000000..a5c911f47d
--- /dev/null
+++ b/samples/ControlCatalog/Pages/NumericUpDownPage.xaml
@@ -0,0 +1,80 @@
+
+
+ Numeric up-down control
+ Numeric up-down control provides a TextBox with button spinners that allow incrementing and decrementing numeric values by using the spinner buttons, keyboard up/down arrows, or mouse wheel.
+
+ Features:
+
+
+ ShowButtonSpinner:
+
+
+ IsReadOnly:
+
+
+ AllowSpin:
+
+
+ ClipValueToMinMax:
+
+
+
+
+ FormatString:
+
+
+
+
+
+
+
+
+
+
+
+
+ ButtonSpinnerLocation:
+
+
+ CultureInfo:
+
+
+ Watermark:
+
+
+ Text:
+
+
+
+ Minimum:
+
+
+ Maximum:
+
+
+ Increment:
+
+
+ Value:
+
+
+
+
+
+
+ Usage of NumericUpDown:
+
+
+
+
+
\ No newline at end of file
diff --git a/samples/ControlCatalog/Pages/NumericUpDownPage.xaml.cs b/samples/ControlCatalog/Pages/NumericUpDownPage.xaml.cs
new file mode 100644
index 0000000000..92da64d87e
--- /dev/null
+++ b/samples/ControlCatalog/Pages/NumericUpDownPage.xaml.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Markup.Xaml;
+using ReactiveUI;
+
+namespace ControlCatalog.Pages
+{
+ public class NumericUpDownPage : UserControl
+ {
+ public NumericUpDownPage()
+ {
+ this.InitializeComponent();
+ var viewModel = new NumbersPageViewModel();
+ DataContext = viewModel;
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ }
+
+ public class NumbersPageViewModel : ReactiveObject
+ {
+ private IList _formats;
+ private FormatObject _selectedFormat;
+ private IList _spinnerLocations;
+
+ public NumbersPageViewModel()
+ {
+ SelectedFormat = Formats.FirstOrDefault();
+ }
+
+ public IList Formats
+ {
+ get
+ {
+ return _formats ?? (_formats = new List()
+ {
+ new FormatObject() {Name = "Currency", Value = "C2"},
+ new FormatObject() {Name = "Fixed point", Value = "F2"},
+ new FormatObject() {Name = "General", Value = "G"},
+ new FormatObject() {Name = "Number", Value = "N"},
+ new FormatObject() {Name = "Percent", Value = "P"},
+ new FormatObject() {Name = "Degrees", Value = "{0:N2} °"},
+ });
+ }
+ }
+
+ public IList SpinnerLocations
+ {
+ get
+ {
+ if (_spinnerLocations == null)
+ {
+ _spinnerLocations = new List();
+ foreach (Location value in Enum.GetValues(typeof(Location)))
+ {
+ _spinnerLocations.Add(value);
+ }
+ }
+ return _spinnerLocations ;
+ }
+ }
+
+ public IList Cultures { get; } = new List()
+ {
+ new CultureInfo("en-US"),
+ new CultureInfo("en-GB"),
+ new CultureInfo("fr-FR"),
+ new CultureInfo("ar-DZ"),
+ new CultureInfo("zh-CN"),
+ new CultureInfo("cs-CZ")
+ };
+
+ public FormatObject SelectedFormat
+ {
+ get { return _selectedFormat; }
+ set { this.RaiseAndSetIfChanged(ref _selectedFormat, value); }
+ }
+ }
+
+ public class FormatObject
+ {
+ public string Value { get; set; }
+ public string Name { get; set; }
+ }
+}
diff --git a/src/Avalonia.Controls/ButtonSpinner.cs b/src/Avalonia.Controls/ButtonSpinner.cs
index 3ce81e0b12..866237ecce 100644
--- a/src/Avalonia.Controls/ButtonSpinner.cs
+++ b/src/Avalonia.Controls/ButtonSpinner.cs
@@ -201,6 +201,11 @@ namespace Avalonia.Controls
}
}
+ protected override void OnValidSpinDirectionChanged(ValidSpinDirections oldValue, ValidSpinDirections newValue)
+ {
+ SetButtonUsage();
+ }
+
///
/// Called when the property value changed.
///
diff --git a/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs b/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs
new file mode 100644
index 0000000000..59d2949b81
--- /dev/null
+++ b/src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs
@@ -0,0 +1,998 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using Avalonia.Controls.Primitives;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+using Avalonia.Utilities;
+
+namespace Avalonia.Controls
+{
+ ///
+ /// Control that represents a TextBox with button spinners that allow incrementing and decrementing numeric values.
+ ///
+ public class NumericUpDown : TemplatedControl
+ {
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty AllowSpinProperty =
+ ButtonSpinner.AllowSpinProperty.AddOwner();
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty ButtonSpinnerLocationProperty =
+ ButtonSpinner.ButtonSpinnerLocationProperty.AddOwner();
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty ShowButtonSpinnerProperty =
+ ButtonSpinner.ShowButtonSpinnerProperty.AddOwner();
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly DirectProperty ClipValueToMinMaxProperty =
+ AvaloniaProperty.RegisterDirect(nameof(ClipValueToMinMax),
+ updown => updown.ClipValueToMinMax, (updown, b) => updown.ClipValueToMinMax = b);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly DirectProperty CultureInfoProperty =
+ AvaloniaProperty.RegisterDirect(nameof(CultureInfo), o => o.CultureInfo,
+ (o, v) => o.CultureInfo = v, CultureInfo.CurrentCulture);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty FormatStringProperty =
+ AvaloniaProperty.Register(nameof(FormatString), string.Empty);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty IncrementProperty =
+ AvaloniaProperty.Register(nameof(Increment), 1.0d, validate: OnCoerceIncrement);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty IsReadOnlyProperty =
+ AvaloniaProperty.Register(nameof(IsReadOnly));
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty MaximumProperty =
+ AvaloniaProperty.Register(nameof(Maximum), double.MaxValue, validate: OnCoerceMaximum);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty MinimumProperty =
+ AvaloniaProperty.Register(nameof(Minimum), double.MinValue, validate: OnCoerceMinimum);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly DirectProperty ParsingNumberStyleProperty =
+ AvaloniaProperty.RegisterDirect(nameof(ParsingNumberStyle),
+ updown => updown.ParsingNumberStyle, (updown, style) => updown.ParsingNumberStyle = style);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly DirectProperty TextProperty =
+ AvaloniaProperty.RegisterDirect(nameof(Text), o => o.Text, (o, v) => o.Text = v,
+ defaultBindingMode: BindingMode.TwoWay);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly DirectProperty ValueProperty =
+ AvaloniaProperty.RegisterDirect(nameof(Value), updown => updown.Value,
+ (updown, v) => updown.Value = v, defaultBindingMode: BindingMode.TwoWay);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty WatermarkProperty =
+ AvaloniaProperty.Register(nameof(Watermark));
+
+ private IDisposable _textBoxTextChangedSubscription;
+
+ private double _value;
+ private string _text;
+ private bool _internalValueSet;
+ private bool _clipValueToMinMax;
+ private bool _isSyncingTextAndValueProperties;
+ private bool _isTextChangedFromUI;
+ private CultureInfo _cultureInfo;
+ private NumberStyles _parsingNumberStyle = NumberStyles.Any;
+
+ ///
+ /// Gets the Spinner template part.
+ ///
+ private Spinner Spinner { get; set; }
+
+ ///
+ /// Gets the TextBox template part.
+ ///
+ private TextBox TextBox { get; set; }
+
+ ///
+ /// Gets or sets the ability to perform increment/decrement operations via the keyboard, button spinners, or mouse wheel.
+ ///
+ public bool AllowSpin
+ {
+ get { return GetValue(AllowSpinProperty); }
+ set { SetValue(AllowSpinProperty, value); }
+ }
+
+ ///
+ /// Gets or sets current location of the .
+ ///
+ public Location ButtonSpinnerLocation
+ {
+ get { return GetValue(ButtonSpinnerLocationProperty); }
+ set { SetValue(ButtonSpinnerLocationProperty, value); }
+ }
+
+ ///
+ /// Gets or sets a value indicating whether the spin buttons should be shown.
+ ///
+ public bool ShowButtonSpinner
+ {
+ get { return GetValue(ShowButtonSpinnerProperty); }
+ set { SetValue(ShowButtonSpinnerProperty, value); }
+ }
+
+ ///
+ /// Gets or sets if the value should be clipped when minimum/maximum is reached.
+ ///
+ public bool ClipValueToMinMax
+ {
+ get { return _clipValueToMinMax; }
+ set { SetAndRaise(ClipValueToMinMaxProperty, ref _clipValueToMinMax, value); }
+ }
+
+ ///
+ /// Gets or sets the current CultureInfo.
+ ///
+ public CultureInfo CultureInfo
+ {
+ get { return _cultureInfo; }
+ set { SetAndRaise(CultureInfoProperty, ref _cultureInfo, value); }
+ }
+
+ ///
+ /// Gets or sets the display format of the .
+ ///
+ public string FormatString
+ {
+ get { return GetValue(FormatStringProperty); }
+ set { SetValue(FormatStringProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the amount in which to increment the .
+ ///
+ public double Increment
+ {
+ get { return GetValue(IncrementProperty); }
+ set { SetValue(IncrementProperty, value); }
+ }
+
+ ///
+ /// Gets or sets if the control is read only.
+ ///
+ public bool IsReadOnly
+ {
+ get { return GetValue(IsReadOnlyProperty); }
+ set { SetValue(IsReadOnlyProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the maximum allowed value.
+ ///
+ public double Maximum
+ {
+ get { return GetValue(MaximumProperty); }
+ set { SetValue(MaximumProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the minimum allowed value.
+ ///
+ public double Minimum
+ {
+ get { return GetValue(MinimumProperty); }
+ set { SetValue(MinimumProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the parsing style (AllowLeadingWhite, Float, AllowHexSpecifier, ...). By default, Any.
+ ///
+ public NumberStyles ParsingNumberStyle
+ {
+ get { return _parsingNumberStyle; }
+ set { SetAndRaise(ParsingNumberStyleProperty, ref _parsingNumberStyle, value); }
+ }
+
+ ///
+ /// Gets or sets the formatted string representation of the value.
+ ///
+ public string Text
+ {
+ get { return _text; }
+ set { SetAndRaise(TextProperty, ref _text, value); }
+ }
+
+ ///
+ /// Gets or sets the value.
+ ///
+ public double Value
+ {
+ get { return _value; }
+ set
+ {
+ value = OnCoerceValue(value);
+ SetAndRaise(ValueProperty, ref _value, value);
+ }
+ }
+
+ ///
+ /// Gets or sets the object to use as a watermark if the is null.
+ ///
+ public string Watermark
+ {
+ get { return GetValue(WatermarkProperty); }
+ set { SetValue(WatermarkProperty, value); }
+ }
+
+ ///
+ /// Initializes new instance of class.
+ ///
+ public NumericUpDown()
+ {
+ Initialized += (sender, e) =>
+ {
+ if (!_internalValueSet && IsInitialized)
+ {
+ SyncTextAndValueProperties(false, null, true);
+ }
+
+ SetValidSpinDirection();
+ };
+ }
+
+ ///
+ /// Initializes static members of the class.
+ ///
+ static NumericUpDown()
+ {
+ CultureInfoProperty.Changed.Subscribe(OnCultureInfoChanged);
+ FormatStringProperty.Changed.Subscribe(FormatStringChanged);
+ IncrementProperty.Changed.Subscribe(IncrementChanged);
+ IsReadOnlyProperty.Changed.Subscribe(OnIsReadOnlyChanged);
+ MaximumProperty.Changed.Subscribe(OnMaximumChanged);
+ MinimumProperty.Changed.Subscribe(OnMinimumChanged);
+ TextProperty.Changed.Subscribe(OnTextChanged);
+ ValueProperty.Changed.Subscribe(OnValueChanged);
+ }
+
+ ///
+ protected override void OnTemplateApplied(TemplateAppliedEventArgs e)
+ {
+ if (TextBox != null)
+ {
+ TextBox.PointerPressed -= TextBoxOnPointerPressed;
+ _textBoxTextChangedSubscription?.Dispose();
+ }
+ TextBox = e.NameScope.Find("PART_TextBox");
+ if (TextBox != null)
+ {
+ TextBox.Text = Text;
+ TextBox.PointerPressed += TextBoxOnPointerPressed;
+ _textBoxTextChangedSubscription = TextBox.GetObservable(TextBox.TextProperty).Subscribe(txt => TextBoxOnTextChanged());
+ }
+
+ if (Spinner != null)
+ {
+ Spinner.Spin -= OnSpinnerSpin;
+ }
+
+ Spinner = e.NameScope.Find("PART_Spinner");
+
+ if (Spinner != null)
+ {
+ Spinner.Spin += OnSpinnerSpin;
+ }
+
+ SetValidSpinDirection();
+ }
+
+ ///
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ switch (e.Key)
+ {
+ case Key.Enter:
+ var commitSuccess = CommitInput();
+ e.Handled = !commitSuccess;
+ break;
+ }
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The old value.
+ /// The new value.
+ protected virtual void OnCultureInfoChanged(CultureInfo oldValue, CultureInfo newValue)
+ {
+ if (IsInitialized)
+ {
+ SyncTextAndValueProperties(false, null);
+ }
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The old value.
+ /// The new value.
+ protected virtual void OnFormatStringChanged(string oldValue, string newValue)
+ {
+ if (IsInitialized)
+ {
+ SyncTextAndValueProperties(false, null);
+ }
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The old value.
+ /// The new value.
+ protected virtual void OnIncrementChanged(double oldValue, double newValue)
+ {
+ if (IsInitialized)
+ {
+ SetValidSpinDirection();
+ }
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The old value.
+ /// The new value.
+ protected virtual void OnIsReadOnlyChanged(bool oldValue, bool newValue)
+ {
+ SetValidSpinDirection();
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The old value.
+ /// The new value.
+ protected virtual void OnMaximumChanged(double oldValue, double newValue)
+ {
+ if (IsInitialized)
+ {
+ SetValidSpinDirection();
+ }
+ if (ClipValueToMinMax)
+ {
+ Value = MathUtilities.Clamp(Value, Minimum, Maximum);
+ }
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The old value.
+ /// The new value.
+ protected virtual void OnMinimumChanged(double oldValue, double newValue)
+ {
+ if (IsInitialized)
+ {
+ SetValidSpinDirection();
+ }
+ if (ClipValueToMinMax)
+ {
+ Value = MathUtilities.Clamp(Value, Minimum, Maximum);
+ }
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The old value.
+ /// The new value.
+ protected virtual void OnTextChanged(string oldValue, string newValue)
+ {
+ if (IsInitialized)
+ {
+ SyncTextAndValueProperties(true, Text);
+ }
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The old value.
+ /// The new value.
+ protected virtual void OnValueChanged(double oldValue, double newValue)
+ {
+ if (!_internalValueSet && IsInitialized)
+ {
+ SyncTextAndValueProperties(false, null, true);
+ }
+
+ SetValidSpinDirection();
+
+ RaiseValueChangedEvent(oldValue, newValue);
+ }
+
+ ///
+ /// Called when the property has to be coerced.
+ ///
+ /// The value.
+ protected virtual double OnCoerceIncrement(double baseValue)
+ {
+ return baseValue;
+ }
+
+ ///
+ /// Called when the property has to be coerced.
+ ///
+ /// The value.
+ protected virtual double OnCoerceMaximum(double baseValue)
+ {
+ return Math.Max(baseValue, Minimum);
+ }
+
+ ///
+ /// Called when the property has to be coerced.
+ ///
+ /// The value.
+ protected virtual double OnCoerceMinimum(double baseValue)
+ {
+ return Math.Min(baseValue, Maximum);
+ }
+
+ ///
+ /// Called when the property has to be coerced.
+ ///
+ /// The value.
+ protected virtual double OnCoerceValue(double baseValue)
+ {
+ return baseValue;
+ }
+
+ ///
+ /// Raises the OnSpin event when spinning is initiated by the end-user.
+ ///
+ /// The event args.
+ protected virtual void OnSpin(SpinEventArgs e)
+ {
+ if (e == null)
+ {
+ throw new ArgumentNullException("e");
+ }
+
+ var handler = Spinned;
+ handler?.Invoke(this, e);
+
+ if (e.Direction == SpinDirection.Increase)
+ {
+ DoIncrement();
+ }
+ else
+ {
+ DoDecrement();
+ }
+ }
+
+ ///
+ /// Raises the event.
+ ///
+ /// The old value.
+ /// The new value.
+ protected virtual void RaiseValueChangedEvent(double oldValue, double newValue)
+ {
+ var e = new NumericUpDownValueChangedEventArgs(ValueChangedEvent, oldValue, newValue);
+ RaiseEvent(e);
+ }
+
+ ///
+ /// Converts the formatted text to a value.
+ ///
+ private double ConvertTextToValue(string text)
+ {
+ double result = 0;
+
+ if (string.IsNullOrEmpty(text))
+ {
+ return result;
+ }
+
+ // Since the conversion from Value to text using a FormartString may not be parsable,
+ // we verify that the already existing text is not the exact same value.
+ var currentValueText = ConvertValueToText();
+ if (Equals(currentValueText, text))
+ {
+ return Value;
+ }
+
+ result = ConvertTextToValueCore(currentValueText, text);
+
+ if (ClipValueToMinMax)
+ {
+ return MathUtilities.Clamp(result, Minimum, Maximum);
+ }
+
+ ValidateMinMax(result);
+
+ return result;
+ }
+
+ ///
+ /// Converts the value to formatted text.
+ ///
+ ///
+ private string ConvertValueToText()
+ {
+ //Manage FormatString of type "{}{0:N2} °" (in xaml) or "{0:N2} °" in code-behind.
+ if (FormatString.Contains("{0"))
+ {
+ return string.Format(CultureInfo, FormatString, Value);
+ }
+
+ return Value.ToString(FormatString, CultureInfo);
+ }
+
+ ///
+ /// Called by OnSpin when the spin direction is SpinDirection.Increase.
+ ///
+ private void OnIncrement()
+ {
+ var result = Value + Increment;
+ Value = MathUtilities.Clamp(result, Minimum, Maximum);
+ }
+
+ ///
+ /// Called by OnSpin when the spin direction is SpinDirection.Descrease.
+ ///
+ private void OnDecrement()
+ {
+ var result = Value - Increment;
+ Value = MathUtilities.Clamp(result, Minimum, Maximum);
+ }
+
+ ///
+ /// Sets the valid spin directions.
+ ///
+ private void SetValidSpinDirection()
+ {
+ var validDirections = ValidSpinDirections.None;
+
+ // Zero increment always prevents spin.
+ if (Increment != 0 && !IsReadOnly)
+ {
+ if (Value < Maximum)
+ {
+ validDirections = validDirections | ValidSpinDirections.Increase;
+ }
+
+ if (Value > Minimum)
+ {
+ validDirections = validDirections | ValidSpinDirections.Decrease;
+ }
+ }
+
+ if (Spinner != null)
+ {
+ Spinner.ValidSpinDirection = validDirections;
+ }
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The event args.
+ private static void OnCultureInfoChanged(AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Sender is NumericUpDown upDown)
+ {
+ var oldValue = (CultureInfo)e.OldValue;
+ var newValue = (CultureInfo)e.NewValue;
+ upDown.OnCultureInfoChanged(oldValue, newValue);
+ }
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The event args.
+ private static void IncrementChanged(AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Sender is NumericUpDown upDown)
+ {
+ var oldValue = (double)e.OldValue;
+ var newValue = (double)e.NewValue;
+ upDown.OnIncrementChanged(oldValue, newValue);
+ }
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The event args.
+ private static void FormatStringChanged(AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Sender is NumericUpDown upDown)
+ {
+ var oldValue = (string)e.OldValue;
+ var newValue = (string)e.NewValue;
+ upDown.OnFormatStringChanged(oldValue, newValue);
+ }
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The event args.
+ private static void OnIsReadOnlyChanged(AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Sender is NumericUpDown upDown)
+ {
+ var oldValue = (bool)e.OldValue;
+ var newValue = (bool)e.NewValue;
+ upDown.OnIsReadOnlyChanged(oldValue, newValue);
+ }
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The event args.
+ private static void OnMaximumChanged(AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Sender is NumericUpDown upDown)
+ {
+ var oldValue = (double)e.OldValue;
+ var newValue = (double)e.NewValue;
+ upDown.OnMaximumChanged(oldValue, newValue);
+ }
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The event args.
+ private static void OnMinimumChanged(AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Sender is NumericUpDown upDown)
+ {
+ var oldValue = (double)e.OldValue;
+ var newValue = (double)e.NewValue;
+ upDown.OnMinimumChanged(oldValue, newValue);
+ }
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The event args.
+ private static void OnTextChanged(AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Sender is NumericUpDown upDown)
+ {
+ var oldValue = (string)e.OldValue;
+ var newValue = (string)e.NewValue;
+ upDown.OnTextChanged(oldValue, newValue);
+ }
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The event args.
+ private static void OnValueChanged(AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Sender is NumericUpDown upDown)
+ {
+ var oldValue = (double)e.OldValue;
+ var newValue = (double)e.NewValue;
+ upDown.OnValueChanged(oldValue, newValue);
+ }
+ }
+
+ private void SetValueInternal(double value)
+ {
+ _internalValueSet = true;
+ try
+ {
+ Value = value;
+ }
+ finally
+ {
+ _internalValueSet = false;
+ }
+ }
+
+ private static double OnCoerceMaximum(NumericUpDown upDown, double value)
+ {
+ return upDown.OnCoerceMaximum(value);
+ }
+
+ private static double OnCoerceMinimum(NumericUpDown upDown, double value)
+ {
+ return upDown.OnCoerceMinimum(value);
+ }
+
+ private static double OnCoerceIncrement(NumericUpDown upDown, double value)
+ {
+ return upDown.OnCoerceIncrement(value);
+ }
+
+ private void TextBoxOnTextChanged()
+ {
+ try
+ {
+ _isTextChangedFromUI = true;
+ if (TextBox != null)
+ {
+ Text = TextBox.Text;
+ }
+ }
+ finally
+ {
+ _isTextChangedFromUI = false;
+ }
+ }
+
+ private void OnSpinnerSpin(object sender, SpinEventArgs e)
+ {
+ if (AllowSpin && !IsReadOnly)
+ {
+ var spin = !e.UsingMouseWheel;
+ spin |= ((TextBox != null) && TextBox.IsFocused);
+
+ if (spin)
+ {
+ e.Handled = true;
+ OnSpin(e);
+ }
+ }
+ }
+
+ private void DoDecrement()
+ {
+ if (Spinner == null || (Spinner.ValidSpinDirection & ValidSpinDirections.Decrease) == ValidSpinDirections.Decrease)
+ {
+ OnDecrement();
+ }
+ }
+
+ private void DoIncrement()
+ {
+ if (Spinner == null || (Spinner.ValidSpinDirection & ValidSpinDirections.Increase) == ValidSpinDirections.Increase)
+ {
+ OnIncrement();
+ }
+ }
+
+ public event EventHandler Spinned;
+
+ private void TextBoxOnPointerPressed(object sender, PointerPressedEventArgs e)
+ {
+ if (e.Device.Captured != Spinner)
+ {
+ Dispatcher.UIThread.InvokeAsync(() => { e.Device.Capture(Spinner); }, DispatcherPriority.Input);
+ }
+ }
+
+ ///
+ /// Defines the event.
+ ///
+ public static readonly RoutedEvent ValueChangedEvent =
+ RoutedEvent.Register(nameof(ValueChanged), RoutingStrategies.Bubble);
+
+ ///
+ /// Raised when the changes.
+ ///
+ public event EventHandler ValueChanged
+ {
+ add { AddHandler(ValueChangedEvent, value); }
+ remove { RemoveHandler(ValueChangedEvent, value); }
+ }
+
+ private bool CommitInput()
+ {
+ return SyncTextAndValueProperties(true, Text);
+ }
+
+ ///
+ /// Synchronize and properties.
+ ///
+ /// If value should be updated from text.
+ /// The text.
+ private bool SyncTextAndValueProperties(bool updateValueFromText, string text)
+ {
+ return SyncTextAndValueProperties(updateValueFromText, text, false);
+ }
+
+ ///
+ /// Synchronize and properties.
+ ///
+ /// If value should be updated from text.
+ /// The text.
+ /// Force text update.
+ private bool SyncTextAndValueProperties(bool updateValueFromText, string text, bool forceTextUpdate)
+ {
+ if (_isSyncingTextAndValueProperties)
+ return true;
+
+ _isSyncingTextAndValueProperties = true;
+ var parsedTextIsValid = true;
+ try
+ {
+ if (updateValueFromText)
+ {
+ if (!string.IsNullOrEmpty(text))
+ {
+ try
+ {
+ var newValue = ConvertTextToValue(text);
+ if (!Equals(newValue, Value))
+ {
+ SetValueInternal(newValue);
+ }
+ }
+ catch
+ {
+ parsedTextIsValid = false;
+ }
+ }
+ }
+
+ // Do not touch the ongoing text input from user.
+ if (!_isTextChangedFromUI)
+ {
+ var keepEmpty = !forceTextUpdate && string.IsNullOrEmpty(Text);
+ if (!keepEmpty)
+ {
+ var newText = ConvertValueToText();
+ if (!Equals(Text, newText))
+ {
+ Text = newText;
+ }
+ }
+
+ // Sync Text and textBox
+ if (TextBox != null)
+ {
+ TextBox.Text = Text;
+ }
+ }
+
+ if (_isTextChangedFromUI && !parsedTextIsValid)
+ {
+ // Text input was made from the user and the text
+ // repesents an invalid value. Disable the spinner in this case.
+ if (Spinner != null)
+ {
+ Spinner.ValidSpinDirection = ValidSpinDirections.None;
+ }
+ }
+ else
+ {
+ SetValidSpinDirection();
+ }
+ }
+ finally
+ {
+ _isSyncingTextAndValueProperties = false;
+ }
+ return parsedTextIsValid;
+ }
+
+ private double ConvertTextToValueCore(string currentValueText, string text)
+ {
+ double result;
+
+ if (IsPercent(FormatString))
+ {
+ result = decimal.ToDouble(ParsePercent(text, CultureInfo));
+ }
+ else
+ {
+ // Problem while converting new text
+ if (!double.TryParse(text, ParsingNumberStyle, CultureInfo, out var outputValue))
+ {
+ var shouldThrow = true;
+
+ // Check if CurrentValueText is also failing => it also contains special characters. ex : 90°
+ if (!double.TryParse(currentValueText, ParsingNumberStyle, CultureInfo, out var _))
+ {
+ // extract non-digit characters
+ var currentValueTextSpecialCharacters = currentValueText.Where(c => !char.IsDigit(c));
+ var textSpecialCharacters = text.Where(c => !char.IsDigit(c));
+ // same non-digit characters on currentValueText and new text => remove them on new Text to parse it again.
+ if (currentValueTextSpecialCharacters.Except(textSpecialCharacters).ToList().Count == 0)
+ {
+ foreach (var character in textSpecialCharacters)
+ {
+ text = text.Replace(character.ToString(), string.Empty);
+ }
+ // if without the special characters, parsing is good, do not throw
+ if (double.TryParse(text, ParsingNumberStyle, CultureInfo, out outputValue))
+ {
+ shouldThrow = false;
+ }
+ }
+ }
+
+ if (shouldThrow)
+ {
+ throw new InvalidDataException("Input string was not in a correct format.");
+ }
+ }
+ result = outputValue;
+ }
+ return result;
+ }
+
+ private void ValidateMinMax(double value)
+ {
+ if (value < Minimum)
+ {
+ throw new ArgumentOutOfRangeException(nameof(Minimum), string.Format("Value must be greater than Minimum value of {0}", Minimum));
+ }
+ else if (value > Maximum)
+ {
+ throw new ArgumentOutOfRangeException(nameof(Maximum), string.Format("Value must be less than Maximum value of {0}", Maximum));
+ }
+ }
+
+ ///
+ /// Parse percent format text
+ ///
+ /// Text to parse.
+ /// The culture info.
+ private static decimal ParsePercent(string text, IFormatProvider cultureInfo)
+ {
+ var info = NumberFormatInfo.GetInstance(cultureInfo);
+ text = text.Replace(info.PercentSymbol, null);
+ var result = decimal.Parse(text, NumberStyles.Any, info);
+ result = result / 100;
+ return result;
+ }
+
+
+ private bool IsPercent(string stringToTest)
+ {
+ var PIndex = stringToTest.IndexOf("P", StringComparison.Ordinal);
+ if (PIndex >= 0)
+ {
+ //stringToTest contains a "P" between 2 "'", it's considered as text, not percent
+ var isText = stringToTest.Substring(0, PIndex).Contains("'")
+ && stringToTest.Substring(PIndex, FormatString.Length - PIndex).Contains("'");
+
+ return !isText;
+ }
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs b/src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs
new file mode 100644
index 0000000000..e994ffdd15
--- /dev/null
+++ b/src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs
@@ -0,0 +1,16 @@
+using Avalonia.Interactivity;
+
+namespace Avalonia.Controls
+{
+ public class NumericUpDownValueChangedEventArgs : RoutedEventArgs
+ {
+ public NumericUpDownValueChangedEventArgs(RoutedEvent routedEvent, double oldValue, double newValue) : base(routedEvent)
+ {
+ OldValue = oldValue;
+ NewValue = newValue;
+ }
+
+ public double OldValue { get; }
+ public double NewValue { get; }
+ }
+}
diff --git a/src/Avalonia.Themes.Default/DefaultTheme.xaml b/src/Avalonia.Themes.Default/DefaultTheme.xaml
index 7c567c1835..aa1a3c6385 100644
--- a/src/Avalonia.Themes.Default/DefaultTheme.xaml
+++ b/src/Avalonia.Themes.Default/DefaultTheme.xaml
@@ -43,4 +43,5 @@
+
diff --git a/src/Avalonia.Themes.Default/NumericUpDown.xaml b/src/Avalonia.Themes.Default/NumericUpDown.xaml
new file mode 100644
index 0000000000..e6325d07dc
--- /dev/null
+++ b/src/Avalonia.Themes.Default/NumericUpDown.xaml
@@ -0,0 +1,41 @@
+
+
+
\ No newline at end of file