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