diff --git a/src/CommunityToolkit.Maui.Core/Primitives/Defaults/RatingViewDefaults.shared.cs b/src/CommunityToolkit.Maui.Core/Primitives/Defaults/RatingViewDefaults.shared.cs new file mode 100644 index 0000000000..c4b9eca58d --- /dev/null +++ b/src/CommunityToolkit.Maui.Core/Primitives/Defaults/RatingViewDefaults.shared.cs @@ -0,0 +1,52 @@ +namespace CommunityToolkit.Maui.Core; + +/// +/// Default Values for RatingView +/// +public static class RatingViewDefaults +{ + /// + /// Default value for + /// + public const int CurrentRating = 0; + + /// + /// Default value for + /// + public const int MaximumRating = 5; + + /// + /// Default value for + /// + public const double Size = 20.0; + + /// + /// Default value for + /// + public const double Spacing = 10.0; + + /// + /// Default value for + /// + public const double StrokeThickness = 7.0; + + /// + /// Default value for + /// + public const bool IsEnabled = true; + + /// + /// Default value for + /// + public static Color FilledBackgroundColor { get; } = Colors.Yellow; + + /// + /// Default value for + /// + public static Color EmptyBackgroundColor { get; } = Colors.Transparent; + + /// + /// Default value for + /// + public static Color StrokeColor { get; } = Colors.Grey; +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui.Core/Primitives/Rating.cs b/src/CommunityToolkit.Maui.Core/Primitives/Rating.cs new file mode 100644 index 0000000000..eabf61e612 --- /dev/null +++ b/src/CommunityToolkit.Maui.Core/Primitives/Rating.cs @@ -0,0 +1,11 @@ +namespace CommunityToolkit.Maui.Core.Primitives; + +/// +public class Rating +{ + /// + public double? CurrentRating { get; set; } + + /// + public object? CommandParameter { get; set; } +} diff --git a/src/CommunityToolkit.Maui.Core/Primitives/RatingShape.cs b/src/CommunityToolkit.Maui.Core/Primitives/RatingShape.cs new file mode 100644 index 0000000000..029ea893d4 --- /dev/null +++ b/src/CommunityToolkit.Maui.Core/Primitives/RatingShape.cs @@ -0,0 +1,49 @@ +namespace CommunityToolkit.Maui.Core.Primitives; + +/// +/// The type of the shape to display on the rating view +/// +public readonly struct RatingShape(string pathData) +{ + /// + /// the data path data from the svg + /// + public string PathData { get; } = pathData; + + /// + /// the default star shape + /// + public static RatingShape Star { get; } = new(PathShapes.Star); + + /// + /// The default heart shape + /// + public static RatingShape Heart { get; } = new(PathShapes.Heart); + + /// + /// The circle shape + /// + public static RatingShape Circle { get; } = new(PathShapes.Circle); + + /// + /// The Like shapes + /// + public static RatingShape Like { get; } = new(PathShapes.Like); + + /// + /// The Dislike + /// + public static RatingShape Dislike { get; } = new(PathShapes.Dislike); +} + +/// +/// +/// +static class PathShapes +{ + public const string Star = "M9 11.3l3.71 2.7-1.42-4.36L15 7h-4.55L9 2.5 7.55 7H3l3.71 2.64L5.29 14z"; + public const string Heart = "M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"; + public const string Circle = "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"; + public const string Like = "M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-1.91l-.01-.01L23 10z"; + public const string Dislike = "M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v1.91l.01.01L1 14c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"; +} diff --git a/src/CommunityToolkit.Maui.UnitTests/Views/RatingView/RatingViewTests.cs b/src/CommunityToolkit.Maui.UnitTests/Views/RatingView/RatingViewTests.cs new file mode 100644 index 0000000000..c3d8726b63 --- /dev/null +++ b/src/CommunityToolkit.Maui.UnitTests/Views/RatingView/RatingViewTests.cs @@ -0,0 +1,162 @@ +using CommunityToolkit.Maui.Core; +using CommunityToolkit.Maui.Core.Primitives; +using CommunityToolkit.Maui.Core.Primitives.Defaults; +using FluentAssertions; +using Microsoft.Maui.Controls.Shapes; +using Xunit; +using Path = Microsoft.Maui.Controls.Shapes.Path; + +namespace CommunityToolkit.Maui.UnitTests.Views.RatingView; + +public class RatingViewTests : BaseHandlerTest +{ + [Fact] + public void DefaultInitialization_ShouldHaveCorrectDefaultValues() + { + // Arrange + var ratingView = new Maui.Views.RatingView.RatingView(); + + // Assert + Assert.Equal(RatingViewDefaults.CurrentRating, ratingView.CurrentRating); + Assert.Equal(RatingViewDefaults.MaximumRating, ratingView.MaximumRating); + Assert.Equal(RatingViewDefaults.Size, ratingView.Size); + Assert.Equal(RatingViewDefaults.FilledBackgroundColor, ratingView.FilledBackgroundColor); + Assert.Equal(RatingViewDefaults.EmptyBackgroundColor, ratingView.EmptyBackgroundColor); + Assert.Equal(RatingViewDefaults.StrokeThickness, ratingView.StrokeThickness); + Assert.Equal(RatingViewDefaults.Spacing, ratingView.Spacing); + Assert.Equal(RatingViewDefaults.IsEnabled, ratingView.IsEnabled); + } + + [Fact] + public void OnControlInitialized_ShouldCreateCorrectNumberOfShapes() + { + // Arrange + var ratingView = new Maui.Views.RatingView.RatingView(); + ratingView.MaximumRating = 3; + + // Act + ratingView.InitializeShape(); + + // Assert + ratingView.Control?.ColumnDefinitions.Count.Should().Be(3); + ratingView.Control?.Children.Count.Should().Be(3); + } + + [Fact] + public void PropertyChangedEvent_ShouldBeRaised_WhenCurrentRatingChanges() + { + // Arrange + var ratingView = new Maui.Views.RatingView.RatingView(); + bool eventRaised = false; + ratingView.PropertyChanged += (_, e) => { eventRaised = true; }; + + // Act + ratingView.CurrentRating = 3.5; + + // Assert + eventRaised.Should().BeTrue(); + } + + [Fact] + public void Draw_ShouldCreateCorrectNumberOfShapes() + { + // Arrange + var ratingView = new Maui.Views.RatingView.RatingView + { + MaximumRating = 3 + }; + + // Assert + ratingView.Control?.ColumnDefinitions.Count.Should().Be(3); + ratingView.Control?.Children.Count.Should().Be(3); + } + + [Fact] + public void ReDraw_ShouldRecreateShapes() + { + // Arrange + var ratingView = new Maui.Views.RatingView.RatingView + { + MaximumRating = 3 + }; + + // Act + ratingView.ReDraw(); + + // Assert + ratingView.Control?.ColumnDefinitions.Count.Should().Be(3); + ratingView.Control?.Children.Count.Should().Be(3); + } + + [Fact] + public void UpdateDraw_ShouldUpdateShapesCorrectly() + { + // Arrange + var ratingView = new Maui.Views.RatingView.RatingView + { + MaximumRating = 3, + CurrentRating = 2.5 + }; + + // Act + ratingView.UpdateDraw(); + + // Assert + ratingView.Control?.Children.Count.Should().Be(3); +#pragma warning disable CS8602 // Dereference of a possibly null reference. + (ratingView.Control?.Children[0] as Path).Fill.Should() + .BeEquivalentTo(Brush.Yellow); + (ratingView.Control?.Children[0] as Path).Stroke.Should() + .BeEquivalentTo(Brush.Yellow); +#pragma warning restore CS8602 // Dereference of a possibly null reference. + } + + [Fact] + public void InitializeShape_ShouldSetShapeAndControlColumnSpacing() + { + // Arrange + var ratingView = new Maui.Views.RatingView.RatingView(); + + // Act + ratingView.InitializeShape(); + + // Assert + ratingView.Shape.Should().NotBeNull(); + ratingView.Control?.ColumnSpacing.Should().Be(ratingView.Spacing); + } + + [Fact] + public void OnShapeTapped_ShouldUpdateCurrentRatingCorrectly() + { + // Arrange + var ratingView = new Maui.Views.RatingView.RatingView + { + MaximumRating = 3, + IsEnabled = true + }; + + Path tappedShape = new() + { + Data = (Geometry?)new PathGeometryConverter().ConvertFromInvariantString(RatingShape.Star.PathData) + }; + + // Act + ratingView.OnShapeTapped(tappedShape, null); + + // Assert + ratingView.CurrentRating.Should().Be(1); + } + + [Fact] + public void ShapeProperty_ShouldSetShapeCorrectly() + { + // Arrange + var ratingView = new Maui.Views.RatingView.RatingView(); + + // Act + ratingView.Shape = RatingShape.Heart; + + // Assert + ratingView.Shape.Should().Be(RatingShape.Heart); + } +} \ No newline at end of file diff --git a/src/CommunityToolkit.Maui/CommunityToolkit.Maui.csproj b/src/CommunityToolkit.Maui/CommunityToolkit.Maui.csproj index 858cb1cc9e..fd6dcc2070 100644 --- a/src/CommunityToolkit.Maui/CommunityToolkit.Maui.csproj +++ b/src/CommunityToolkit.Maui/CommunityToolkit.Maui.csproj @@ -63,8 +63,8 @@ - - + + diff --git a/src/CommunityToolkit.Maui/Views/RatingView/RatingView.Shared.cs b/src/CommunityToolkit.Maui/Views/RatingView/RatingView.Shared.cs new file mode 100644 index 0000000000..b493fe914a --- /dev/null +++ b/src/CommunityToolkit.Maui/Views/RatingView/RatingView.Shared.cs @@ -0,0 +1,346 @@ +using CommunityToolkit.Maui.Core; +using CommunityToolkit.Maui.Core.Primitives; +using Microsoft.Maui.Controls.Shapes; + +namespace CommunityToolkit.Maui.Views.RatingView; + +/// RatingView control. +public class RatingView : TemplatedView +{ + /// Rating value bindable property + public static readonly BindableProperty CurrentRatingProperty = BindableProperty.Create(nameof(CurrentRating), + typeof(double), typeof(RatingView), defaultValue: RatingViewDefaults.CurrentRating, propertyChanged: OnBindablePropertyChanged); + + + /// MaximumRating rating value Bindable property + public static readonly BindableProperty MaximumRatingProperty = BindableProperty.Create(nameof(MaximumRating), + typeof(int), typeof(RatingView), defaultValue: RatingViewDefaults.MaximumRating, propertyChanged: OnBindablePropertyChanged); + + + /// Shape size Bindable property + public static readonly BindableProperty SizeProperty = BindableProperty.Create(nameof(Size), typeof(double), + typeof(RatingView), defaultValue: RatingViewDefaults.Size, propertyChanged: OnBindablePropertyChanged); + + + /// Shape filled Color Bindable property. + public static readonly BindableProperty FilledBackgroundColorProperty = BindableProperty.Create(nameof(FilledBackgroundColor), + typeof(Color), typeof(RatingView), defaultValue: RatingViewDefaults.FilledBackgroundColor, propertyChanged: OnBindablePropertyChanged); + + ///Shape empty Color Bindable property. + public static readonly BindableProperty EmptyBackgroundColorProperty = BindableProperty.Create(nameof(EmptyBackgroundColor), + typeof(Color), typeof(RatingView), defaultValue: RatingViewDefaults.EmptyBackgroundColor, propertyChanged: OnBindablePropertyChanged); + + ///Shape stroke color bindable property. + public static readonly BindableProperty StrokeColorProperty = BindableProperty.Create(nameof(StrokeColor), + typeof(Color), typeof(RatingView), defaultValue: RatingViewDefaults.StrokeColor, propertyChanged: OnBindablePropertyChanged); + + ///Shapes space between bindable property. + public static readonly BindableProperty SpacingProperty = BindableProperty.Create(nameof(Spacing), typeof(double), + typeof(RatingView), defaultValue: RatingViewDefaults.Spacing, propertyChanged: OnBindablePropertyChanged); + + ///Shape clickable property. + public static new readonly BindableProperty IsEnabledProperty = BindableProperty.Create(nameof(IsEnabled), + typeof(bool), typeof(RatingView), defaultValue: false, propertyChanged: OnIsEnabledChanged); + + ///The shape to be drawn bindable property. + public readonly BindableProperty ShapeProperty = BindableProperty.Create(nameof(Shape), typeof(RatingShape), + typeof(RatingView), propertyChanged: OnShapePropertyChanged); + + ///Shape stroke thickness bindable property. + public static readonly BindableProperty StrokeThicknessProperty = BindableProperty.Create(nameof(StrokeThickness), + typeof(double), typeof(RatingView), defaultValue: RatingViewDefaults.StrokeThickness, propertyChanged: OnBindablePropertyChanged); + + Microsoft.Maui.Controls.Shapes.Path[] shapes; + + string shape = string.Empty; + + ///The default constructor of the control. + public RatingView() + { + ControlTemplate = new ControlTemplate(typeof(Grid)); + + shapes = new Microsoft.Maui.Controls.Shapes.Path[MaximumRating]; + + HorizontalOptions = LayoutOptions.CenterAndExpand; + + InitializeShape(); + + Draw(); + } + + ///Method called everytime the control's Binding Context is changed. + protected override void OnBindingContextChanged() + { + base.OnBindingContextChanged(); + + if (Control is not null) + { + Control.BindingContext = BindingContext; + } + } + + ///Called everytime a child is added to the control. + protected override void OnChildAdded(Element child) + { + if (Control is null && child is Grid grid) + { + Control = grid; + OnControlInitialized(); + } + + base.OnChildAdded(child); + } + + ///Defines the current rating value. + public double CurrentRating + { + get => (double)GetValue(CurrentRatingProperty); + set => SetValue(CurrentRatingProperty, value); + } + + ///Defines the maximum value allowed for the rating. + public int MaximumRating + { + get => (int)GetValue(MaximumRatingProperty); + set => SetValue(MaximumRatingProperty, value); + } + + ///Defines the size of each drawn shapes. + public double Size + { + get => (double)GetValue(SizeProperty); + set => SetValue(SizeProperty, value); + } + + ///Defines the color to fill with the drawn shape. + public Color FilledBackgroundColor + { + get => (Color)GetValue(FilledBackgroundColorProperty); + set => SetValue(FilledBackgroundColorProperty, value); + } + + ///Defines the color of the drawn shape is not filled. + public Color EmptyBackgroundColor + { + get => (Color)GetValue(EmptyBackgroundColorProperty); + set => SetValue(EmptyBackgroundColorProperty, value); + } + + ///Defines the color of the shape's stroke(border). + public Color StrokeColor + { + get => (Color)GetValue(StrokeColorProperty); + set => SetValue(StrokeColorProperty, value); + } + + ///Defines the thickness of the shape's stroke(border). + public double StrokeThickness + { + get => (double)GetValue(StrokeThicknessProperty); + set => SetValue(StrokeThicknessProperty, value); + } + + ///Defines the space between the drawn shapes. + public double Spacing + { + get => (double)GetValue(SpacingProperty); + set => SetValue(SpacingProperty, value); + } + + ///Defines if the drawn shapes can be clickable to rate. + public new bool IsEnabled + { + get => (bool)GetValue(IsEnabledProperty); + set => SetValue(IsEnabledProperty, value); + } + + ///Defines the shape to be drawn. + public RatingShape Shape + { + get => (RatingShape)GetValue(ShapeProperty); + set => SetValue(ShapeProperty, value); + } + + ///The control to be displayed + public Grid? Control { get; private set; } + + static void OnIsEnabledChanged(BindableObject bindable, object oldValue, object newValue) + { + var ratingView = (RatingView)bindable; + ratingView.HandleIsEnabledChanged(); + ratingView.ReDraw(); + } + + static void OnBindablePropertyChanged(BindableObject bindable, object oldValue, object newValue) + { + ((RatingView)bindable).ReDraw(); + } + + static void OnShapePropertyChanged(BindableObject bindable, object oldValue, object newValue) + { + ((RatingView)bindable).InitializeShape(); + ((RatingView)bindable).ReDraw(); + } + + void OnShapeTapped(object? sender, TappedEventArgs? e) + { + if (sender is not Microsoft.Maui.Controls.Shapes.Path tappedShape) + { + return; + } + + if (Control is not null) + { + int columnIndex = Control.GetColumn(tappedShape); + + if (MaximumRating > 1) + { + CurrentRating = columnIndex + 1; + } + } + } + + void Draw() + { + for (int i = 0; i < MaximumRating; i++) + { + Control?.ColumnDefinitions.Add(new ColumnDefinition { Width = Size }); + + Microsoft.Maui.Controls.Shapes.Path image = new() + { + Data = (Geometry?)new PathGeometryConverter().ConvertFromInvariantString(shape) + }; + + if (i <= CurrentRating) + { + image.Fill = FilledBackgroundColor; + image.Stroke = FilledBackgroundColor; + image.Aspect = Stretch.Uniform; + image.HeightRequest = Size; + image.WidthRequest = Size; + } + else + { + image.Fill = EmptyBackgroundColor; + image.Stroke = StrokeColor; + + image.StrokeLineJoin = PenLineJoin.Round; + image.StrokeLineCap = PenLineCap.Round; + image.StrokeThickness = StrokeThickness; + + image.Aspect = Stretch.Uniform; + image.HeightRequest = Size; + image.WidthRequest = Size; + } + + + if (IsEnabled) + { + var tapGestureRecognizer = new TapGestureRecognizer(); + tapGestureRecognizer.Tapped += OnShapeTapped; + image.GestureRecognizers.Add(tapGestureRecognizer); + } + + Control?.Children.Add(image); + Control?.SetColumn(image, i); + + shapes[i] = image; + } + + + UpdateDraw(); + } + + void ReDraw() + { + Control?.Children.Clear(); + + Control?.ColumnDefinitions.Clear(); + + shapes = new Microsoft.Maui.Controls.Shapes.Path[MaximumRating]; + + Draw(); + + UpdateDraw(); + } + + void UpdateDraw() + { + for (int i = 0; i < MaximumRating; i++) + { + var image = shapes[i]; + + if (CurrentRating >= i + 1) + { + image.HeightRequest = Size; + image.WidthRequest = Size; + image.StrokeLineJoin = PenLineJoin.Round; + image.StrokeThickness = StrokeThickness; + image.Stroke = FilledBackgroundColor; + } + else + { + image.HeightRequest = Size; + image.WidthRequest = Size; + if (CurrentRating % 1 is 0) + { + image.Fill = EmptyBackgroundColor; + image.Stroke = StrokeColor; + image.StrokeThickness = StrokeThickness; + image.StrokeLineJoin = PenLineJoin.Round; + image.StrokeLineCap = PenLineCap.Round; + } + else + { + var fraction = CurrentRating - Math.Floor(CurrentRating); + var element = shapes[(int)(CurrentRating - fraction)]; + { + var colors = new GradientStopCollection + { + new(FilledBackgroundColor, (float)fraction), + new(EmptyBackgroundColor, (float)fraction) + }; + + element.Fill = + new LinearGradientBrush(colors, new Point(0, 0), new Point(1, 0)); + element.StrokeThickness = StrokeThickness; + element.StrokeLineJoin = PenLineJoin.Round; + element.Stroke = StrokeColor; + } + } + } + } + } + + void InitializeShape() + { + if (Control is not null) + { + Control.ColumnSpacing = Spacing; + } + + StrokeColor = Colors.Transparent; + + shape = Shape.PathData; + } + + // Ensure VisualElement.IsEnabled always matches RatingView.IsEnabled + void HandleIsEnabledChanged() + { + base.IsEnabled = IsEnabled; + } + + void OnControlInitialized() + { + shapes = new Microsoft.Maui.Controls.Shapes.Path[MaximumRating]; + + HorizontalOptions = LayoutOptions.CenterAndExpand; + + if (Control is not null) + { + Control.ColumnSpacing = Spacing; + } + + Draw(); + } +} \ No newline at end of file