diff --git a/src/Avalonia.Controls/NumericUpDown/CommonNumericUpDown.cs b/src/Avalonia.Controls/NumericUpDown/CommonNumericUpDown.cs
new file mode 100644
index 0000000000..1ce2508b4a
--- /dev/null
+++ b/src/Avalonia.Controls/NumericUpDown/CommonNumericUpDown.cs
@@ -0,0 +1,346 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+
+namespace Avalonia.Controls
+{
+ ///
+ /// Base class for controls that represents a TextBox with button spinners that allow incrementing and decrementing numeric values.
+ ///
+ public abstract class CommonNumericUpDown : NumericUpDown where T : struct, IFormattable, IComparable
+ {
+ protected delegate bool FromText(string s, NumberStyles style, IFormatProvider provider, out T result);
+ protected delegate T FromDecimal(decimal d);
+
+ private readonly FromText _fromText;
+ private readonly FromDecimal _fromDecimal;
+ private readonly Func _fromLowerThan;
+ private readonly Func _fromGreaterThan;
+
+ private NumberStyles _parsingNumberStyle = NumberStyles.Any;
+
+ ///
+ /// Defines the property.
+ ///
+ public static readonly DirectProperty, NumberStyles> ParsingNumberStyleProperty =
+ AvaloniaProperty.RegisterDirect, NumberStyles>(nameof(ParsingNumberStyle),
+ updown => updown.ParsingNumberStyle, (updown, style) => updown.ParsingNumberStyle = style);
+
+
+ ///
+ /// Initializes new instance of the class.
+ ///
+ /// Delegate to parse value from text.
+ /// Delegate to parse value from decimal.
+ /// Delegate to compare if one value is lower than another.
+ /// Delegate to compare if one value is greater than another.
+ protected CommonNumericUpDown(FromText fromText, FromDecimal fromDecimal, Func fromLowerThan, Func fromGreaterThan)
+ {
+ _fromText = fromText ?? throw new ArgumentNullException(nameof(fromText));
+ _fromDecimal = fromDecimal ?? throw new ArgumentNullException(nameof(fromDecimal));
+ _fromLowerThan = fromLowerThan ?? throw new ArgumentNullException(nameof(fromLowerThan));
+ _fromGreaterThan = fromGreaterThan ?? throw new ArgumentNullException(nameof(fromGreaterThan));
+ }
+
+ ///
+ /// Gets or sets the parsing style (AllowLeadingWhite, Float, AllowHexSpecifier, ...). By default, Any.
+ ///
+ public NumberStyles ParsingNumberStyle
+ {
+ get { return _parsingNumberStyle; }
+ set { SetAndRaise(ParsingNumberStyleProperty, ref _parsingNumberStyle, value); }
+ }
+
+ ///
+ protected override void OnIncrement()
+ {
+ if (!HandleNullSpin())
+ {
+ var result = IncrementValue(Value.Value, Increment.Value);
+ Value = CoerceValueMinMax(result);
+ }
+ }
+
+ ///
+ protected override void OnDecrement()
+ {
+ if (!HandleNullSpin())
+ {
+ var result = DecrementValue(Value.Value, Increment.Value);
+ Value = CoerceValueMinMax(result);
+ }
+ }
+
+ ///
+ protected override void OnMinimumChanged(T? oldValue, T? newValue)
+ {
+ base.OnMinimumChanged(oldValue, newValue);
+
+ if (Value.HasValue && ClipValueToMinMax)
+ {
+ Value = CoerceValueMinMax(Value.Value);
+ }
+ }
+
+ ///
+ protected override void OnMaximumChanged(T? oldValue, T? newValue)
+ {
+ base.OnMaximumChanged(oldValue, newValue);
+
+ if (Value.HasValue && ClipValueToMinMax)
+ {
+ Value = CoerceValueMinMax(Value.Value);
+ }
+ }
+
+ ///
+ protected override T? ConvertTextToValue(string text)
+ {
+ T? result = null;
+
+ 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 GetClippedMinMaxValue(result);
+ }
+
+ ValidateDefaultMinMax(result);
+
+ return result;
+ }
+
+ ///
+ protected override string ConvertValueToText()
+ {
+ if (Value == null)
+ {
+ return string.Empty;
+ }
+
+ //Manage FormatString of type "{}{0:N2} °" (in xaml) or "{0:N2} °" in code-behind.
+ if (FormatString.Contains("{0"))
+ {
+ return string.Format(CultureInfo, FormatString, Value.Value);
+ }
+
+ return Value.Value.ToString(FormatString, CultureInfo);
+ }
+
+ ///
+ protected override void SetValidSpinDirection()
+ {
+ var validDirections = ValidSpinDirections.None;
+
+ // Null increment always prevents spin.
+ if (Increment != null && !IsReadOnly)
+ {
+ if (IsLowerThan(Value, Maximum) || !Value.HasValue || !Maximum.HasValue)
+ {
+ validDirections = validDirections | ValidSpinDirections.Increase;
+ }
+
+ if (IsGreaterThan(Value, Minimum) || !Value.HasValue || !Minimum.HasValue)
+ {
+ validDirections = validDirections | ValidSpinDirections.Decrease;
+ }
+ }
+
+ if (Spinner != null)
+ {
+ Spinner.ValidSpinDirection = validDirections;
+ }
+ }
+
+ ///
+ /// Checks if provided value is within allowed values.
+ ///
+ /// The alowed values.
+ /// The value to check.
+ protected void TestInputSpecialValue(AllowedSpecialValues allowedValues, AllowedSpecialValues valueToCompare)
+ {
+ if ((allowedValues & valueToCompare) != valueToCompare)
+ {
+ switch (valueToCompare)
+ {
+ case AllowedSpecialValues.NaN:
+ throw new InvalidDataException("Value to parse shouldn't be NaN.");
+ case AllowedSpecialValues.PositiveInfinity:
+ throw new InvalidDataException("Value to parse shouldn't be Positive Infinity.");
+ case AllowedSpecialValues.NegativeInfinity:
+ throw new InvalidDataException("Value to parse shouldn't be Negative Infinity.");
+ }
+ }
+ }
+
+ protected static void UpdateMetadata(Type type, T? increment, T? minimun, T? maximum)
+ {
+ IncrementProperty.OverrideDefaultValue(type, increment);
+ MinimumProperty.OverrideDefaultValue(type, minimun);
+ MaximumProperty.OverrideDefaultValue(type, maximum);
+ }
+
+ protected abstract T IncrementValue(T value, T increment);
+
+ protected abstract T DecrementValue(T value, T increment);
+
+ private bool IsLowerThan(T? value1, T? value2)
+ {
+ if (value1 == null || value2 == null)
+ {
+ return false;
+ }
+ return _fromLowerThan(value1.Value, value2.Value);
+ }
+
+ private bool IsGreaterThan(T? value1, T? value2)
+ {
+ if (value1 == null || value2 == null)
+ {
+ return false;
+ }
+ return _fromGreaterThan(value1.Value, value2.Value);
+ }
+
+ private bool HandleNullSpin()
+ {
+ if (!Value.HasValue)
+ {
+ var forcedValue = DefaultValue ?? default(T);
+ Value = CoerceValueMinMax(forcedValue);
+ return true;
+ }
+ else if (!Increment.HasValue)
+ {
+ return true;
+ }
+ return false;
+ }
+
+ internal bool IsValid(T? value)
+ {
+ return !IsLowerThan(value, Minimum) && !IsGreaterThan(value, Maximum);
+ }
+
+ private T? CoerceValueMinMax(T value)
+ {
+ if (IsLowerThan(value, Minimum))
+ {
+ return Minimum;
+ }
+ else if (IsGreaterThan(value, Maximum))
+ {
+ return Maximum;
+ }
+ else
+ {
+ return value;
+ }
+ }
+
+ 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;
+ }
+
+ private T? ConvertTextToValueCore(string currentValueText, string text)
+ {
+ T? result;
+
+ if (IsPercent(FormatString))
+ {
+ result = _fromDecimal(ParsePercent(text, CultureInfo));
+ }
+ else
+ {
+ // Problem while converting new text
+ if (!_fromText(text, ParsingNumberStyle, CultureInfo, out T outputValue))
+ {
+ var shouldThrow = true;
+
+ // Check if CurrentValueText is also failing => it also contains special characters. ex : 90°
+ if (!_fromText(currentValueText, ParsingNumberStyle, CultureInfo, out T _))
+ {
+ // 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 (_fromText(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 T? GetClippedMinMaxValue(T? result)
+ {
+ if (IsGreaterThan(result, Maximum))
+ {
+ return Maximum;
+ }
+ else if (IsLowerThan(result, Minimum))
+ {
+ return Minimum;
+ }
+ return result;
+ }
+
+ private void ValidateDefaultMinMax(T? value)
+ {
+ // DefaultValue is always accepted.
+ if (Equals(value, DefaultValue))
+ {
+ return;
+ }
+
+ if (IsLowerThan(value, Minimum))
+ {
+ throw new ArgumentOutOfRangeException(nameof(Minimum), string.Format("Value must be greater than Minimum value of {0}", Minimum));
+ }
+ else if (IsGreaterThan(value, Maximum))
+ {
+ throw new ArgumentOutOfRangeException(nameof(Maximum), string.Format("Value must be less than Maximum value of {0}", Maximum));
+ }
+ }
+ }
+}
\ No newline at end of file