diff --git a/src/Avalonia.Controls/NumericUpDown/UpDownBase.cs b/src/Avalonia.Controls/NumericUpDown/UpDownBase.cs
new file mode 100644
index 0000000000..fa95eb01a7
--- /dev/null
+++ b/src/Avalonia.Controls/NumericUpDown/UpDownBase.cs
@@ -0,0 +1,824 @@
+using System;
+using System.Globalization;
+using Avalonia.Controls.Primitives;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+
+namespace Avalonia.Controls
+{
+ public abstract class UpDownBase : TemplatedControl
+ {
+ }
+
+ ///
+ /// Base class for controls that represents a TextBox with button spinners that allow incrementing and decrementing values.
+ ///
+ public abstract class UpDownBase : UpDownBase
+ {
+ ///
+ /// 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, bool> ClipValueToMinMaxProperty =
+ AvaloniaProperty.RegisterDirect, bool>(nameof(ClipValueToMinMax),
+ updown => updown.ClipValueToMinMax, (updown, b) => updown.ClipValueToMinMax = b);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly DirectProperty, CultureInfo> CultureInfoProperty =
+ AvaloniaProperty.RegisterDirect, CultureInfo>(nameof(CultureInfo), o => o.CultureInfo,
+ (o, v) => o.CultureInfo = v, CultureInfo.CurrentCulture);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty DefaultValueProperty =
+ AvaloniaProperty.Register, T>(nameof(DefaultValue));
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty DisplayDefaultValueOnEmptyTextProperty =
+ AvaloniaProperty.Register, bool>(nameof(DisplayDefaultValueOnEmptyText));
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty IsReadOnlyProperty =
+ AvaloniaProperty.Register, bool>(nameof(IsReadOnly));
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty MaximumProperty =
+ AvaloniaProperty.Register, T>(nameof(Maximum), validate: OnCoerceMaximum);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty MinimumProperty =
+ AvaloniaProperty.Register, T>(nameof(Minimum), validate: OnCoerceMinimum);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly DirectProperty, string> TextProperty =
+ AvaloniaProperty.RegisterDirect, string>(nameof(Text), o => o.Text, (o, v) => o.Text = v,
+ defaultBindingMode: BindingMode.TwoWay);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly DirectProperty, T> ValueProperty =
+ AvaloniaProperty.RegisterDirect, T>(nameof(Value), updown => updown.Value,
+ (updown, v) => updown.Value = v, defaultBindingMode: BindingMode.TwoWay);
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly StyledProperty WatermarkProperty =
+ AvaloniaProperty.Register, string>(nameof(Watermark));
+
+ private IDisposable _textBoxTextChangedSubscription;
+ private T _value;
+ private string _text;
+ private bool _internalValueSet;
+ private bool _clipValueToMinMax;
+ private bool _isSyncingTextAndValueProperties;
+ private bool _isTextChangedFromUI;
+ private CultureInfo _cultureInfo;
+
+ ///
+ /// Gets the Spinner template part.
+ ///
+ protected Spinner Spinner { get; private set; }
+
+ ///
+ /// Gets the TextBox template part.
+ ///
+ protected TextBox TextBox { get; private 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 value to use when the is null and an increment/decrement operation is performed.
+ ///
+ public T DefaultValue
+ {
+ get { return GetValue(DefaultValueProperty); }
+ set { SetValue(DefaultValueProperty, value); }
+ }
+
+ ///
+ /// Gets or sets if the defaultValue should be displayed when the Text is empty.
+ ///
+ public bool DisplayDefaultValueOnEmptyText
+ {
+ get { return GetValue(DisplayDefaultValueOnEmptyTextProperty); }
+ set { SetValue(DisplayDefaultValueOnEmptyTextProperty, 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 T Maximum
+ {
+ get { return GetValue(MaximumProperty); }
+ set { SetValue(MaximumProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the minimum allowed value.
+ ///
+ public T Minimum
+ {
+ get { return GetValue(MinimumProperty); }
+ set { SetValue(MinimumProperty, 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 T 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.
+ ///
+ protected UpDownBase()
+ {
+ Initialized += (sender, e) =>
+ {
+ if (!_internalValueSet && IsInitialized)
+ {
+ SyncTextAndValueProperties(false, null, true);
+ }
+
+ SetValidSpinDirection();
+ };
+ }
+
+ ///
+ /// Initializes static members of the class.
+ ///
+ static UpDownBase()
+ {
+ CultureInfoProperty.Changed.Subscribe(OnCultureInfoChanged);
+ DefaultValueProperty.Changed.Subscribe(OnDefaultValueChanged);
+ DisplayDefaultValueOnEmptyTextProperty.Changed.Subscribe(OnDisplayDefaultValueOnEmptyTextChanged);
+ 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 OnDefaultValueChanged(T oldValue, T newValue)
+ {
+ if (IsInitialized && string.IsNullOrEmpty(Text))
+ {
+ SyncTextAndValueProperties(true, Text);
+ }
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The old value.
+ /// The new value.
+ protected virtual void OnDisplayDefaultValueOnEmptyTextChanged(bool oldValue, bool newValue)
+ {
+ if (IsInitialized && string.IsNullOrEmpty(Text))
+ {
+ SyncTextAndValueProperties(false, Text);
+ }
+ }
+
+ ///
+ /// 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(T oldValue, T newValue)
+ {
+ if (IsInitialized)
+ {
+ SetValidSpinDirection();
+ }
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The old value.
+ /// The new value.
+ protected virtual void OnMinimumChanged(T oldValue, T newValue)
+ {
+ if (IsInitialized)
+ {
+ SetValidSpinDirection();
+ }
+ }
+
+ ///
+ /// 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(T oldValue, T newValue)
+ {
+ if (!_internalValueSet && IsInitialized)
+ {
+ SyncTextAndValueProperties(false, null, true);
+ }
+
+ SetValidSpinDirection();
+
+ RaiseValueChangedEvent(oldValue, newValue);
+ }
+
+ ///
+ /// Called when the property has to be coerced.
+ ///
+ /// The value.
+ protected virtual T OnCoerceMaximum(T baseValue)
+ {
+ return baseValue;
+ }
+
+ ///
+ /// Called when the property has to be coerced.
+ ///
+ /// The value.
+ protected virtual T OnCoerceMinimum(T baseValue)
+ {
+ return baseValue;
+ }
+
+ ///
+ /// Called when the property has to be coerced.
+ ///
+ /// The value.
+ protected virtual T OnCoerceValue(T 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(T oldValue, T newValue)
+ {
+ var e = new UpDownValueChangedEventArgs(ValueChangedEvent, oldValue, newValue);
+ RaiseEvent(e);
+ }
+
+ ///
+ /// Converts the formatted text to a value.
+ ///
+ protected abstract T ConvertTextToValue(string text);
+
+ ///
+ /// Converts the value to formatted text.
+ ///
+ ///
+ protected abstract string ConvertValueToText();
+
+ ///
+ /// Called by OnSpin when the spin direction is SpinDirection.Increase.
+ ///
+ protected abstract void OnIncrement();
+
+ ///
+ /// Called by OnSpin when the spin direction is SpinDirection.Descrease.
+ ///
+ protected abstract void OnDecrement();
+
+ ///
+ /// Sets the valid spin directions.
+ ///
+ protected abstract void SetValidSpinDirection();
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The event args.
+ private static void OnCultureInfoChanged(AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Sender is UpDownBase 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 OnDefaultValueChanged(AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Sender is UpDownBase upDown)
+ {
+ var oldValue = (T)e.OldValue;
+ var newValue = (T)e.NewValue;
+ upDown.OnDefaultValueChanged(oldValue, newValue);
+ }
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The event args.
+ private static void OnDisplayDefaultValueOnEmptyTextChanged(AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Sender is UpDownBase upDown)
+ {
+ var oldValue = (bool) e.OldValue;
+ var newValue = (bool) e.NewValue;
+ upDown.OnDisplayDefaultValueOnEmptyTextChanged(oldValue, newValue);
+ }
+ }
+
+ ///
+ /// Called when the property value changed.
+ ///
+ /// The event args.
+ private static void OnIsReadOnlyChanged(AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Sender is UpDownBase 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 UpDownBase upDown)
+ {
+ var oldValue = (T)e.OldValue;
+ var newValue = (T)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 UpDownBase upDown)
+ {
+ var oldValue = (T)e.OldValue;
+ var newValue = (T)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 UpDownBase 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 UpDownBase upDown)
+ {
+ var oldValue = (T)e.OldValue;
+ var newValue = (T)e.NewValue;
+ upDown.OnValueChanged(oldValue, newValue);
+ }
+ }
+
+ private void SetValueInternal(T value)
+ {
+ _internalValueSet = true;
+ try
+ {
+ Value = value;
+ }
+ finally
+ {
+ _internalValueSet = false;
+ }
+ }
+
+ private static T OnCoerceMaximum(UpDownBase upDown, T value)
+ {
+ return upDown.OnCoerceMaximum(value);
+ }
+
+ private static T OnCoerceMinimum(UpDownBase upDown, T value)
+ {
+ return upDown.OnCoerceMinimum(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);
+ }
+ }
+ }
+
+ internal void DoDecrement()
+ {
+ if (Spinner == null || (Spinner.ValidSpinDirection & ValidSpinDirections.Decrease) == ValidSpinDirections.Decrease)
+ {
+ OnDecrement();
+ }
+ }
+
+ internal 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, UpDownValueChangedEventArgs>(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.
+ protected 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))
+ {
+ // An empty input sets the value to the default value.
+ SetValueInternal(DefaultValue);
+ }
+ else
+ {
+ 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)
+ {
+ // Don't replace the empty Text with the non-empty representation of DefaultValue.
+ var shouldKeepEmpty = !forceTextUpdate && string.IsNullOrEmpty(Text) && Equals(Value, DefaultValue) && !DisplayDefaultValueOnEmptyText;
+ if (!shouldKeepEmpty)
+ {
+ 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;
+ }
+ }
+
+ public class UpDownValueChangedEventArgs : RoutedEventArgs
+ {
+ public UpDownValueChangedEventArgs(RoutedEvent routedEvent, T oldValue, T newValue) : base(routedEvent)
+ {
+ OldValue = oldValue;
+ NewValue = newValue;
+ }
+
+ public T OldValue { get; }
+ public T NewValue { get; }
+ }
+}