diff --git a/src/Controls/src/Core/Slider/Slider.cs b/src/Controls/src/Core/Slider/Slider.cs index 5575636cf511..cdc8077effd2 100644 --- a/src/Controls/src/Core/Slider/Slider.cs +++ b/src/Controls/src/Core/Slider/Slider.cs @@ -10,26 +10,40 @@ namespace Microsoft.Maui.Controls [DebuggerDisplay("{GetDebuggerDisplay(), nq}")] public partial class Slider : View, ISliderController, IElementConfiguration, ISlider { + // Stores the value that was requested by the user, before clamping + double _requestedValue = 0d; + // Tracks if the user explicitly set Value (vs it being set by recoercion) + bool _userSetValue = false; + bool _isRecoercing = false; + /// Bindable property for . - public static readonly BindableProperty MinimumProperty = BindableProperty.Create(nameof(Minimum), typeof(double), typeof(Slider), 0d, coerceValue: (bindable, value) => - { - var slider = (Slider)bindable; - slider.Value = slider.Value.Clamp((double)value, slider.Maximum); - return value; - }); + public static readonly BindableProperty MinimumProperty = BindableProperty.Create( + nameof(Minimum), typeof(double), typeof(Slider), 0d, + propertyChanged: (bindable, oldValue, newValue) => + { + var slider = (Slider)bindable; + slider.RecoerceValue(); + }); /// Bindable property for . - public static readonly BindableProperty MaximumProperty = BindableProperty.Create(nameof(Maximum), typeof(double), typeof(Slider), 1d, coerceValue: (bindable, value) => - { - var slider = (Slider)bindable; - slider.Value = slider.Value.Clamp(slider.Minimum, (double)value); - return value; - }); + public static readonly BindableProperty MaximumProperty = BindableProperty.Create( + nameof(Maximum), typeof(double), typeof(Slider), 1d, + propertyChanged: (bindable, oldValue, newValue) => + { + var slider = (Slider)bindable; + slider.RecoerceValue(); + }); /// Bindable property for . public static readonly BindableProperty ValueProperty = BindableProperty.Create(nameof(Value), typeof(double), typeof(Slider), 0d, BindingMode.TwoWay, coerceValue: (bindable, value) => { var slider = (Slider)bindable; + // Only store the requested value if the user is setting it (not during recoercion) + if (!slider._isRecoercing) + { + slider._requestedValue = (double)value; + slider._userSetValue = true; + } return ((double)value).Clamp(slider.Minimum, slider.Maximum); }, propertyChanged: (bindable, oldValue, newValue) => { @@ -37,6 +51,23 @@ public partial class Slider : View, ISliderController, IElementConfigurationBindable property for . public static readonly BindableProperty MinimumTrackColorProperty = BindableProperty.Create(nameof(MinimumTrackColor), typeof(Color), typeof(Slider), null); diff --git a/src/Controls/src/Core/Stepper/Stepper.cs b/src/Controls/src/Core/Stepper/Stepper.cs index 4c649b298fc2..260f8859c235 100644 --- a/src/Controls/src/Core/Stepper/Stepper.cs +++ b/src/Controls/src/Core/Stepper/Stepper.cs @@ -10,24 +10,28 @@ namespace Microsoft.Maui.Controls [DebuggerDisplay("{GetDebuggerDisplay(), nq}")] public partial class Stepper : View, IElementConfiguration, IStepper { + // Stores the value that was requested by the user, before clamping + double _requestedValue = 0d; + // Tracks if the user explicitly set Value (vs it being set by recoercion) + bool _userSetValue = false; + bool _isRecoercing = false; + /// Bindable property for . public static readonly BindableProperty MaximumProperty = BindableProperty.Create(nameof(Maximum), typeof(double), typeof(Stepper), 100.0, validateValue: (bindable, value) => (double)value >= ((Stepper)bindable).Minimum, - coerceValue: (bindable, value) => + propertyChanged: (bindable, oldValue, newValue) => { var stepper = (Stepper)bindable; - stepper.Value = stepper.Value.Clamp(stepper.Minimum, (double)value); - return value; + stepper.RecoerceValue(); }); /// Bindable property for . public static readonly BindableProperty MinimumProperty = BindableProperty.Create(nameof(Minimum), typeof(double), typeof(Stepper), 0.0, validateValue: (bindable, value) => (double)value <= ((Stepper)bindable).Maximum, - coerceValue: (bindable, value) => + propertyChanged: (bindable, oldValue, newValue) => { var stepper = (Stepper)bindable; - stepper.Value = stepper.Value.Clamp((double)value, stepper.Maximum); - return value; + stepper.RecoerceValue(); }); /// Bindable property for . @@ -35,6 +39,12 @@ public partial class Stepper : View, IElementConfiguration, IStepper coerceValue: (bindable, value) => { var stepper = (Stepper)bindable; + // Only store the requested value if the user is setting it (not during recoercion) + if (!stepper._isRecoercing) + { + stepper._requestedValue = (double)value; + stepper._userSetValue = true; + } return Math.Round(((double)value), stepper.digits).Clamp(stepper.Minimum, stepper.Maximum); }, propertyChanged: (bindable, oldValue, newValue) => @@ -43,6 +53,23 @@ public partial class Stepper : View, IElementConfiguration, IStepper stepper.ValueChanged?.Invoke(stepper, new ValueChangedEventArgs((double)oldValue, (double)newValue)); }); + void RecoerceValue() + { + _isRecoercing = true; + try + { + // If the user explicitly set Value, try to restore the requested value within the new range + if (_userSetValue) + Value = _requestedValue; + else + Value = Value.Clamp(Minimum, Maximum); + } + finally + { + _isRecoercing = false; + } + } + int digits = 4; //'-log10(increment) + 4' as rounding digits gives us 4 significant decimal digits after the most significant one. //If your increment uses more than 4 significant digits, you're holding it wrong. diff --git a/src/Controls/tests/Core.UnitTests/SliderUnitTests.cs b/src/Controls/tests/Core.UnitTests/SliderUnitTests.cs index b3d851326857..acc382c528c0 100644 --- a/src/Controls/tests/Core.UnitTests/SliderUnitTests.cs +++ b/src/Controls/tests/Core.UnitTests/SliderUnitTests.cs @@ -19,6 +19,172 @@ public void TestConstructor() Assert.Equal(50, slider.Value); } + // Tests for setting Min, Max, Value in all 6 possible orders + // Order: Min, Max, Value + [Theory] + [InlineData(10, 100, 50)] + [InlineData(0, 1, 0.5)] + [InlineData(-100, 100, 0)] + [InlineData(50, 150, 100)] + public void SetProperties_MinMaxValue_Order(double min, double max, double value) + { + var slider = new Slider(); + slider.Minimum = min; + slider.Maximum = max; + slider.Value = value; + + Assert.Equal(min, slider.Minimum); + Assert.Equal(max, slider.Maximum); + Assert.Equal(value, slider.Value); + } + + // Order: Min, Value, Max + [Theory] + [InlineData(10, 100, 50)] + [InlineData(0, 1, 0.5)] + [InlineData(-100, 100, 0)] + [InlineData(50, 150, 100)] + public void SetProperties_MinValueMax_Order(double min, double max, double value) + { + var slider = new Slider(); + slider.Minimum = min; + slider.Value = value; + slider.Maximum = max; + + Assert.Equal(min, slider.Minimum); + Assert.Equal(max, slider.Maximum); + Assert.Equal(value, slider.Value); + } + + // Order: Max, Min, Value + [Theory] + [InlineData(10, 100, 50)] + [InlineData(0, 1, 0.5)] + [InlineData(-100, 100, 0)] + [InlineData(50, 150, 100)] + public void SetProperties_MaxMinValue_Order(double min, double max, double value) + { + var slider = new Slider(); + slider.Maximum = max; + slider.Minimum = min; + slider.Value = value; + + Assert.Equal(min, slider.Minimum); + Assert.Equal(max, slider.Maximum); + Assert.Equal(value, slider.Value); + } + + // Order: Max, Value, Min + [Theory] + [InlineData(10, 100, 50)] + [InlineData(0, 1, 0.5)] + [InlineData(-100, 100, 0)] + [InlineData(50, 150, 100)] + public void SetProperties_MaxValueMin_Order(double min, double max, double value) + { + var slider = new Slider(); + slider.Maximum = max; + slider.Value = value; + slider.Minimum = min; + + Assert.Equal(min, slider.Minimum); + Assert.Equal(max, slider.Maximum); + Assert.Equal(value, slider.Value); + } + + // Order: Value, Min, Max + [Theory] + [InlineData(10, 100, 50)] + [InlineData(0, 1, 0.5)] + [InlineData(-100, 100, 0)] + [InlineData(50, 150, 100)] + public void SetProperties_ValueMinMax_Order(double min, double max, double value) + { + var slider = new Slider(); + slider.Value = value; + slider.Minimum = min; + slider.Maximum = max; + + Assert.Equal(min, slider.Minimum); + Assert.Equal(max, slider.Maximum); + Assert.Equal(value, slider.Value); + } + + // Order: Value, Max, Min + [Theory] + [InlineData(10, 100, 50)] + [InlineData(0, 1, 0.5)] + [InlineData(-100, 100, 0)] + [InlineData(50, 150, 100)] + public void SetProperties_ValueMaxMin_Order(double min, double max, double value) + { + var slider = new Slider(); + slider.Value = value; + slider.Maximum = max; + slider.Minimum = min; + + Assert.Equal(min, slider.Minimum); + Assert.Equal(max, slider.Maximum); + Assert.Equal(value, slider.Value); + } + + // Tests that _requestedValue is preserved across multiple recoercions + [Fact] + public void RequestedValuePreservedAcrossMultipleRangeChanges() + { + var slider = new Slider(); + slider.Value = 50; + slider.Minimum = -10; + slider.Maximum = -1; // Value clamped to -1 + + Assert.Equal(-1, slider.Value); + + slider.Maximum = -2; // Value should still be clamped, not corrupted + + Assert.Equal(-2, slider.Value); + + slider.Maximum = 100; // Now the original requested value (50) should be restored + + Assert.Equal(50, slider.Value); + } + + [Fact] + public void RequestedValuePreservedWhenMinimumChangesMultipleTimes() + { + var slider = new Slider(); + slider.Value = 5; + slider.Maximum = 100; + slider.Minimum = 10; // Value clamped to 10 + + Assert.Equal(10, slider.Value); + + slider.Minimum = 20; // Value clamped to 20 + + Assert.Equal(20, slider.Value); + + slider.Minimum = 0; // Original requested value (5) should be restored + + Assert.Equal(5, slider.Value); + } + + [Fact] + public void ValueClampedWhenOnlyRangeChanges() + { + var slider = new Slider(); // Value defaults to 0 + slider.Minimum = 10; // Value should clamp to 10 + slider.Maximum = 100; + + Assert.Equal(10, slider.Value); + + slider.Minimum = 5; // Value stays at 10 because 10 is within [5, 100] + + Assert.Equal(10, slider.Value); + + slider.Minimum = 15; // Value clamps to 15 + + Assert.Equal(15, slider.Value); + } + [Fact] public void TestInvalidConstructor() { diff --git a/src/Controls/tests/Core.UnitTests/StepperUnitTests.cs b/src/Controls/tests/Core.UnitTests/StepperUnitTests.cs index a96724da4a48..db90ca36435a 100644 --- a/src/Controls/tests/Core.UnitTests/StepperUnitTests.cs +++ b/src/Controls/tests/Core.UnitTests/StepperUnitTests.cs @@ -87,7 +87,6 @@ public void TestMinClampValue() minThrown = true; break; case "Value": - Assert.False(minThrown); valThrown = true; break; } @@ -119,7 +118,6 @@ public void TestMaxClampValue() maxThrown = true; break; case "Value": - Assert.False(maxThrown); valThrown = true; break; } @@ -248,5 +246,170 @@ public void InitialValue() stepper.Value += stepper.Increment; Assert.Equal(5.39, stepper.Value); } + + // Tests for setting Min, Max, Value in all 6 possible orders + // Order: Min, Max, Value + [Theory] + [InlineData(10, 100, 50)] + [InlineData(0, 50, 25)] + [InlineData(-100, 100, 0)] + [InlineData(50, 150, 100)] + public void SetProperties_MinMaxValue_Order(double min, double max, double value) + { + var stepper = new Stepper(); + stepper.Minimum = min; + stepper.Maximum = max; + stepper.Value = value; + + Assert.Equal(min, stepper.Minimum); + Assert.Equal(max, stepper.Maximum); + Assert.Equal(value, stepper.Value); + } + + // Order: Min, Value, Max + [Theory] + [InlineData(10, 200, 50)] + [InlineData(0, 50, 25)] + [InlineData(-100, 100, 0)] + [InlineData(50, 150, 100)] + public void SetProperties_MinValueMax_Order(double min, double max, double value) + { + var stepper = new Stepper(); + stepper.Minimum = min; + stepper.Value = value; + stepper.Maximum = max; + + Assert.Equal(min, stepper.Minimum); + Assert.Equal(max, stepper.Maximum); + Assert.Equal(value, stepper.Value); + } + + // Order: Max, Min, Value + [Theory] + [InlineData(10, 200, 50)] + [InlineData(0, 50, 25)] + [InlineData(-100, 100, 0)] + [InlineData(50, 150, 100)] + public void SetProperties_MaxMinValue_Order(double min, double max, double value) + { + var stepper = new Stepper(); + stepper.Maximum = max; + stepper.Minimum = min; + stepper.Value = value; + + Assert.Equal(min, stepper.Minimum); + Assert.Equal(max, stepper.Maximum); + Assert.Equal(value, stepper.Value); + } + + // Order: Max, Value, Min + [Theory] + [InlineData(10, 200, 50)] + [InlineData(0, 50, 25)] + [InlineData(-100, 100, 0)] + [InlineData(50, 150, 100)] + public void SetProperties_MaxValueMin_Order(double min, double max, double value) + { + var stepper = new Stepper(); + stepper.Maximum = max; + stepper.Value = value; + stepper.Minimum = min; + + Assert.Equal(min, stepper.Minimum); + Assert.Equal(max, stepper.Maximum); + Assert.Equal(value, stepper.Value); + } + + // Order: Value, Min, Max + [Theory] + [InlineData(10, 200, 50)] + [InlineData(0, 50, 25)] + [InlineData(-100, 100, 0)] + [InlineData(50, 150, 100)] + public void SetProperties_ValueMinMax_Order(double min, double max, double value) + { + var stepper = new Stepper(); + stepper.Value = value; + stepper.Minimum = min; + stepper.Maximum = max; + + Assert.Equal(min, stepper.Minimum); + Assert.Equal(max, stepper.Maximum); + Assert.Equal(value, stepper.Value); + } + + // Order: Value, Max, Min + [Theory] + [InlineData(10, 200, 50)] + [InlineData(0, 50, 25)] + [InlineData(-100, 100, 0)] + [InlineData(50, 150, 100)] + public void SetProperties_ValueMaxMin_Order(double min, double max, double value) + { + var stepper = new Stepper(); + stepper.Value = value; + stepper.Maximum = max; + stepper.Minimum = min; + + Assert.Equal(min, stepper.Minimum); + Assert.Equal(max, stepper.Maximum); + Assert.Equal(value, stepper.Value); + } + + // Tests that _requestedValue is preserved across multiple recoercions + [Fact] + public void RequestedValuePreservedAcrossMultipleRangeChanges() + { + var stepper = new Stepper(); + stepper.Value = 50; + stepper.Minimum = -10; + stepper.Maximum = -1; // Value clamped to -1 + + Assert.Equal(-1, stepper.Value); + + stepper.Maximum = -2; // Value should still be clamped, not corrupted + + Assert.Equal(-2, stepper.Value); + + stepper.Maximum = 100; // Now the original requested value (50) should be restored + + Assert.Equal(50, stepper.Value); + } + + [Fact] + public void RequestedValuePreservedWhenMinimumChangesMultipleTimes() + { + var stepper = new Stepper(); + stepper.Value = 5; + stepper.Maximum = 100; + stepper.Minimum = 10; // Value clamped to 10 + + Assert.Equal(10, stepper.Value); + + stepper.Minimum = 20; // Value clamped to 20 + + Assert.Equal(20, stepper.Value); + + stepper.Minimum = 0; // Original requested value (5) should be restored + + Assert.Equal(5, stepper.Value); + } + + [Fact] + public void ValueClampedWhenOnlyRangeChanges() + { + var stepper = new Stepper(); // Value defaults to 0 + stepper.Minimum = 10; // Value should clamp to 10 + + Assert.Equal(10, stepper.Value); + + stepper.Minimum = 5; // Value stays at 10 because 10 is within [5, 100] + + Assert.Equal(10, stepper.Value); + + stepper.Minimum = 15; // Value clamps to 15 + + Assert.Equal(15, stepper.Value); + } } }