diff --git a/samples/CommunityToolkit.Maui.Sample/AppShell.xaml.cs b/samples/CommunityToolkit.Maui.Sample/AppShell.xaml.cs index 12721c1b46..5f1022318e 100644 --- a/samples/CommunityToolkit.Maui.Sample/AppShell.xaml.cs +++ b/samples/CommunityToolkit.Maui.Sample/AppShell.xaml.cs @@ -135,6 +135,7 @@ public partial class AppShell : Shell CreateViewModelMapping(), CreateViewModelMapping(), CreateViewModelMapping(), + CreateViewModelMapping(), CreateViewModelMapping(), CreateViewModelMapping(), CreateViewModelMapping(), diff --git a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs index 8a2fa51450..d195e7ba02 100644 --- a/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs +++ b/samples/CommunityToolkit.Maui.Sample/MauiProgram.cs @@ -258,6 +258,7 @@ static void RegisterViewsAndViewModels(in IServiceCollection services) services.AddTransientWithShellRoute(); services.AddTransientWithShellRoute(); services.AddTransientWithShellRoute(); + services.AddTransientWithShellRoute(); services.AddTransientWithShellRoute(); services.AddTransientWithShellRoute(); services.AddTransientWithShellRoute(); diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Views/RangeSlider/RangeSliderPage.xaml b/samples/CommunityToolkit.Maui.Sample/Pages/Views/RangeSlider/RangeSliderPage.xaml new file mode 100644 index 0000000000..9e15e13d2b --- /dev/null +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/RangeSlider/RangeSliderPage.xaml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + diff --git a/samples/CommunityToolkit.Maui.Sample/Pages/Views/RangeSlider/RangeSliderPage.xaml.cs b/samples/CommunityToolkit.Maui.Sample/Pages/Views/RangeSlider/RangeSliderPage.xaml.cs new file mode 100644 index 0000000000..a022b7ea15 --- /dev/null +++ b/samples/CommunityToolkit.Maui.Sample/Pages/Views/RangeSlider/RangeSliderPage.xaml.cs @@ -0,0 +1,11 @@ +using CommunityToolkit.Maui.Sample.ViewModels.Views; + +namespace CommunityToolkit.Maui.Sample.Pages.Views; + +public partial class RangeSliderPage : BasePage +{ + public RangeSliderPage(RangeSliderViewModel viewModel) : base(viewModel) + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/RangeSliderViewModel.cs b/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/RangeSliderViewModel.cs new file mode 100644 index 0000000000..e7767ead09 --- /dev/null +++ b/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/RangeSliderViewModel.cs @@ -0,0 +1,36 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace CommunityToolkit.Maui.Sample.ViewModels.Views; + +public partial class RangeSliderViewModel : BaseViewModel +{ + [ObservableProperty] + public partial double SimpleLowerValue { get; set; } = 125; + + [ObservableProperty] + public partial double SimpleUpperValue { get; set; } = 175; + + [ObservableProperty] + public partial double LowerValueWithCustomStyle { get; set; } = 125; + + [ObservableProperty] + public partial double UpperValueWithCustomStyle { get; set; } = 175; + + [ObservableProperty] + public partial double LowerValueWithStep { get; set; } = 125; + + [ObservableProperty] + public partial double UpperValueWithStep { get; set; } = 175; + + [ObservableProperty] + public partial double LowerValueDescending { get; set; } = 175; + + [ObservableProperty] + public partial double UpperValueDescending { get; set; } = 125; + + [ObservableProperty] + public partial double LowerValueDescendingWithStep { get; set; } = 175; + + [ObservableProperty] + public partial double UpperValueDescendingWithStep { get; set; } = 125; +} \ No newline at end of file diff --git a/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/ViewsGalleryViewModel.cs b/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/ViewsGalleryViewModel.cs index ea038112a0..e02cb17fe0 100644 --- a/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/ViewsGalleryViewModel.cs +++ b/samples/CommunityToolkit.Maui.Sample/ViewModels/Views/ViewsGalleryViewModel.cs @@ -25,6 +25,7 @@ public sealed partial class ViewsGalleryViewModel() : BaseGalleryViewModel( SectionModel.Create("MediaElement in CarouselView", Colors.Red, "MediaElement can be used inside a DataTemplate in a CarouselView"), SectionModel.Create("MediaElement in CollectionView", Colors.Red, "MediaElement can be used inside a DataTemplate in a CollectionView"), SectionModel.Create("MediaElement in a Multi-Window Application", Colors.Red, "Demonstrates that MediaElement can be used inside a DataTemplate simultaneously on multiple windows"), + SectionModel.Create("RangeSlider Page", Colors.Red, "A page demonstrating RangeSlider in various styles."), SectionModel.Create("RatingView Showcase Page", Colors.Red, "A page with showcase examples for the RatingView control."), SectionModel.Create("RatingView XAML Page", Colors.Red, "A page demonstrating the RatingView control and possible uses using XAML"), SectionModel.Create("RatingView C# Page", Colors.Red, "A page demonstrating the RatingView control and possible uses using C#"), diff --git a/src/CommunityToolkit.Maui.Core/Primitives/Defaults/RangeSliderDefaults.shared.cs b/src/CommunityToolkit.Maui.Core/Primitives/Defaults/RangeSliderDefaults.shared.cs new file mode 100644 index 0000000000..0f63705543 --- /dev/null +++ b/src/CommunityToolkit.Maui.Core/Primitives/Defaults/RangeSliderDefaults.shared.cs @@ -0,0 +1,82 @@ +namespace CommunityToolkit.Maui.Core; + +/// +/// Default Values for RangeSlider +/// +public static class RangeSliderDefaults +{ + /// + /// Default width request + /// + public const double WidthRequest = 200; + + /// + /// Default for Minimum Value + /// + public const double MinimumValue = 0; + + /// + /// Default for Maximum Value + /// + public const double MaximumValue = 1; + + /// + /// Default for Lower Value + /// + public const double LowerValue = 0; + + /// + /// Default for Upper Value + /// + public const double UpperValue = 1; + + /// + /// Default for Step Size + /// + public const double StepSize = 0; + + /// + /// Default value for Lower Thumb Color + /// + public static Color LowerThumbColor { get; } = Colors.Gray; + + /// + /// Default value for Upper Thumb Color + /// + public static Color UpperThumbColor { get; } = Colors.Gray; + + /// + /// Default value for Track Color + /// + public static Color InnerTrackColor { get; } = Colors.Pink; + + /// + /// Default value for Track Size + /// + public const double InnerTrackSize = 6; + + /// + /// Default value for Inner Track Radius + /// + public static CornerRadius InnerTrackCornerRadius { get; } = new(3); + + /// + /// Default value for Outer Track Color + /// + public static Color OuterTrackColor { get; } = Colors.LightGray; + + /// + /// Default value for Outer Track Size + /// + public const double OuterTrackSize = 6; + + /// + /// Default value for Outer Track Radius + /// + public static CornerRadius OuterTrackCornerRadius { get; } = new(3); + + /// + /// Default value for Focus Mode + /// + public const RangeSliderFocusMode FocusMode = RangeSliderFocusMode.Default; +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.Core/Primitives/RangeSliderFocusMode.cs b/src/CommunityToolkit.Maui.Core/Primitives/RangeSliderFocusMode.cs new file mode 100644 index 0000000000..4a57b56e28 --- /dev/null +++ b/src/CommunityToolkit.Maui.Core/Primitives/RangeSliderFocusMode.cs @@ -0,0 +1,20 @@ +namespace CommunityToolkit.Maui.Core; + +/// +/// Enum for RangeSlider focus mode +/// +public enum RangeSliderFocusMode +{ + /// + /// Balance the focus between the lower and upper thumbs + /// + Default, + /// + /// The lower thumb has focus + /// + Lower, + /// + /// The upper thumb has focus + /// + Upper, +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/RangeSlider/RangeSliderTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/RangeSlider/RangeSliderTests.cs new file mode 100644 index 0000000000..076317681a --- /dev/null +++ b/src/CommunityToolkit.Maui.UnitTests/Views/RangeSlider/RangeSliderTests.cs @@ -0,0 +1,92 @@ +using CommunityToolkit.Maui.Core; +using CommunityToolkit.Maui.Views; +using FluentAssertions; +using Xunit; + +namespace CommunityToolkit.Maui.UnitTests.Views; + +public class RangeSliderTests : BaseViewTest +{ + [Fact] + public void Defaults_ShouldBeCorrect() + { + RangeSlider rangeSlider = new(); + rangeSlider.MinimumValue.Should().Be(RangeSliderDefaults.MinimumValue); + rangeSlider.MaximumValue.Should().Be(RangeSliderDefaults.MaximumValue); + rangeSlider.LowerValue.Should().Be(RangeSliderDefaults.LowerValue); + rangeSlider.UpperValue.Should().Be(RangeSliderDefaults.UpperValue); + rangeSlider.StepSize.Should().Be(RangeSliderDefaults.StepSize); + rangeSlider.LowerThumbColor.Should().Be(RangeSliderDefaults.LowerThumbColor); + rangeSlider.UpperThumbColor.Should().Be(RangeSliderDefaults.UpperThumbColor); + rangeSlider.InnerTrackColor.Should().Be(RangeSliderDefaults.InnerTrackColor); + rangeSlider.InnerTrackSize.Should().Be(RangeSliderDefaults.InnerTrackSize); + rangeSlider.InnerTrackCornerRadius.Should().Be(RangeSliderDefaults.InnerTrackCornerRadius); + rangeSlider.OuterTrackColor.Should().Be(RangeSliderDefaults.OuterTrackColor); + rangeSlider.OuterTrackSize.Should().Be(RangeSliderDefaults.OuterTrackSize); + rangeSlider.OuterTrackCornerRadius.Should().Be(RangeSliderDefaults.OuterTrackCornerRadius); + } + + [Theory] + [InlineData(-50, 50)] + [InlineData(50, -50)] + [InlineData(0, 100)] + [InlineData(100, 0)] + [InlineData(100, 200)] + [InlineData(200, 100)] + public void MinimumMaximumValues_ShouldStickToContract(double min, double max) + { + RangeSlider rangeSlider = new(true) + { + MinimumValue = min, + MaximumValue = max, + }; + rangeSlider.IsClampingEnabled.Should().BeTrue(); + rangeSlider.MinimumValue.Should().Be(min); + rangeSlider.MaximumValue.Should().Be(max); + } + + [Theory] + [InlineData(0, 100, 70, 30, 30)] + [InlineData(0, 100, 70, 80, 70)] + [InlineData(0, 100, 170, 80, 80)] + [InlineData(0, 100, 170, 180, 100)] + public void LowerValue_ShouldBeClampedToBounds(double min, double max, double upper, double lower, double expectedLower) + { + RangeSlider rangeSlider = new(true) + { + MinimumValue = min, + MaximumValue = max, + UpperValue = max, + LowerValue = min, + }; + rangeSlider.IsClampingEnabled.Should().BeTrue(); + rangeSlider.UpperValue = upper; + rangeSlider.LowerValue = lower; + rangeSlider.MinimumValue.Should().Be(min); + rangeSlider.MaximumValue.Should().Be(max); + rangeSlider.UpperValue.Should().Be(Math.Min(upper, max)); + rangeSlider.LowerValue.Should().Be(expectedLower); + } + + [Theory] + [InlineData(0, 100, 30, 70, 70)] + [InlineData(0, 100, 30, 0, 30)] + [InlineData(0, 100, 30, 170, 100)] + public void UpperValue_ShouldBeClampedToBounds(double min, double max, double lower, double upper, double expectedUpper) + { + RangeSlider rangeSlider = new(true) + { + MinimumValue = min, + MaximumValue = max, + LowerValue = min, + UpperValue = max, + }; + rangeSlider.IsClampingEnabled.Should().BeTrue(); + rangeSlider.LowerValue = lower; + rangeSlider.UpperValue = upper; + rangeSlider.MinimumValue.Should().Be(min); + rangeSlider.MaximumValue.Should().Be(max); + rangeSlider.LowerValue.Should().Be(Math.Max(lower, min)); + rangeSlider.UpperValue.Should().Be(expectedUpper); + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui/Extensions/PropertyChangedEventArgsExtensions.shared.cs b/src/CommunityToolkit.Maui/Extensions/PropertyChangedEventArgsExtensions.shared.cs index 4d4929512b..47f29e2e19 100644 --- a/src/CommunityToolkit.Maui/Extensions/PropertyChangedEventArgsExtensions.shared.cs +++ b/src/CommunityToolkit.Maui/Extensions/PropertyChangedEventArgsExtensions.shared.cs @@ -43,4 +43,15 @@ public static bool IsOneOf(this string propertyName, BindableProperty p0, Bindab || propertyName == p3.PropertyName || propertyName == p4.PropertyName; } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsOneOf(this string propertyName, BindableProperty p0, BindableProperty p1, BindableProperty p2, BindableProperty p3, BindableProperty p4, BindableProperty p5) + { + return propertyName == p0.PropertyName + || propertyName == p1.PropertyName + || propertyName == p2.PropertyName + || propertyName == p3.PropertyName + || propertyName == p4.PropertyName + || propertyName == p5.PropertyName; + } } \ No newline at end of file diff --git a/src/CommunityToolkit.Maui/Views/RangeSlider/RangeSlider.shared.cs b/src/CommunityToolkit.Maui/Views/RangeSlider/RangeSlider.shared.cs new file mode 100644 index 0000000000..bf49e1bac0 --- /dev/null +++ b/src/CommunityToolkit.Maui/Views/RangeSlider/RangeSlider.shared.cs @@ -0,0 +1,453 @@ +using System.ComponentModel; +using CommunityToolkit.Maui.Core; +using CommunityToolkit.Maui.Extensions; +using Microsoft.Maui.Controls.Shapes; + +namespace CommunityToolkit.Maui.Views; + +/// +/// RangeSlider control +/// +public partial class RangeSlider : ContentView +{ + /// + /// Gets or sets the minimum value + /// + [BindableProperty] + public partial double MinimumValue { get; set; } = RangeSliderDefaults.MinimumValue; + + /// + /// Gets or sets the maximum value + /// + [BindableProperty] + public partial double MaximumValue { get; set; } = RangeSliderDefaults.MaximumValue; + + /// + /// Gets or sets the lower value + /// + [BindableProperty(CoerceValueMethodName = nameof(CoerceLowerValue))] + public partial double LowerValue { get; set; } = RangeSliderDefaults.LowerValue; + + /// + /// Gets or sets the upper value + /// + [BindableProperty(CoerceValueMethodName = nameof(CoerceUpperValue))] + public partial double UpperValue { get; set; } = RangeSliderDefaults.UpperValue; + + /// + /// Gets or sets the step size + /// + [BindableProperty] + public partial double StepSize { get; set; } = RangeSliderDefaults.StepSize; + + /// + /// Gets or sets the lower thumb color + /// + [BindableProperty] + public partial Color LowerThumbColor { get; set; } = RangeSliderDefaults.LowerThumbColor; + + /// + /// Gets or sets the upper thumb color + /// + [BindableProperty] + public partial Color UpperThumbColor { get; set; } = RangeSliderDefaults.UpperThumbColor; + + /// + /// Gets or sets the inner track color + /// + [BindableProperty] + public partial Color InnerTrackColor { get; set; } = RangeSliderDefaults.InnerTrackColor; + + /// + /// Gets or sets the inner track size + /// + [BindableProperty] + public partial double InnerTrackSize { get; set; } = RangeSliderDefaults.InnerTrackSize; + + /// + /// Gets or sets the inner track corner radius + /// + [BindableProperty] + public partial CornerRadius InnerTrackCornerRadius { get; set; } = RangeSliderDefaults.InnerTrackCornerRadius; + + /// + /// Gets or sets the outer track color + /// + [BindableProperty] + public partial Color OuterTrackColor { get; set; } = RangeSliderDefaults.OuterTrackColor; + + /// + /// Gets or sets the outer track size + /// + [BindableProperty] + public partial double OuterTrackSize { get; set; } = RangeSliderDefaults.OuterTrackSize; + + /// + /// Gets or sets the outer track corner radius + /// + [BindableProperty] + public partial CornerRadius OuterTrackCornerRadius { get; set; } = RangeSliderDefaults.OuterTrackCornerRadius; + + internal static readonly BindablePropertyKey FocusModePropertyKey = BindableProperty.CreateReadOnly(nameof(FocusMode), typeof(RangeSliderFocusMode), typeof(RangeSlider), RangeSliderDefaults.FocusMode); + + /// + /// This is a read-only property that represents the focus state of the slider thumbs. + /// + public static readonly BindableProperty FocusModeProperty = FocusModePropertyKey.BindableProperty; + + /// + /// Gets the current focus state of the slider thumbs. + /// + public RangeSliderFocusMode FocusMode => (RangeSliderFocusMode)GetValue(FocusModeProperty); + + /// + /// Gets the platform-specific thumb size measured from the underlying .NET MAUI Slider thumb. + /// It was observed that the thumb size differs on Windows compared to other platforms. + /// +#if WINDOWS + public const double PlatformThumbSize = 17.5; +#else + public const double PlatformThumbSize = 31.5; +#endif + + readonly RoundRectangle outerTrack = new() + { + HorizontalOptions = LayoutOptions.Start, + VerticalOptions = LayoutOptions.Center, + }; + + readonly RoundRectangle innerTrack = new() + { + HorizontalOptions = LayoutOptions.Start, + VerticalOptions = LayoutOptions.Center, + }; + + readonly Slider lowerSlider = new() + { + HorizontalOptions = LayoutOptions.Start, + VerticalOptions = LayoutOptions.Center, + BackgroundColor = Colors.Transparent, + MinimumTrackColor = Colors.Transparent, + MaximumTrackColor = Colors.Transparent, + }; + + readonly Slider upperSlider = new() + { + HorizontalOptions = LayoutOptions.End, + VerticalOptions = LayoutOptions.Center, + BackgroundColor = Colors.Transparent, + MinimumTrackColor = Colors.Transparent, + MaximumTrackColor = Colors.Transparent, + }; + + /// + /// Internal property used to defer clamping until initialization completes so all bindable properties, bindings, and default values settle before coercion runs. + /// + internal bool IsClampingEnabled { get; set; } + + /// + /// Initializes a new instance of the class + /// + public RangeSlider() + { + PropertyChanged += HandlePropertyChanged; + + WidthRequest = RangeSliderDefaults.WidthRequest; + outerTrack.SetBinding(RoundRectangle.HeightRequestProperty, BindingBase.Create(static p => p.OuterTrackSize, BindingMode.OneWay, source: this)); + outerTrack.SetBinding(RoundRectangle.BackgroundColorProperty, BindingBase.Create(static p => p.OuterTrackColor, BindingMode.OneWay, source: this)); + outerTrack.SetBinding(RoundRectangle.CornerRadiusProperty, BindingBase.Create(static p => p.OuterTrackCornerRadius, BindingMode.OneWay, source: this)); + innerTrack.SetBinding(RoundRectangle.HeightRequestProperty, BindingBase.Create(static p => p.InnerTrackSize, BindingMode.OneWay, source: this)); + innerTrack.SetBinding(RoundRectangle.BackgroundColorProperty, BindingBase.Create(static p => p.InnerTrackColor, BindingMode.OneWay, source: this)); + innerTrack.SetBinding(RoundRectangle.CornerRadiusProperty, BindingBase.Create(static p => p.InnerTrackCornerRadius, BindingMode.OneWay, source: this)); + lowerSlider.SetBinding(Slider.ThumbColorProperty, BindingBase.Create(static p => p.LowerThumbColor, BindingMode.OneWay, source: this)); + upperSlider.SetBinding(Slider.ThumbColorProperty, BindingBase.Create(static p => p.UpperThumbColor, BindingMode.OneWay, source: this)); + + Content = new Grid + { + Children = + { + outerTrack, + innerTrack, + lowerSlider, + upperSlider, + }, + }; + + Loaded += FinalizeInitialization; + } + + /// + /// Initializes a new instance of the RangeSlider class for unit testing purposes. + /// + /// The value is irrelevant - passing this parameter enables the class for unit testing scenarios. + internal RangeSlider(bool unitTest) : this() + { + FinalizeInitialization(this, EventArgs.Empty); + } + + /// + /// Enables clamping and finalizes setup after all bindable properties, bindings, and defaults have been initialized. + /// Once initialization completes, coerces and into valid, clamped ranges, + /// wires up event handlers, and updates slider ranges, values, focus state, and track layouts. + /// + void FinalizeInitialization(object? sender, EventArgs e) + { + IsClampingEnabled = true; + CoerceValue(LowerValueProperty); + CoerceValue(UpperValueProperty); + lowerSlider.DragStarted += HandleLowerSliderDragStarted; + lowerSlider.DragCompleted += HandleLowerSliderDragCompleted; + lowerSlider.PropertyChanged += HandleLowerSliderPropertyChanged; + lowerSlider.Focused += HandleLowerSliderFocused; + upperSlider.DragStarted += HandleUpperSliderDragStarted; + upperSlider.DragCompleted += HandleUpperSliderDragCompleted; + upperSlider.PropertyChanged += HandleUpperSliderPropertyChanged; + upperSlider.Focused += HandleUpperSliderFocused; + UpdateSliderRanges(); + UpdateLowerSliderValue(); + UpdateUpperSliderValue(); + UpdateFocusedSliderLayout(); + UpdateOuterTrackLayout(); + UpdateInnerTrackLayout(); + } + + /// + /// This method changes the value of the property. + /// + /// The new focus mode + protected void SetFocusMode(RangeSliderFocusMode focusMode) => SetValue(FocusModePropertyKey, focusMode); + + static object CoerceLowerValue(BindableObject bindable, object value) + { + var rangeSlider = (RangeSlider)bindable; + var input = (double)value; + + if (rangeSlider.IsClampingEnabled) + { + if (rangeSlider.MinimumValue <= rangeSlider.MaximumValue) + { + input = Math.Min(Math.Clamp(input, rangeSlider.MinimumValue, rangeSlider.MaximumValue), rangeSlider.UpperValue); + } + else + { + input = Math.Max(Math.Clamp(input, rangeSlider.MaximumValue, rangeSlider.MinimumValue), rangeSlider.UpperValue); + } + } + + return input; + } + + static object CoerceUpperValue(BindableObject bindable, object value) + { + var rangeSlider = (RangeSlider)bindable; + var input = (double)value; + + if (rangeSlider.IsClampingEnabled) + { + if (rangeSlider.MinimumValue <= rangeSlider.MaximumValue) + { + input = Math.Max(Math.Clamp(input, rangeSlider.MinimumValue, rangeSlider.MaximumValue), rangeSlider.LowerValue); + } + else + { + input = Math.Min(Math.Clamp(input, rangeSlider.MaximumValue, rangeSlider.MinimumValue), rangeSlider.LowerValue); + } + } + + return input; + } + + void HandlePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (string.IsNullOrWhiteSpace(e.PropertyName)) + { + return; + } + + if (e.PropertyName.IsOneOf(MinimumValueProperty, MaximumValueProperty, StepSizeProperty)) + { + UpdateSliderRanges(); + } + + if (e.PropertyName.IsOneOf(WidthProperty, MinimumValueProperty, MaximumValueProperty, StepSizeProperty)) + { + UpdateFocusedSliderLayout(); + } + + if (e.PropertyName.Is(LowerValueProperty)) + { + UpdateLowerSliderValue(); + } + + if (e.PropertyName.Is(UpperValueProperty)) + { + UpdateUpperSliderValue(); + } + + if (e.PropertyName.IsOneOf(WidthProperty, MinimumValueProperty, MaximumValueProperty, OuterTrackSizeProperty)) + { + UpdateOuterTrackLayout(); + } + + if (e.PropertyName.IsOneOf(WidthProperty, MinimumValueProperty, MaximumValueProperty, LowerValueProperty, UpperValueProperty, InnerTrackSizeProperty)) + { + UpdateInnerTrackLayout(); + } + } + + void HandleLowerSliderDragStarted(object? sender, EventArgs e) + { + SetFocusMode(RangeSliderFocusMode.Lower); + UpdateFocusedSliderLayout(); + } + + void HandleLowerSliderDragCompleted(object? sender, EventArgs e) + { + SetFocusMode(RangeSliderFocusMode.Default); + UpdateFocusedSliderLayout(); + } + + /// + /// Computes the effective unit used for slider value calculations. + /// + /// + /// The returned unit is derived from and corrected for the sign of the range: + /// - If is 0, defaults to 1 (no stepping). + /// - Otherwise uses the absolute value of . + /// - If is greater than , the unit is negated to match a descending range. + /// This ensures consistent mapping between slider positions and / across ascending and descending ranges. + /// + /// + /// A positive unit for ascending ranges or a negative unit for descending ranges. Defaults to 1 or -1 when is 0. + /// + double GetUnit() + { + double unit = StepSize == 0 ? 1 : Math.Abs(StepSize); + return MinimumValue <= MaximumValue ? unit : -unit; + } + + void HandleLowerSliderPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (string.IsNullOrWhiteSpace(e.PropertyName)) + { + return; + } + + if (e.PropertyName.Is(Slider.ValueProperty)) + { + LowerValue = CalculateValueFromSliderValue(lowerSlider.Value); + SetFocusMode(RangeSliderFocusMode.Lower); + UpdateInnerTrackLayout(); + } + } + + void HandleLowerSliderFocused(object? sender, FocusEventArgs e) + { + SetFocusMode(RangeSliderFocusMode.Lower); + UpdateFocusedSliderLayout(); + } + + void HandleUpperSliderDragStarted(object? sender, EventArgs e) + { + SetFocusMode(RangeSliderFocusMode.Upper); + UpdateFocusedSliderLayout(); + } + + void HandleUpperSliderDragCompleted(object? sender, EventArgs e) + { + SetFocusMode(RangeSliderFocusMode.Default); + UpdateFocusedSliderLayout(); + } + + void HandleUpperSliderPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (string.IsNullOrWhiteSpace(e.PropertyName)) + { + return; + } + + if (e.PropertyName.Is(Slider.ValueProperty)) + { + UpperValue = CalculateValueFromSliderValue(upperSlider.Value); + SetFocusMode(RangeSliderFocusMode.Upper); + UpdateInnerTrackLayout(); + } + } + + double CalculateValueFromSliderValue(double sliderValue) + { + return (StepSize == 0.0 ? sliderValue : Math.Round(sliderValue)) * GetUnit() + MinimumValue; + } + + void HandleUpperSliderFocused(object? sender, FocusEventArgs e) + { + SetFocusMode(RangeSliderFocusMode.Upper); + UpdateFocusedSliderLayout(); + } + + void UpdateFocusedSliderLayout() + { + double unit = GetUnit(); + double maxValue = (MaximumValue - MinimumValue) / unit; + double middleValue = FocusMode switch + { + RangeSliderFocusMode.Lower => upperSlider.Value, + RangeSliderFocusMode.Upper => lowerSlider.Value, + _ => (lowerSlider.Value + upperSlider.Value) / 2, + }; + lowerSlider.Minimum = 0; + lowerSlider.Maximum = middleValue; + upperSlider.Minimum = middleValue; + upperSlider.Maximum = maxValue; + + double range = upperSlider.Maximum - lowerSlider.Minimum; + double trackWidth = Width - PlatformThumbSize; + if (IsLayoutValid(range, trackWidth)) + { + lowerSlider.WidthRequest = trackWidth * (lowerSlider.Maximum - lowerSlider.Minimum) / range + PlatformThumbSize; + upperSlider.WidthRequest = trackWidth * (upperSlider.Maximum - upperSlider.Minimum) / range + PlatformThumbSize; + } + } + + void UpdateSliderRanges() + { + double unit = GetUnit(); + double maxValue = (MaximumValue - MinimumValue) / unit; + lowerSlider.Minimum = 0; + lowerSlider.Maximum = maxValue; + upperSlider.Minimum = 0; + upperSlider.Maximum = maxValue; + } + + void UpdateLowerSliderValue() + { + lowerSlider.Value = (LowerValue - MinimumValue) / GetUnit(); + } + + void UpdateUpperSliderValue() + { + upperSlider.Value = (UpperValue - MinimumValue) / GetUnit(); + } + + void UpdateOuterTrackLayout() + { + outerTrack.TranslationX = PlatformThumbSize / 2 - OuterTrackSize / 2; + outerTrack.WidthRequest = (Width - PlatformThumbSize) + OuterTrackSize; + } + + void UpdateInnerTrackLayout() + { + double range = upperSlider.Maximum - lowerSlider.Minimum; + double trackWidth = Width - PlatformThumbSize; + if (IsLayoutValid(range, trackWidth)) + { + innerTrack.TranslationX = trackWidth * (lowerSlider.Value - lowerSlider.Minimum) / range + PlatformThumbSize / 2 - InnerTrackSize / 2; + innerTrack.WidthRequest = trackWidth * (upperSlider.Value - lowerSlider.Value) / range + InnerTrackSize; + } + } + + bool IsLayoutValid(double range, double trackWidth) + { + return range != 0 && trackWidth > 0; + } +}