From c45c527b0dd360302f56032862aadc03e4f966d1 Mon Sep 17 00:00:00 2001 From: Stephane Delcroix Date: Mon, 1 Dec 2025 15:23:41 +0100 Subject: [PATCH 1/3] Fix Slider and Stepper property order independence This change ensures that the Value property of Slider and Stepper controls is correctly preserved regardless of the order in which Minimum, Maximum, and Value properties are set (either programmatically or via XAML bindings). The fix introduces: - _requestedValue: stores the user's intended value before clamping - _userSetValue: tracks if the user explicitly set Value - _isRecoercing: prevents _requestedValue corruption during recoercion - RecoerceValue(): restores _requestedValue when range expands When Min/Max changes, if the user explicitly set Value, the original requested value is restored (clamped to the new range). This allows Value to 'spring back' when the range expands to include it. Fixes #32903 Fixes #14472 Fixes #18910 Fixes #12243 --- src/Controls/src/Core/Slider/Slider.cs | 55 ++++-- src/Controls/src/Core/Stepper/Stepper.cs | 39 +++- .../tests/Core.UnitTests/SliderUnitTests.cs | 166 +++++++++++++++++ .../tests/Core.UnitTests/StepperUnitTests.cs | 167 +++++++++++++++++- 4 files changed, 407 insertions(+), 20 deletions(-) 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); + } } } From 32761bb45925100aae1c1967314a231093abc930 Mon Sep 17 00:00:00 2001 From: Jakub Florkowski Date: Mon, 1 Dec 2025 17:06:16 +0100 Subject: [PATCH 2/3] Add Sandbox test scenario for PR #32939 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test scenario validates the Slider/Stepper property order independence fix. Test coverage: - XAML binding with Min=10, Max=100, Value=50 (Issue #32903 reproduction) - Programmatic property order tests (all 6 permutations) - Dynamic range changes (value preservation) All tests validated on Android with Appium automation. Test results: ALL PASSED - Initial binding: Slider=50, Stepper=50 ✓ - All property orders: Value=50 ✓ - Dynamic range: Value restored to 50 after expansion ✓ Related: #32903, #14472, #18910, #12243 --- .../Controls.Sample.Sandbox/MainPage.xaml | 73 ++++++++ .../Controls.Sample.Sandbox/MainPage.xaml.cs | 157 +++++++++++++++++- 2 files changed, 228 insertions(+), 2 deletions(-) diff --git a/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml b/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml index 7363d18deadd..bf0599613728 100644 --- a/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml +++ b/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml @@ -2,4 +2,77 @@ xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" x:Class="Maui.Controls.Sample.MainPage" xmlns:local="clr-namespace:Maui.Controls.Sample"> + + + + + + + + + + + + + + +