From db0ced2f0071e798d5ad28865a59da378cb55794 Mon Sep 17 00:00:00 2001
From: NaBian <836904362@qq.com>
Date: Mon, 15 Jun 2026 23:46:05 +0800
Subject: [PATCH] feat: Add Rate styles and demos.
---
.../Data/DemoInfo.json | 3 +-
.../UserControl/Controls/RateDemo.axaml | 35 ++
.../UserControl/Controls/RateDemo.axaml.cs | 9 +
.../Controls/Rate/Rate.cs | 316 ++++++++++++++++++
.../Controls/Rate/RateItem.cs | 201 +++++++++++
.../Data/Args/FunctionEventArgs`1.cs | 17 +
.../Themes/Styles/MergedStyles.axaml | 1 +
.../Themes/Styles/Rate.axaml | 104 ++++++
8 files changed, 685 insertions(+), 1 deletion(-)
create mode 100644 src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/RateDemo.axaml
create mode 100644 src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/RateDemo.axaml.cs
create mode 100644 src/Avalonia/HandyControl_Avalonia/Controls/Rate/Rate.cs
create mode 100644 src/Avalonia/HandyControl_Avalonia/Controls/Rate/RateItem.cs
create mode 100644 src/Avalonia/HandyControl_Avalonia/Data/Args/FunctionEventArgs`1.cs
create mode 100644 src/Avalonia/HandyControl_Avalonia/Themes/Styles/Rate.axaml
diff --git a/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json b/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json
index 3a0ecbee..a024474f 100644
--- a/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json
+++ b/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json
@@ -52,7 +52,8 @@
[ "WaterfallPanel", "WaterfallPanelDemo", "Brush.Panel", "", "Layout" ],
[ "HoneycombPanel", "HoneycombPanelDemo", "Brush.PolygonHexagon", "", "Layout" ],
[ "UniformSpacingPanel", "UniformSpacingPanelDemo", "Brush.IncreaseHorizontalSpacing", "", "Layout" ],
- [ "ProgressBar", "ProgressBarDemo", "Brush.ProgressBar", "", "Feedback" ]
+ [ "ProgressBar", "ProgressBarDemo", "Brush.ProgressBar", "", "Feedback" ],
+ [ "Rate", "RateDemo", "Brush.Favorite", "", "Feedback" ]
]
}
]
diff --git a/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/RateDemo.axaml b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/RateDemo.axaml
new file mode 100644
index 00000000..5b48ac43
--- /dev/null
+++ b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/RateDemo.axaml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/RateDemo.axaml.cs b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/RateDemo.axaml.cs
new file mode 100644
index 00000000..ac3f10b7
--- /dev/null
+++ b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/RateDemo.axaml.cs
@@ -0,0 +1,9 @@
+namespace HandyControlDemo.UserControl;
+
+public partial class RateDemo : Avalonia.Controls.UserControl
+{
+ public RateDemo()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/Avalonia/HandyControl_Avalonia/Controls/Rate/Rate.cs b/src/Avalonia/HandyControl_Avalonia/Controls/Rate/Rate.cs
new file mode 100644
index 00000000..14fdd3a4
--- /dev/null
+++ b/src/Avalonia/HandyControl_Avalonia/Controls/Rate/Rate.cs
@@ -0,0 +1,316 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using HandyControl.Data;
+
+namespace HandyControl.Controls;
+
+public class Rate : ItemsControl
+{
+ public static readonly StyledProperty AllowHalfProperty =
+ AvaloniaProperty.Register(nameof(AllowHalf), inherits: true);
+
+ public static readonly StyledProperty AllowClearProperty =
+ AvaloniaProperty.Register(nameof(AllowClear), true, inherits: true);
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon), inherits: true);
+
+ public static readonly StyledProperty CountProperty =
+ AvaloniaProperty.Register(nameof(Count), 5);
+
+ public static readonly StyledProperty DefaultValueProperty =
+ AvaloniaProperty.Register(nameof(DefaultValue));
+
+ public static readonly StyledProperty ValueProperty =
+ AvaloniaProperty.Register(nameof(Value));
+
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ public static readonly StyledProperty ShowTextProperty =
+ AvaloniaProperty.Register(nameof(ShowText));
+
+ public static readonly StyledProperty IsReadOnlyProperty =
+ AvaloniaProperty.Register(nameof(IsReadOnly));
+
+ public static readonly StyledProperty ItemWidthProperty =
+ AvaloniaProperty.Register(nameof(ItemWidth), 20);
+
+ public static readonly StyledProperty ItemHeightProperty =
+ AvaloniaProperty.Register(nameof(ItemHeight), 20);
+
+ public static readonly StyledProperty ItemMarginProperty =
+ AvaloniaProperty.Register(nameof(ItemMargin));
+
+ public static readonly RoutedEvent> ValueChangedEvent =
+ RoutedEvent.Register>(nameof(ValueChanged), RoutingStrategies.Bubble);
+
+ private readonly List _rateItems = [];
+ private bool _isLoaded;
+ private bool _isUpdatingItems;
+
+ static Rate()
+ {
+ FocusableProperty.OverrideDefaultValue(false);
+ CountProperty.Changed.AddClassHandler((rate, _) => rate.RebuildItems());
+ AllowHalfProperty.Changed.AddClassHandler((rate, _) => rate.UpdateItemProperties());
+ AllowClearProperty.Changed.AddClassHandler((rate, _) => rate.UpdateItemProperties());
+ IconProperty.Changed.AddClassHandler((rate, _) => rate.UpdateItemProperties());
+ IsReadOnlyProperty.Changed.AddClassHandler((rate, _) => rate.UpdateItemProperties());
+ BackgroundProperty.Changed.AddClassHandler((rate, _) => rate.UpdateItemProperties());
+ ItemWidthProperty.Changed.AddClassHandler((rate, _) => rate.UpdateItemProperties());
+ ItemHeightProperty.Changed.AddClassHandler((rate, _) => rate.UpdateItemProperties());
+ ItemMarginProperty.Changed.AddClassHandler((rate, _) => rate.UpdateItemProperties());
+ ForegroundProperty.Changed.AddClassHandler((rate, _) => rate.UpdateItemProperties());
+ DefaultValueProperty.Changed.AddClassHandler((rate, _) => rate.ApplyDefaultValue());
+ ValueProperty.Changed.AddClassHandler((rate, e) => rate.OnValueChanged(e));
+ }
+
+ public event EventHandler> ValueChanged
+ {
+ add => AddHandler(ValueChangedEvent, value);
+ remove => RemoveHandler(ValueChangedEvent, value);
+ }
+
+ public bool AllowHalf
+ {
+ get => GetValue(AllowHalfProperty);
+ set => SetValue(AllowHalfProperty, value);
+ }
+
+ public bool AllowClear
+ {
+ get => GetValue(AllowClearProperty);
+ set => SetValue(AllowClearProperty, value);
+ }
+
+ public Geometry? Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public int Count
+ {
+ get => GetValue(CountProperty);
+ set => SetValue(CountProperty, value);
+ }
+
+ public double DefaultValue
+ {
+ get => GetValue(DefaultValueProperty);
+ set => SetValue(DefaultValueProperty, value);
+ }
+
+ public double Value
+ {
+ get => GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public bool ShowText
+ {
+ get => GetValue(ShowTextProperty);
+ set => SetValue(ShowTextProperty, value);
+ }
+
+ public bool IsReadOnly
+ {
+ get => GetValue(IsReadOnlyProperty);
+ set => SetValue(IsReadOnlyProperty, value);
+ }
+
+ public double ItemWidth
+ {
+ get => GetValue(ItemWidthProperty);
+ set => SetValue(ItemWidthProperty, value);
+ }
+
+ public double ItemHeight
+ {
+ get => GetValue(ItemHeightProperty);
+ set => SetValue(ItemHeightProperty, value);
+ }
+
+ public Thickness ItemMargin
+ {
+ get => GetValue(ItemMarginProperty);
+ set => SetValue(ItemMarginProperty, value);
+ }
+
+ protected override void OnLoaded(RoutedEventArgs e)
+ {
+ base.OnLoaded(e);
+
+ RebuildItems();
+ _isLoaded = true;
+ ApplyDefaultValue();
+ UpdateItems();
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ UpdateItems();
+ }
+
+ public void Reset() => Value = DefaultValue;
+
+ private void OnValueChanged(AvaloniaPropertyChangedEventArgs e)
+ {
+ var value = CoerceValue(e.GetNewValue());
+ if (!value.Equals(Value))
+ {
+ SetCurrentValue(ValueProperty, value);
+ return;
+ }
+
+ SetCurrentValue(TextProperty, value.ToString("0.0", CultureInfo.CurrentCulture));
+ RaiseEvent(new FunctionEventArgs(ValueChangedEvent, this) { Info = value });
+ UpdateItems();
+ }
+
+ private double CoerceValue(double value)
+ {
+ if (double.IsNaN(value))
+ {
+ return 0;
+ }
+
+ var max = Math.Max(0, Count);
+ return Math.Clamp(value, 0, max);
+ }
+
+ private void ApplyDefaultValue()
+ {
+ if (!_isLoaded || Value > 0 || DefaultValue <= 0)
+ {
+ return;
+ }
+
+ Value = DefaultValue;
+ }
+
+ private void RebuildItems()
+ {
+ foreach (var item in _rateItems)
+ {
+ item.SelectedChanged -= RateItemSelectedChanged;
+ item.ValueChanged -= RateItemValueChanged;
+ }
+
+ _rateItems.Clear();
+ Items.Clear();
+
+ for (var i = 1; i <= Count; i++)
+ {
+ var item = new RateItem { Index = i };
+ item.SelectedChanged += RateItemSelectedChanged;
+ item.ValueChanged += RateItemValueChanged;
+ _rateItems.Add(item);
+ Items.Add(item);
+ }
+
+ UpdateItemProperties();
+ UpdateItems();
+ }
+
+ private void UpdateItemProperties()
+ {
+ foreach (var item in _rateItems)
+ {
+ item.Width = ItemWidth;
+ item.Height = ItemHeight;
+ item.Margin = ItemMargin;
+ item.AllowHalf = AllowHalf;
+ item.AllowClear = AllowClear;
+ item.Icon = Icon;
+ item.IsReadOnly = IsReadOnly;
+ item.Background = Background;
+ item.Foreground = Foreground;
+ }
+ }
+
+ private void RateItemValueChanged(object? sender, EventArgs e)
+ {
+ if (_isUpdatingItems)
+ {
+ return;
+ }
+
+ Value = _rateItems.Where(item => item.IsSelected).Sum(item => item.IsHalf ? 0.5 : 1);
+ }
+
+ private void RateItemSelectedChanged(object? sender, EventArgs e)
+ {
+ if (sender is not RateItem rateItem)
+ {
+ return;
+ }
+
+ var index = rateItem.Index;
+ _isUpdatingItems = true;
+
+ for (var i = 0; i < index - 1 && i < _rateItems.Count; i++)
+ {
+ _rateItems[i].IsSelected = true;
+ _rateItems[i].IsHalf = false;
+ }
+
+ for (var i = index; i < _rateItems.Count; i++)
+ {
+ _rateItems[i].IsSelected = false;
+ _rateItems[i].IsHalf = false;
+ }
+
+ _isUpdatingItems = false;
+ }
+
+ private void UpdateItems()
+ {
+ if (!_isLoaded)
+ {
+ return;
+ }
+
+ _isUpdatingItems = true;
+ var value = CoerceValue(Value);
+ var wholeCount = (int)value;
+
+ for (var i = 0; i < wholeCount && i < _rateItems.Count; i++)
+ {
+ _rateItems[i].IsSelected = true;
+ _rateItems[i].IsHalf = false;
+ }
+
+ if (value > wholeCount && wholeCount < _rateItems.Count)
+ {
+ _rateItems[wholeCount].IsSelected = true;
+ _rateItems[wholeCount].IsHalf = true;
+ wholeCount++;
+ }
+
+ for (var i = wholeCount; i < _rateItems.Count; i++)
+ {
+ _rateItems[i].IsSelected = false;
+ _rateItems[i].IsHalf = false;
+ }
+
+ _isUpdatingItems = false;
+ }
+}
diff --git a/src/Avalonia/HandyControl_Avalonia/Controls/Rate/RateItem.cs b/src/Avalonia/HandyControl_Avalonia/Controls/Rate/RateItem.cs
new file mode 100644
index 00000000..42ff567d
--- /dev/null
+++ b/src/Avalonia/HandyControl_Avalonia/Controls/Rate/RateItem.cs
@@ -0,0 +1,201 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Metadata;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Media;
+
+namespace HandyControl.Controls;
+
+[TemplatePart(ElementIcon, typeof(Control))]
+public class RateItem : TemplatedControl
+{
+ private const string ElementIcon = "PART_Icon";
+
+ public static readonly StyledProperty AllowClearProperty =
+ AvaloniaProperty.Register(nameof(AllowClear), true);
+
+ public static readonly StyledProperty AllowHalfProperty =
+ AvaloniaProperty.Register(nameof(AllowHalf));
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ public static readonly StyledProperty IsReadOnlyProperty =
+ AvaloniaProperty.Register(nameof(IsReadOnly));
+
+ internal static readonly StyledProperty IsSelectedProperty =
+ AvaloniaProperty.Register(nameof(IsSelected));
+
+ private Control? _icon;
+ private bool _isHalf;
+ private bool _isPointerPressed;
+ private bool _isSentValue;
+
+ public event EventHandler? SelectedChanged;
+
+ public event EventHandler? ValueChanged;
+
+ static RateItem()
+ {
+ FocusableProperty.OverrideDefaultValue(false);
+ IsSelectedProperty.Changed.AddClassHandler((item, _) => item.UpdateIcon());
+ }
+
+ public bool AllowClear
+ {
+ get => GetValue(AllowClearProperty);
+ set => SetValue(AllowClearProperty, value);
+ }
+
+ public bool AllowHalf
+ {
+ get => GetValue(AllowHalfProperty);
+ set => SetValue(AllowHalfProperty, value);
+ }
+
+ public Geometry? Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public bool IsReadOnly
+ {
+ get => GetValue(IsReadOnlyProperty);
+ set => SetValue(IsReadOnlyProperty, value);
+ }
+
+ internal bool IsSelected
+ {
+ get => GetValue(IsSelectedProperty);
+ set => SetValue(IsSelectedProperty, value);
+ }
+
+ internal bool IsHalf
+ {
+ get => _isHalf;
+ set
+ {
+ if (_isHalf == value)
+ {
+ return;
+ }
+
+ _isHalf = value;
+ UpdateIcon();
+ }
+ }
+
+ internal int Index { get; set; }
+
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+
+ _icon = e.NameScope.Find(ElementIcon);
+ UpdateIcon();
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ if (IsReadOnly)
+ {
+ return;
+ }
+
+ _isSentValue = false;
+ IsSelected = true;
+ SelectedChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ protected override void OnPointerMoved(PointerEventArgs e)
+ {
+ base.OnPointerMoved(e);
+ if (IsReadOnly || !AllowHalf)
+ {
+ return;
+ }
+
+ var point = e.GetPosition(this);
+ IsHalf = point.X < Bounds.Width / 2;
+ }
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ if (IsReadOnly || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ {
+ return;
+ }
+
+ _isPointerPressed = true;
+ e.Handled = true;
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (IsReadOnly || !_isPointerPressed)
+ {
+ return;
+ }
+
+ if (Index == 1 && AllowClear)
+ {
+ if (IsSelected)
+ {
+ if (!_isSentValue)
+ {
+ ValueChanged?.Invoke(this, EventArgs.Empty);
+ _isPointerPressed = false;
+ _isSentValue = true;
+ e.Handled = true;
+ return;
+ }
+
+ _isSentValue = false;
+ IsSelected = false;
+ IsHalf = false;
+ }
+ else
+ {
+ IsSelected = true;
+ if (AllowHalf)
+ {
+ var point = e.GetPosition(this);
+ IsHalf = point.X < Bounds.Width / 2;
+ }
+ }
+ }
+
+ ValueChanged?.Invoke(this, EventArgs.Empty);
+ _isPointerPressed = false;
+ e.Handled = true;
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ if (IsReadOnly)
+ {
+ return;
+ }
+
+ _isPointerPressed = false;
+ }
+
+ private void UpdateIcon()
+ {
+ if (_icon == null)
+ {
+ return;
+ }
+
+ _icon.IsVisible = IsSelected;
+ var width = double.IsNaN(Width) ? Bounds.Width : Width;
+ _icon.Width = IsHalf ? width / 2 : double.NaN;
+ }
+}
diff --git a/src/Avalonia/HandyControl_Avalonia/Data/Args/FunctionEventArgs`1.cs b/src/Avalonia/HandyControl_Avalonia/Data/Args/FunctionEventArgs`1.cs
new file mode 100644
index 00000000..b950b727
--- /dev/null
+++ b/src/Avalonia/HandyControl_Avalonia/Data/Args/FunctionEventArgs`1.cs
@@ -0,0 +1,17 @@
+using Avalonia.Interactivity;
+
+namespace HandyControl.Data;
+
+public class FunctionEventArgs : RoutedEventArgs
+{
+ public FunctionEventArgs(T info)
+ {
+ Info = info;
+ }
+
+ public FunctionEventArgs(RoutedEvent routedEvent, object source) : base(routedEvent, source)
+ {
+ }
+
+ public T Info { get; set; } = default!;
+}
diff --git a/src/Avalonia/HandyControl_Avalonia/Themes/Styles/MergedStyles.axaml b/src/Avalonia/HandyControl_Avalonia/Themes/Styles/MergedStyles.axaml
index 41aad7f3..28fb54b6 100644
--- a/src/Avalonia/HandyControl_Avalonia/Themes/Styles/MergedStyles.axaml
+++ b/src/Avalonia/HandyControl_Avalonia/Themes/Styles/MergedStyles.axaml
@@ -48,6 +48,7 @@
+
diff --git a/src/Avalonia/HandyControl_Avalonia/Themes/Styles/Rate.axaml b/src/Avalonia/HandyControl_Avalonia/Themes/Styles/Rate.axaml
new file mode 100644
index 00000000..feefe048
--- /dev/null
+++ b/src/Avalonia/HandyControl_Avalonia/Themes/Styles/Rate.axaml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+