diff --git a/MainDemo.Wpf/RatingBar.xaml b/MainDemo.Wpf/RatingBar.xaml index b546736aac..7bf633758c 100644 --- a/MainDemo.Wpf/RatingBar.xaml +++ b/MainDemo.Wpf/RatingBar.xaml @@ -79,6 +79,180 @@ VerticalAlignment="Top" Margin="10,2,0,0"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MainDemo.Wpf/RatingBar.xaml.cs b/MainDemo.Wpf/RatingBar.xaml.cs index 3e5a5ec5a8..0b0c96ad7f 100644 --- a/MainDemo.Wpf/RatingBar.xaml.cs +++ b/MainDemo.Wpf/RatingBar.xaml.cs @@ -7,7 +7,10 @@ public partial class RatingBar { public RatingBar() => InitializeComponent(); - private void BasicRatingBar_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + private void BasicRatingBar_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) => Debug.WriteLine($"BasicRatingBar value changed from {e.OldValue} to {e.NewValue}."); + + private void BasicRatingBarFractional_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + => Debug.WriteLine($"BasicRatingBarFractional value changed from {e.OldValue} to {e.NewValue}."); } } diff --git a/MaterialDesign3.Demo.Wpf/RatingBar.xaml.cs b/MaterialDesign3.Demo.Wpf/RatingBar.xaml.cs index f65addb1db..7100128eac 100644 --- a/MaterialDesign3.Demo.Wpf/RatingBar.xaml.cs +++ b/MaterialDesign3.Demo.Wpf/RatingBar.xaml.cs @@ -7,7 +7,7 @@ public partial class RatingBar { public RatingBar() => InitializeComponent(); - private void BasicRatingBar_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + private void BasicRatingBar_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) => Debug.WriteLine($"BasicRatingBar value changed from {e.OldValue} to {e.NewValue}."); } } diff --git a/MaterialDesignThemes.Wpf.Tests/RatingBarTests.cs b/MaterialDesignThemes.Wpf.Tests/RatingBarTests.cs new file mode 100644 index 0000000000..352dafba2c --- /dev/null +++ b/MaterialDesignThemes.Wpf.Tests/RatingBarTests.cs @@ -0,0 +1,420 @@ +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; +using Xunit; + +namespace MaterialDesignThemes.Wpf.Tests; + +public class RatingBarTests +{ + [StaFact] + public void SetMin_ShouldCoerceToMax_WhenMinIsGreaterThanMax() + { + // Arrange + RatingBar ratingBar = new() { Min = 1, Max = 10 }; + + // Act + ratingBar.Min = 15; + + // Assert + Assert.Equal(10, ratingBar.Min); + } + + [StaFact] + public void SetMin_ShouldNotCoerceValue_WhenFractionalValuesAreDisabled() + { + // Arrange + RatingBar ratingBar = new() { Min = 1, Max = 10, Value = 5}; + + // Act + ratingBar.Min = 7; + + // Assert + Assert.Equal(5, ratingBar.Value); + } + + [StaFact] + public void SetMin_ShouldCoerceValue_WhenFractionalValuesAreEnabled() + { + // Arrange + RatingBar ratingBar = new() { Min = 1, Max = 10, Value = 5, ValueIncrements = 0.5 }; + + // Act + ratingBar.Min = 7; + + // Assert + Assert.Equal(7, ratingBar.Value); + } + + [StaFact] + public void SetMax_ShouldNotCoerceValue_WhenFractionalValuesAreDisabled() + { + // Arrange + RatingBar ratingBar = new() { Min = 1, Max = 10, Value = 5 }; + + // Act + ratingBar.Max = 3; + + // Assert + Assert.Equal(5, ratingBar.Value); + } + + [StaFact] + public void SetMax_ShouldCoerceValue_WhenFractionalValuesAreEnabled() + { + // Arrange + RatingBar ratingBar = new() { Min = 1, Max = 10, Value = 5, ValueIncrements = 0.5 }; + + // Act + ratingBar.Max = 3; + + // Assert + Assert.Equal(3, ratingBar.Value); + } + + [StaFact] + public void SetMax_ShouldCoerceToMin_WhenMaxIsLessThanMin() + { + // Arrange + RatingBar ratingBar = new() { Min = 1, Max = 10 }; + + // Act + ratingBar.Max = -5; + + // Assert + Assert.Equal(1, ratingBar.Max); + } + + [StaTheory] + [InlineData(-5, 1.0)] + [InlineData(5, 5.0)] + [InlineData(15, 10.0)] + [InlineData(1.2, 1.0)] + [InlineData(1.3, 1.5)] + [InlineData(1.7, 1.5)] + [InlineData(1.8, 2.0)] + [InlineData(2.2, 2.0)] + [InlineData(2.3, 2.5)] + public void SetValue_ShouldCoerceToCorrectMultipleAndStaysWithinBounds_WhenFractionalValuesAreEnabled(double valueToSet, double expectedValue) + { + // Arrange + RatingBar ratingBar = new() { Min = 1, Max = 10, ValueIncrements = 0.5 }; + + // Act + ratingBar.Value = valueToSet; + + // Assert + Assert.Equal(expectedValue, ratingBar.Value); + } + + [StaTheory] + [InlineData(-5, -5.0)] + [InlineData(5, 5.0)] + [InlineData(15, 15.0)] + [InlineData(1.2, 1.2)] + [InlineData(2.3, 2.3)] + public void SetValue_ShouldNotCoerceValue_WhenFractionalValuesAreDisabled(double valueToSet, double expectedValue) + { + // Arrange + RatingBar ratingBar = new() { Min = 1, Max = 10 }; + + // Act + ratingBar.Value = valueToSet; + + // Assert + Assert.Equal(expectedValue, ratingBar.Value); + } + + [Fact] + public void TextBlockForegroundConverter_ShouldReturnOriginalBrush_WhenValueIsEqualToButtonValue() + { + // Arrange + SolidColorBrush brush = Brushes.Red; + IMultiValueConverter converter = RatingBar.TextBlockForegroundConverter.Instance; + object[] values = Arrange_TextBlockForegroundConverterValues(brush, value: 1, buttonValue: 1); + + // Act + var result = converter.Convert(values, typeof(Brush), null, CultureInfo.CurrentCulture) as Brush; + + // Assert + Assert.Equal(brush, result); + } + + [Fact] + public void TextBlockForegroundConverter_ShouldReturnOriginalBrush_WhenValueIsGreaterThanButtonValue() + { + // Arrange + SolidColorBrush brush = Brushes.Red; + IMultiValueConverter converter = RatingBar.TextBlockForegroundConverter.Instance; + object[] values = Arrange_TextBlockForegroundConverterValues(brush, value: 2, buttonValue: 1); + + // Act + var result = converter.Convert(values, typeof(Brush), null, CultureInfo.CurrentCulture) as Brush; + + // Assert + Assert.Equal(brush, result); + } + + [Fact] + public void TextBlockForegroundConverter_ShouldReturnSemiTransparentBrush_WhenValueIsLessThanButtonValueMinusOne() + { + // Arrange + SolidColorBrush brush = Brushes.Red; + IMultiValueConverter converter = RatingBar.TextBlockForegroundConverter.Instance; + object[] values = Arrange_TextBlockForegroundConverterValues(brush, value: 0.5, buttonValue: 2); + + // Act + var result = converter.Convert(values, typeof(Brush), null, CultureInfo.CurrentCulture) as Brush; + + // Assert + Assert.IsAssignableFrom(result); + SolidColorBrush resultBrush = (SolidColorBrush)result!; + Assert.Equal(RatingBar.TextBlockForegroundConverter.SemiTransparent, resultBrush.Color.A); + } + + [Fact] + public void TextBlockForegroundConverter_ShouldReturnHorizontalLinearGradientBrush_WhenValueIsBetweenButtonValueAndButtonValueMinusOne() + { + // Arrange + SolidColorBrush brush = Brushes.Red; + IMultiValueConverter converter = RatingBar.TextBlockForegroundConverter.Instance; + object[] values = Arrange_TextBlockForegroundConverterValues(brush, value: 1.5, buttonValue: 2, orientation: Orientation.Horizontal); + + // Act + var result = converter.Convert(values, typeof(Brush), null, CultureInfo.CurrentCulture) as Brush; + + // Assert + Assert.IsAssignableFrom(result); + LinearGradientBrush resultBrush = (LinearGradientBrush)result!; + Assert.Equal(new Point(0, 0.5), resultBrush.StartPoint); + Assert.Equal(new Point(1, 0.5), resultBrush.EndPoint); + } + + [Fact] + public void TextBlockForegroundConverter_ShouldReturnVerticalLinearGradientBrush_WhenValueIsBetweenButtonValueAndButtonValueMinusOne() + { + // Arrange + SolidColorBrush brush = Brushes.Red; + IMultiValueConverter converter = RatingBar.TextBlockForegroundConverter.Instance; + object[] values = Arrange_TextBlockForegroundConverterValues(brush, value: 1.5, buttonValue: 2, orientation: Orientation.Vertical); + + // Act + var result = converter.Convert(values, typeof(Brush), null, CultureInfo.CurrentCulture) as Brush; + + // Assert + Assert.IsAssignableFrom(result); + LinearGradientBrush resultBrush = (LinearGradientBrush)result!; + Assert.Equal(new Point(0.5, 0), resultBrush.StartPoint); + Assert.Equal(new Point(0.5, 1), resultBrush.EndPoint); + } + + [Fact] + public void TextBlockForegroundConverter_ShouldReturnFractionalGradientStops_WhenValueCovers10PercentOfButtonValue() + { + // Arrange + SolidColorBrush brush = Brushes.Red; + IMultiValueConverter converter = RatingBar.TextBlockForegroundConverter.Instance; + object[] values = Arrange_TextBlockForegroundConverterValues(brush, value: 1.1, buttonValue: 2); + + // Act + var result = converter.Convert(values, typeof(Brush), null, CultureInfo.CurrentCulture) as Brush; + + // Assert + Assert.IsAssignableFrom(result); + LinearGradientBrush resultBrush = (LinearGradientBrush)result!; + Assert.Equal(2, resultBrush.GradientStops.Count); + GradientStop stop1 = resultBrush.GradientStops[0]; + GradientStop stop2 = resultBrush.GradientStops[1]; + Assert.Equal(0.1, stop1.Offset, 10); + Assert.Equal(brush.Color, stop1.Color); + Assert.Equal(0.1, stop2.Offset, 10); + Assert.Equal(brush.Color.WithAlphaChannel(RatingBar.TextBlockForegroundConverter.SemiTransparent), stop2.Color); + } + + [Fact] + public void TextBlockForegroundConverter_ShouldReturnFractionalGradientStops_WhenValueCovers42PercentOfButtonValue() + { + // Arrange + SolidColorBrush brush = Brushes.Red; + IMultiValueConverter converter = RatingBar.TextBlockForegroundConverter.Instance; + object[] values = Arrange_TextBlockForegroundConverterValues(brush, value: 1.42, buttonValue: 2); + + // Act + var result = converter.Convert(values, typeof(Brush), null, CultureInfo.CurrentCulture) as Brush; + + // Assert + Assert.IsAssignableFrom(result); + LinearGradientBrush resultBrush = (LinearGradientBrush)result!; + Assert.Equal(2, resultBrush.GradientStops.Count); + GradientStop stop1 = resultBrush.GradientStops[0]; + GradientStop stop2 = resultBrush.GradientStops[1]; + Assert.Equal(0.42, stop1.Offset, 10); + Assert.Equal(brush.Color, stop1.Color); + Assert.Equal(0.42, stop2.Offset, 10); + Assert.Equal(brush.Color.WithAlphaChannel(RatingBar.TextBlockForegroundConverter.SemiTransparent), stop2.Color); + } + + [Fact] + public void TextBlockForegroundConverter_ShouldReturnFractionalGradientStops_WhenValueCovers87PercentOfButtonValue() + { + // Arrange + SolidColorBrush brush = Brushes.Red; + IMultiValueConverter converter = RatingBar.TextBlockForegroundConverter.Instance; + object[] values = Arrange_TextBlockForegroundConverterValues(brush, value: 1.87, buttonValue: 2); + + // Act + var result = converter.Convert(values, typeof(Brush), null, CultureInfo.CurrentCulture) as Brush; + + // Assert + Assert.IsAssignableFrom(result); + LinearGradientBrush resultBrush = (LinearGradientBrush)result!; + Assert.Equal(2, resultBrush.GradientStops.Count); + GradientStop stop1 = resultBrush.GradientStops[0]; + GradientStop stop2 = resultBrush.GradientStops[1]; + Assert.Equal(0.87, stop1.Offset, 10); + Assert.Equal(brush.Color, stop1.Color); + Assert.Equal(0.87, stop2.Offset, 10); + Assert.Equal(brush.Color.WithAlphaChannel(RatingBar.TextBlockForegroundConverter.SemiTransparent), stop2.Color); + } + + private static object[] Arrange_TextBlockForegroundConverterValues(SolidColorBrush brush, double value, int buttonValue, Orientation orientation = Orientation.Horizontal) => + new object[] { brush, orientation, value, buttonValue }; + + [Fact] + public void PreviewIndicatorTransformXConverter_ShouldCenterPreviewIndicator_WhenFractionalValuesAreDisabledAndOrientationIsHorizontal() + { + // Arrange + IMultiValueConverter converter = RatingBar.PreviewIndicatorTransformXConverter.Instance; + object[] values = Arrange_PreviewIndicatorTransformXConverterValues(100, 20, Orientation.Horizontal, false, 1, 1); + + // Act + double? result = converter.Convert(values, typeof(double), null, CultureInfo.CurrentCulture) as double?; + + // Assert + Assert.NotNull(result); + Assert.Equal(40.0, result); // 50% of 100 minus 20/2 + } + + [Fact] + public void PreviewIndicatorTransformXConverter_ShouldOffsetPreviewIndicatorByPercentage_WhenFractionalValuesAreEnabledAndOrientationIsHorizontal() + { + // Arrange + IMultiValueConverter converter = RatingBar.PreviewIndicatorTransformXConverter.Instance; + object[] values = Arrange_PreviewIndicatorTransformXConverterValues(100, 20, Orientation.Horizontal, true, 1.25, 1); + + // Act + double? result = converter.Convert(values, typeof(double), null, CultureInfo.CurrentCulture) as double?; + + // Assert + Assert.NotNull(result); + Assert.Equal(15.0, result); // 25% of 100 minus 20/2 + } + + [Fact] + public void PreviewIndicatorTransformXConverter_ShouldPlacePreviewIndicatorWithSmallMargin_WhenFractionalValuesAreDisabledAndOrientationIsVertical() + { + // Arrange + IMultiValueConverter converter = RatingBar.PreviewIndicatorTransformXConverter.Instance; + object[] values = Arrange_PreviewIndicatorTransformXConverterValues(100, 20, Orientation.Vertical, false, 1, 1); + double expectedValue = -20 - RatingBar.PreviewIndicatorTransformXConverter.Margin; + + // Act + double? result = converter.Convert(values, typeof(double), null, CultureInfo.CurrentCulture) as double?; + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedValue, result); // 100% of 20 minus fixed margin + } + + [Fact] + public void PreviewIndicatorTransformXConverter_ShouldPlacePreviewIndicatorWithSmallMargin_WhenFractionalValuesAreEnabledAndOrientationIsVertical() + { + // Arrange + IMultiValueConverter converter = RatingBar.PreviewIndicatorTransformXConverter.Instance; + object[] values = Arrange_PreviewIndicatorTransformXConverterValues(100, 20, Orientation.Vertical, true, 1.25, 1); + double expectedValue = -20 - RatingBar.PreviewIndicatorTransformXConverter.Margin; + + // Act + double? result = converter.Convert(values, typeof(double), null, CultureInfo.CurrentCulture) as double?; + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedValue, result); // 100% of 20 minus fixed margin + } + + + + private static object[] Arrange_PreviewIndicatorTransformXConverterValues(double ratingBarButtonActualWidth, double previewValueActualWidth, Orientation orientation, bool isFractionalValueEnabled, double previewValue, int buttonValue) => + new object[] { ratingBarButtonActualWidth, previewValueActualWidth, orientation, isFractionalValueEnabled, previewValue, buttonValue }; + + [Fact] + public void PreviewIndicatorTransformYConverter_ShouldPlacePreviewIndicatorWithSmallMargin_WhenFractionalValuesAreDisabledAndOrientationIsHorizontal() + { + // Arrange + IMultiValueConverter converter = RatingBar.PreviewIndicatorTransformYConverter.Instance; + object[] values = Arrange_PreviewIndicatorTransformYConverterValues(100, 20, Orientation.Horizontal, false, 1, 1); + double expectedValue = -20 - RatingBar.PreviewIndicatorTransformYConverter.Margin; + + // Act + double? result = converter.Convert(values, typeof(double), null, CultureInfo.CurrentCulture) as double?; + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedValue, result); // 100% of 20 minus fixed margin + } + + [Fact] + public void PreviewIndicatorTransformYConverter_ShouldPlacePreviewIndicatorWithSmallMargin_WhenFractionalValuesAreEnabledAndOrientationIsHorizontal() + { + // Arrange + IMultiValueConverter converter = RatingBar.PreviewIndicatorTransformYConverter.Instance; + object[] values = Arrange_PreviewIndicatorTransformYConverterValues(100, 20, Orientation.Horizontal, true, 1.25, 1); + double expectedValue = -20 - RatingBar.PreviewIndicatorTransformYConverter.Margin; + + // Act + double? result = converter.Convert(values, typeof(double), null, CultureInfo.CurrentCulture) as double?; + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedValue, result); // 100% of 20 minus fixed margin + } + + [Fact] + public void PreviewIndicatorTransformYConverter_ShouldCenterPreviewIndicator_WhenFractionalValuesAreDisabledAndOrientationIsVertical() + { + // Arrange + IMultiValueConverter converter = RatingBar.PreviewIndicatorTransformYConverter.Instance; + object[] values = Arrange_PreviewIndicatorTransformYConverterValues(100, 20, Orientation.Vertical, false, 1, 1); + + // Act + double? result = converter.Convert(values, typeof(double), null, CultureInfo.CurrentCulture) as double?; + + // Assert + Assert.NotNull(result); + Assert.Equal(40.0, result); // 50% of 100 minus 20/2 + } + + [Fact] + public void PreviewIndicatorTransformYConverter_ShouldPreviewIndicatorByPercentage_WhenFractionalValuesAreEnabledAndOrientationIsVertical() + { + // Arrange + IMultiValueConverter converter = RatingBar.PreviewIndicatorTransformYConverter.Instance; + object[] values = Arrange_PreviewIndicatorTransformYConverterValues(100, 20, Orientation.Vertical, true, 1.25, 1); + + // Act + double? result = converter.Convert(values, typeof(double), null, CultureInfo.CurrentCulture) as double?; + + // Assert + Assert.NotNull(result); + Assert.Equal(15.0, result); // 25% of 100 minus 20/2 + } + + private static object[] Arrange_PreviewIndicatorTransformYConverterValues(double ratingBarButtonActualHeight, double previewValueActualHeight, Orientation orientation, bool isFractionalValueEnabled, double previewValue, int buttonValue) => + new object[] { ratingBarButtonActualHeight, previewValueActualHeight, orientation, isFractionalValueEnabled, previewValue, buttonValue }; +} + +internal static class ColorExtensions +{ + public static Color WithAlphaChannel(this Color color, byte alphaChannel) + => Color.FromArgb(alphaChannel, color.R, color.G, color.B); +} \ No newline at end of file diff --git a/MaterialDesignThemes.Wpf/RatingBar.cs b/MaterialDesignThemes.Wpf/RatingBar.cs index ef42f924c2..b1b75452ac 100644 --- a/MaterialDesignThemes.Wpf/RatingBar.cs +++ b/MaterialDesignThemes.Wpf/RatingBar.cs @@ -1,7 +1,7 @@ using System.Collections.ObjectModel; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; namespace MaterialDesignThemes.Wpf { @@ -25,16 +25,32 @@ public RatingBar() { CommandBindings.Add(new CommandBinding(SelectRatingCommand, SelectItemHandler)); _ratingButtons = new ReadOnlyObservableCollection(_ratingButtonsInternal); + MouseLeave += RatingBar_MouseLeave; } private void SelectItemHandler(object sender, ExecutedRoutedEventArgs executedRoutedEventArgs) { if (executedRoutedEventArgs.Parameter is int && !IsReadOnly) - Value = (int)executedRoutedEventArgs.Parameter; + { + if (!IsFractionalValueEnabled) + { + Value = (int)executedRoutedEventArgs.Parameter; + return; + } + Value = GetValueAtMousePosition((RatingBarButton)executedRoutedEventArgs.OriginalSource); + } + } + + private double GetValueAtMousePosition(RatingBarButton ratingBarButton) + { + // Get mouse offset inside source + Point p = Mouse.GetPosition(ratingBarButton); + double percentSelected = Orientation == Orientation.Horizontal ? p.X / ratingBarButton.ActualWidth : p.Y / ratingBarButton.ActualHeight; + return ratingBarButton.Value - 1 + percentSelected; } public static readonly DependencyProperty MinProperty = DependencyProperty.Register( - nameof(Min), typeof(int), typeof(RatingBar), new PropertyMetadata(1, MinPropertyChangedCallback)); + nameof(Min), typeof(int), typeof(RatingBar), new PropertyMetadata(1, MinPropertyChangedCallback, MinPropertyCoerceValueCallback)); public int Min { @@ -43,7 +59,7 @@ public int Min } public static readonly DependencyProperty MaxProperty = DependencyProperty.Register( - nameof(Max), typeof(int), typeof(RatingBar), new PropertyMetadata(5, MaxPropertyChangedCallback)); + nameof(Max), typeof(int), typeof(RatingBar), new PropertyMetadata(5, MaxPropertyChangedCallback, MaxPropertyCoerceValueCallback)); public int Max { @@ -51,20 +67,117 @@ public int Max set => SetValue(MaxProperty, value); } + private static readonly DependencyPropertyKey IsFractionalValueEnabledPropertyKey = DependencyProperty.RegisterReadOnly( + nameof(IsFractionalValueEnabled), typeof(bool), typeof(RatingBar), new PropertyMetadata(false)); + + internal static readonly DependencyProperty IsFractionalValueEnabledProperty = + IsFractionalValueEnabledPropertyKey.DependencyProperty; + + internal bool IsFractionalValueEnabled + { + get => (bool)GetValue(IsFractionalValueEnabledProperty); + private set => SetValue(IsFractionalValueEnabledPropertyKey, value); + } + + public static readonly DependencyProperty ValueIncrementsProperty = DependencyProperty.Register( + nameof(ValueIncrements), typeof(double), typeof(RatingBar), new PropertyMetadata(1.0, ValueIncrementsPropertyChangedCallback, ValueIncrementsCoerceValueCallback)); + + private static void ValueIncrementsPropertyChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var ratingBar = (RatingBar)d; + ratingBar.IsFractionalValueEnabled = Math.Abs(ratingBar.ValueIncrements - 1.0) > 1e-10; + ratingBar.RebuildButtons(); + } + + private static object ValueIncrementsCoerceValueCallback(DependencyObject d, object baseValue) + => Math.Max(double.Epsilon, Math.Min(1.0, (double)baseValue)); + + /// + /// Gets or sets the value increments. Set to a value between 0.0 and 1.0 (both exclusive) to enable fractional values. Default value is 1.0 (i.e. fractional values disabled) + /// + public double ValueIncrements + { + get { return (double) GetValue(ValueIncrementsProperty); } + set { SetValue(ValueIncrementsProperty, value); } + } + + public static readonly DependencyProperty IsPreviewValueEnabledProperty = DependencyProperty.Register( + nameof(IsPreviewValueEnabled), typeof(bool), typeof(RatingBar), new PropertyMetadata(false)); + + public bool IsPreviewValueEnabled + { + get { return (bool) GetValue(IsPreviewValueEnabledProperty); } + set { SetValue(IsPreviewValueEnabledProperty, value); } + } + + private static readonly DependencyPropertyKey PreviewValuePropertyKey = DependencyProperty.RegisterReadOnly( + nameof(PreviewValue), typeof(double?), typeof(RatingBar), new PropertyMetadata(null, null, PreviewValuePropertyCoerceValueCallback)); + + private static object? PreviewValuePropertyCoerceValueCallback(DependencyObject d, object? baseValue) + { + if (baseValue == null) + return null; + + var ratingBar = (RatingBar)d; + if (baseValue is double value) + { + if (!ratingBar.IsFractionalValueEnabled) + value = Math.Ceiling(value); + return ratingBar.CoerceToValidIncrement(value); + } + return (double)ratingBar.Min; + } + + internal static readonly DependencyProperty PreviewValueProperty = + PreviewValuePropertyKey.DependencyProperty; + + internal double? PreviewValue + { + get => (double?)GetValue(PreviewValueProperty); + private set => SetValue(PreviewValuePropertyKey, value); + } + public static readonly DependencyProperty ValueProperty = DependencyProperty.Register( - nameof(Value), typeof(int), typeof(RatingBar), new PropertyMetadata(0, ValuePropertyChangedCallback)); + nameof(Value), typeof(double), typeof(RatingBar), new PropertyMetadata(0.0, ValuePropertyChangedCallback, ValuePropertyCoerceValueCallback)); private static void ValuePropertyChangedCallback(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs dependencyPropertyChangedEventArgs) { var ratingBar = (RatingBar)dependencyObject; foreach (var button in ratingBar.RatingButtons) - button.IsWithinSelectedValue = button.Value <= (int)dependencyPropertyChangedEventArgs.NewValue; + { +#pragma warning disable CS0618 // Type or member is obsolete + // The property being set here is no longer used. If the RatingBarButton (and the DP) was not public I would have just removed it. + button.IsWithinSelectedValue = button.Value <= (double)dependencyPropertyChangedEventArgs.NewValue; +#pragma warning restore CS0618 // Type or member is obsolete + } OnValueChanged(ratingBar, dependencyPropertyChangedEventArgs); } - public int Value + private static object ValuePropertyCoerceValueCallback(DependencyObject d, object baseValue) { - get => (int)GetValue(ValueProperty); + var ratingBar = (RatingBar) d; + + // If factional values are disabled we don't do any coercion. This maintains back-compat where coercion was not applied and Value could be outside of Min/Max range. + if (!ratingBar.IsFractionalValueEnabled) + return baseValue; + + if (baseValue is double value) + { + return ratingBar.CoerceToValidIncrement(value); + } + return (double)ratingBar.Min; + } + + private double CoerceToValidIncrement(double value) + { + // Coerce the value into a multiple of ValueIncrements and within the bounds. + double valueInCorrectMultiple = Math.Round(value / ValueIncrements, MidpointRounding.AwayFromZero) * ValueIncrements; + return Math.Min(Max, Math.Max(Min, valueInCorrectMultiple)); + } + + public double Value + { + get => (double)GetValue(ValueProperty); set => SetValue(ValueProperty, value); } @@ -72,10 +185,10 @@ public int Value EventManager.RegisterRoutedEvent( nameof(Value), RoutingStrategy.Bubble, - typeof(RoutedPropertyChangedEventHandler), + typeof(RoutedPropertyChangedEventHandler), typeof(RatingBar)); - public event RoutedPropertyChangedEventHandler ValueChanged + public event RoutedPropertyChangedEventHandler ValueChanged { add => AddHandler(ValueChangedEvent, value); remove => RemoveHandler(ValueChangedEvent, value); @@ -85,9 +198,9 @@ private static void OnValueChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { var instance = (RatingBar)d; - var args = new RoutedPropertyChangedEventArgs( - (int)e.OldValue, - (int)e.NewValue) + var args = new RoutedPropertyChangedEventArgs( + (double)e.OldValue, + (double)e.NewValue) { RoutedEvent = ValueChangedEvent }; instance.RaiseEvent(args); } @@ -141,36 +254,211 @@ public bool IsReadOnly private static void MaxPropertyChangedCallback(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs dependencyPropertyChangedEventArgs) { - ((RatingBar)dependencyObject).RebuildButtons(); + var ratingBar = (RatingBar)dependencyObject; + ratingBar.CoerceValue(ValueProperty); + ratingBar.RebuildButtons(); + } + + private static object MinPropertyCoerceValueCallback(DependencyObject d, object baseValue) + { + var ratingBar = (RatingBar)d; + return Math.Min((int)baseValue, ratingBar.Max); } private static void MinPropertyChangedCallback(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs dependencyPropertyChangedEventArgs) { - ((RatingBar)dependencyObject).RebuildButtons(); + var ratingBar = (RatingBar)dependencyObject; + ratingBar.CoerceValue(ValueProperty); + ratingBar.RebuildButtons(); + } + + private static object MaxPropertyCoerceValueCallback(DependencyObject d, object baseValue) + { + var ratingBar = (RatingBar)d; + return Math.Max((int)baseValue, ratingBar.Min); } private void RebuildButtons() { + foreach (var ratingBarButton in _ratingButtonsInternal) + { + ratingBarButton.MouseMove -= RatingBarButton_MouseMove; + } _ratingButtonsInternal.Clear(); - for (var i = Min; i <= Max; i++) + + // When fractional values are enabled, the first rating button represents the value Min when not selected at all and Min+1 when fully selected; + // thus we start with the value Min+1 for the values of the rating buttons. + int start = IsFractionalValueEnabled ? Min + 1 : Min; + for (int i = start; i <= Max; i++) { - _ratingButtonsInternal.Add(new RatingBarButton + var ratingBarButton = new RatingBarButton { Content = i, ContentTemplate = ValueItemTemplate, ContentTemplateSelector = ValueItemTemplateSelector, +#pragma warning disable CS0618 // Type or member is obsolete IsWithinSelectedValue = i <= Value, +#pragma warning restore CS0618 // Type or member is obsolete Style = ValueItemContainerButtonStyle, Value = i, - }); + }; + ratingBarButton.MouseMove += RatingBarButton_MouseMove; + _ratingButtonsInternal.Add(ratingBarButton); } } + private void RatingBar_MouseLeave(object sender, MouseEventArgs e) => PreviewValue = null; + + private void RatingBarButton_MouseMove(object sender, MouseEventArgs e) + { + if (!IsPreviewValueEnabled) + return; + + var ratingBarButton = (RatingBarButton) sender; + PreviewValue = GetValueAtMousePosition(ratingBarButton); + } + public override void OnApplyTemplate() { RebuildButtons(); base.OnApplyTemplate(); } + + internal class TextBlockForegroundConverter : IMultiValueConverter + { + internal static byte SemiTransparent => 0x42; // ~26% opacity + + public static TextBlockForegroundConverter Instance { get; } = new(); + + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + if (values?.Length == 4 + && values[0] is SolidColorBrush brush + && values[1] is Orientation orientation + && values[2] is double value + && values[3] is int buttonValue) + { + if (value >= buttonValue) + return brush; + + var originalColor = brush.Color; + var semiTransparent = Color.FromArgb(SemiTransparent, brush.Color.R, brush.Color.G, brush.Color.B); + + if (value > buttonValue - 1.0) + { + double offset = value - buttonValue + 1; + return new LinearGradientBrush + { + StartPoint = orientation == Orientation.Horizontal ? new Point(0, 0.5) : new Point(0.5, 0), + EndPoint = orientation == Orientation.Horizontal ? new Point(1, 0.5) : new Point(0.5, 1), + GradientStops = new() + { + new GradientStop {Color = originalColor, Offset = offset}, + new GradientStop {Color = semiTransparent, Offset = offset} + } + }; + } + return new SolidColorBrush(semiTransparent); + } + + // This should never happen (returning actual brush to avoid the compilers squiggly line warning) + return Brushes.Transparent; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) => throw new NotImplementedException(); + } + + internal class PreviewIndicatorTransformXConverter : IMultiValueConverter + { + public static PreviewIndicatorTransformXConverter Instance { get; } = new(); + + internal static double Margin => 2.0; + + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + if (values.Length >= 6 + && values[0] is double ratingBarButtonActualWidth + && values[1] is double previewValueActualWidth + && values[2] is Orientation ratingBarOrientation + && values[3] is bool isFractionalValueEnabled + && values[4] is double previewValue + && values[5] is int ratingButtonValue) + { + if (!isFractionalValueEnabled) + { + return ratingBarOrientation switch + { + Orientation.Horizontal => (ratingBarButtonActualWidth - previewValueActualWidth) / 2, + Orientation.Vertical => -previewValueActualWidth - Margin, + _ => throw new ArgumentOutOfRangeException() + }; + } + + // Special handling of edge cases due to the inaccuracy of how double values are stored + double percent = previewValue % 1; + if (Math.Abs(ratingButtonValue - previewValue) <= double.Epsilon) + percent = 1.0; + else if (percent <= double.Epsilon) + percent = 0.0; + + return ratingBarOrientation switch + { + Orientation.Horizontal => percent * ratingBarButtonActualWidth - (previewValueActualWidth / 2), + Orientation.Vertical => -previewValueActualWidth - Margin, + _ => throw new ArgumentOutOfRangeException() + }; + } + return 1.0; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) => throw new NotImplementedException(); + } + + internal class PreviewIndicatorTransformYConverter : IMultiValueConverter + { + public static PreviewIndicatorTransformYConverter Instance { get; } = new(); + + internal static double Margin => 2.0; + + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + if (values.Length >= 6 + && values[0] is double ratingBarButtonActualHeight + && values[1] is double previewValueActualHeight + && values[2] is Orientation ratingBarOrientation + && values[3] is bool isFractionalValueEnabled + && values[4] is double previewValue + && values[5] is int ratingButtonValue) + { + if (!isFractionalValueEnabled) + { + return ratingBarOrientation switch + { + Orientation.Horizontal => -previewValueActualHeight - Margin, + Orientation.Vertical => (ratingBarButtonActualHeight - previewValueActualHeight) / 2, + _ => throw new ArgumentOutOfRangeException() + }; + } + + // Special handling of edge cases due to the inaccuracy of how double values are stored + double percent = previewValue % 1; + if (Math.Abs(ratingButtonValue - previewValue) <= double.Epsilon) + percent = 1.0; + else if (percent <= double.Epsilon) + percent = 0.0; + + return ratingBarOrientation switch + { + Orientation.Horizontal => -previewValueActualHeight - Margin, + Orientation.Vertical => percent * ratingBarButtonActualHeight - (previewValueActualHeight / 2), + _ => throw new ArgumentOutOfRangeException() + }; + } + return 1.0; + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) => throw new NotImplementedException(); + } } } diff --git a/MaterialDesignThemes.Wpf/RatingBarButton.cs b/MaterialDesignThemes.Wpf/RatingBarButton.cs index 7b75352f58..fd805bcc26 100644 --- a/MaterialDesignThemes.Wpf/RatingBarButton.cs +++ b/MaterialDesignThemes.Wpf/RatingBarButton.cs @@ -1,41 +1,39 @@ -using System.Windows; -using System.Windows.Controls.Primitives; +namespace MaterialDesignThemes.Wpf; -namespace MaterialDesignThemes.Wpf +public class RatingBarButton : ButtonBase { - public class RatingBarButton : ButtonBase + static RatingBarButton() { - static RatingBarButton() - { - DefaultStyleKeyProperty.OverrideMetadata(typeof(RatingBarButton), new FrameworkPropertyMetadata(typeof(RatingBarButton))); - } + DefaultStyleKeyProperty.OverrideMetadata(typeof(RatingBarButton), new FrameworkPropertyMetadata(typeof(RatingBarButton))); + } - private static readonly DependencyPropertyKey ValuePropertyKey = - DependencyProperty.RegisterReadOnly( - "Value", typeof(int), typeof(RatingBarButton), - new PropertyMetadata(default(int))); + private static readonly DependencyPropertyKey ValuePropertyKey = + DependencyProperty.RegisterReadOnly( + "Value", typeof(int), typeof(RatingBarButton), + new PropertyMetadata(default(int))); - public static readonly DependencyProperty ValueProperty = - ValuePropertyKey.DependencyProperty; + public static readonly DependencyProperty ValueProperty = + ValuePropertyKey.DependencyProperty; - public int Value - { - get { return (int)GetValue(ValueProperty); } - internal set { SetValue(ValuePropertyKey, value); } - } + public int Value + { + get => (int)GetValue(ValueProperty); + internal set => SetValue(ValuePropertyKey, value); + } - private static readonly DependencyPropertyKey IsWithinValuePropertyKey = - DependencyProperty.RegisterReadOnly( - "IsWithinSelectedValue", typeof(bool), typeof(RatingBarButton), - new PropertyMetadata(default(bool))); + private static readonly DependencyPropertyKey IsWithinValuePropertyKey = + DependencyProperty.RegisterReadOnly( + "IsWithinSelectedValue", typeof(bool), typeof(RatingBarButton), + new PropertyMetadata(default(bool))); - public static readonly DependencyProperty IsWithinSelectedValueProperty = - IsWithinValuePropertyKey.DependencyProperty; + [Obsolete("This will be removed in a future version")] + public static readonly DependencyProperty IsWithinSelectedValueProperty = + IsWithinValuePropertyKey.DependencyProperty; - public bool IsWithinSelectedValue - { - get => (bool)GetValue(IsWithinSelectedValueProperty); - internal set => SetValue(IsWithinValuePropertyKey, value); - } + [Obsolete("This will be removed in a future version")] + public bool IsWithinSelectedValue + { + get => (bool)GetValue(IsWithinSelectedValueProperty); + internal set => SetValue(IsWithinValuePropertyKey, value); } } \ No newline at end of file diff --git a/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.RatingBar.xaml b/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.RatingBar.xaml index bb26a98e07..d012ecad09 100644 --- a/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.RatingBar.xaml +++ b/MaterialDesignThemes.Wpf/Themes/MaterialDesignTheme.RatingBar.xaml @@ -1,12 +1,14 @@ - + xmlns:system="clr-namespace:System;assembly=mscorlib" + xmlns:converters="clr-namespace:MaterialDesignThemes.Wpf.Converters">