-
Notifications
You must be signed in to change notification settings - Fork 1.9k
[C] Fix Slider and Stepper property order independence #32939
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,33 +10,64 @@ namespace Microsoft.Maui.Controls | |
| [DebuggerDisplay("{GetDebuggerDisplay(), nq}")] | ||
| public partial class Slider : View, ISliderController, IElementConfiguration<Slider>, 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; | ||
|
|
||
| /// <summary>Bindable property for <see cref="Minimum"/>.</summary> | ||
| 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(); | ||
| }); | ||
|
|
||
| /// <summary>Bindable property for <see cref="Maximum"/>.</summary> | ||
| 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(); | ||
| }); | ||
|
|
||
| /// <summary>Bindable property for <see cref="Value"/>.</summary> | ||
| 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; | ||
| } | ||
|
Comment on lines
+41
to
+46
|
||
| return ((double)value).Clamp(slider.Minimum, slider.Maximum); | ||
| }, propertyChanged: (bindable, oldValue, newValue) => | ||
| { | ||
| var slider = (Slider)bindable; | ||
| slider.ValueChanged?.Invoke(slider, new ValueChangedEventArgs((double)oldValue, (double)newValue)); | ||
| }); | ||
|
Comment on lines
38
to
52
|
||
|
|
||
| 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; | ||
| } | ||
| } | ||
|
Comment on lines
+54
to
+69
|
||
|
|
||
| /// <summary>Bindable property for <see cref="MinimumTrackColor"/>.</summary> | ||
| public static readonly BindableProperty MinimumTrackColorProperty = BindableProperty.Create(nameof(MinimumTrackColor), typeof(Color), typeof(Slider), null); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -10,31 +10,41 @@ namespace Microsoft.Maui.Controls | |||||||||||||||||||||||||||||||
| [DebuggerDisplay("{GetDebuggerDisplay(), nq}")] | ||||||||||||||||||||||||||||||||
| public partial class Stepper : View, IElementConfiguration<Stepper>, 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; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /// <summary>Bindable property for <see cref="Maximum"/>.</summary> | ||||||||||||||||||||||||||||||||
| 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(); | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /// <summary>Bindable property for <see cref="Minimum"/>.</summary> | ||||||||||||||||||||||||||||||||
| 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(); | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /// <summary>Bindable property for <see cref="Value"/>.</summary> | ||||||||||||||||||||||||||||||||
| public static readonly BindableProperty ValueProperty = BindableProperty.Create(nameof(Value), typeof(double), typeof(Stepper), 0.0, BindingMode.TwoWay, | ||||||||||||||||||||||||||||||||
| 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; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
Comment on lines
+42
to
+47
|
||||||||||||||||||||||||||||||||
| return Math.Round(((double)value), stepper.digits).Clamp(stepper.Minimum, stepper.Maximum); | ||||||||||||||||||||||||||||||||
|
Comment on lines
+42
to
48
|
||||||||||||||||||||||||||||||||
| // 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); | |
| double roundedValue = Math.Round((double)value, stepper.digits); | |
| // Only store the requested value if the user is setting it (not during recoercion) | |
| if (!stepper._isRecoercing) | |
| { | |
| stepper._requestedValue = roundedValue; | |
| stepper._userSetValue = true; | |
| } | |
| return roundedValue.Clamp(stepper.Minimum, stepper.Maximum); |
Copilot
AI
Dec 1, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Potential concurrency issue: The _isRecoercing flag is not thread-safe. If two threads simultaneously call property setters (e.g., one sets Minimum while another sets Maximum), both could enter RecoerceValue() and potentially corrupt the _requestedValue or create race conditions with the _isRecoercing flag.
While MAUI controls are typically used on the UI thread, the BindableProperty system doesn't inherently enforce this, and users might update properties from background threads (especially during MVVM binding updates).
Consider:
- Adding thread-safety using locks or Interlocked operations
- Documenting that these properties must only be set from the UI thread
- Or accepting the risk if cross-thread access is not a supported scenario for these controls
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
| } | ||
|
Comment on lines
+170
to
+186
|
||
|
|
||
| [Fact] | ||
| public void TestInvalidConstructor() | ||
| { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The
_requestedValuefield is initialized to0d, which matches the default value for the Value property. However, this creates ambiguity: when_userSetValueis false, it's unclear whether the user never set a value, or whether they explicitly set it to 0.Consider initializing
_requestedValuetodouble.NaNor a nullabledouble?to distinguish between "never set" and "set to 0". This would make the logic more robust and prevent potential issues where a user sets Value to 0, then changes the range, and the value doesn't restore properly.