feat: Add Rate styles and demos.

This commit is contained in:
NaBian committed 2026-06-15 23:46:05 +08:00
1 parent 4d5f453895
commit db0ced2f00
8 files changed
+685 -1

No files matched your search

@@ -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" ]
]
}
]
@@ -0,0 +1,35 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:hc="https://handyorg.github.io/handycontrol"
x:Class="HandyControlDemo.UserControl.RateDemo"
Background="{DynamicResource RegionBrush}">
<TransitioningContentControl>
<StackPanel Margin="32"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Width="190">
<hc:Rate Value="2" />
<hc:Rate Value="5"
Count="6"
Margin="0,16,0,0"
Foreground="{DynamicResource SuccessBrush}" />
<hc:Rate DefaultValue="3"
IsReadOnly="True"
AllowHalf="True"
Margin="0,16,0,0"
Foreground="{DynamicResource WarningBrush}" />
<hc:Rate DefaultValue="1"
AllowClear="False"
AllowHalf="True"
Margin="0,16,0,0"
Foreground="{DynamicResource DangerBrush}"
Icon="{StaticResource LoveGeometry}" />
<hc:Rate Value="4.5"
AllowHalf="True"
ShowText="True"
Margin="0,16,0,0"
Foreground="{DynamicResource DangerBrush}"
Icon="{StaticResource LoveGeometry}" />
</StackPanel>
</TransitioningContentControl>
</UserControl>
@@ -0,0 +1,9 @@
namespace HandyControlDemo.UserControl;
public partial class RateDemo : Avalonia.Controls.UserControl
{
public RateDemo()
{
InitializeComponent();
}
}
@@ -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<bool> AllowHalfProperty =
AvaloniaProperty.Register<Rate, bool>(nameof(AllowHalf), inherits: true);
public static readonly StyledProperty<bool> AllowClearProperty =
AvaloniaProperty.Register<Rate, bool>(nameof(AllowClear), true, inherits: true);
public static readonly StyledProperty<Geometry?> IconProperty =
AvaloniaProperty.Register<Rate, Geometry?>(nameof(Icon), inherits: true);
public static readonly StyledProperty<int> CountProperty =
AvaloniaProperty.Register<Rate, int>(nameof(Count), 5);
public static readonly StyledProperty<double> DefaultValueProperty =
AvaloniaProperty.Register<Rate, double>(nameof(DefaultValue));
public static readonly StyledProperty<double> ValueProperty =
AvaloniaProperty.Register<Rate, double>(nameof(Value));
public static readonly StyledProperty<string?> TextProperty =
AvaloniaProperty.Register<Rate, string?>(nameof(Text));
public static readonly StyledProperty<bool> ShowTextProperty =
AvaloniaProperty.Register<Rate, bool>(nameof(ShowText));
public static readonly StyledProperty<bool> IsReadOnlyProperty =
AvaloniaProperty.Register<Rate, bool>(nameof(IsReadOnly));
public static readonly StyledProperty<double> ItemWidthProperty =
AvaloniaProperty.Register<Rate, double>(nameof(ItemWidth), 20);
public static readonly StyledProperty<double> ItemHeightProperty =
AvaloniaProperty.Register<Rate, double>(nameof(ItemHeight), 20);
public static readonly StyledProperty<Thickness> ItemMarginProperty =
AvaloniaProperty.Register<Rate, Thickness>(nameof(ItemMargin));
public static readonly RoutedEvent<FunctionEventArgs<double>> ValueChangedEvent =
RoutedEvent.Register<Rate, FunctionEventArgs<double>>(nameof(ValueChanged), RoutingStrategies.Bubble);
private readonly List<RateItem> _rateItems = [];
private bool _isLoaded;
private bool _isUpdatingItems;
static Rate()
{
FocusableProperty.OverrideDefaultValue<Rate>(false);
CountProperty.Changed.AddClassHandler<Rate>((rate, _) => rate.RebuildItems());
AllowHalfProperty.Changed.AddClassHandler<Rate>((rate, _) => rate.UpdateItemProperties());
AllowClearProperty.Changed.AddClassHandler<Rate>((rate, _) => rate.UpdateItemProperties());
IconProperty.Changed.AddClassHandler<Rate>((rate, _) => rate.UpdateItemProperties());
IsReadOnlyProperty.Changed.AddClassHandler<Rate>((rate, _) => rate.UpdateItemProperties());
BackgroundProperty.Changed.AddClassHandler<Rate>((rate, _) => rate.UpdateItemProperties());
ItemWidthProperty.Changed.AddClassHandler<Rate>((rate, _) => rate.UpdateItemProperties());
ItemHeightProperty.Changed.AddClassHandler<Rate>((rate, _) => rate.UpdateItemProperties());
ItemMarginProperty.Changed.AddClassHandler<Rate>((rate, _) => rate.UpdateItemProperties());
ForegroundProperty.Changed.AddClassHandler<Rate>((rate, _) => rate.UpdateItemProperties());
DefaultValueProperty.Changed.AddClassHandler<Rate>((rate, _) => rate.ApplyDefaultValue());
ValueProperty.Changed.AddClassHandler<Rate>((rate, e) => rate.OnValueChanged(e));
}
public event EventHandler<FunctionEventArgs<double>> 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<double>());
if (!value.Equals(Value))
{
SetCurrentValue(ValueProperty, value);
return;
}
SetCurrentValue(TextProperty, value.ToString("0.0", CultureInfo.CurrentCulture));
RaiseEvent(new FunctionEventArgs<double>(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;
}
}
@@ -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<bool> AllowClearProperty =
AvaloniaProperty.Register<RateItem, bool>(nameof(AllowClear), true);
public static readonly StyledProperty<bool> AllowHalfProperty =
AvaloniaProperty.Register<RateItem, bool>(nameof(AllowHalf));
public static readonly StyledProperty<Geometry?> IconProperty =
AvaloniaProperty.Register<RateItem, Geometry?>(nameof(Icon));
public static readonly StyledProperty<bool> IsReadOnlyProperty =
AvaloniaProperty.Register<RateItem, bool>(nameof(IsReadOnly));
internal static readonly StyledProperty<bool> IsSelectedProperty =
AvaloniaProperty.Register<RateItem, bool>(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<RateItem>(false);
IsSelectedProperty.Changed.AddClassHandler<RateItem>((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<Control>(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;
}
}
@@ -0,0 +1,17 @@
using Avalonia.Interactivity;
namespace HandyControl.Data;
public class FunctionEventArgs<T> : RoutedEventArgs
{
public FunctionEventArgs(T info)
{
Info = info;
}
public FunctionEventArgs(RoutedEvent routedEvent, object source) : base(routedEvent, source)
{
}
public T Info { get; set; } = default!;
}
@@ -48,6 +48,7 @@
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/TreeView.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/Label.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/Slider.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/Rate.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/Expander.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/ProgressBar.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/Calendar.axaml" />
@@ -0,0 +1,104 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:ClassModifier="internal"
xmlns:hc="clr-namespace:HandyControl.Controls">
<ControlTheme x:Key="RateItemBaseStyle"
TargetType="hc:RateItem">
<Setter Property="Focusable"
Value="False" />
<Setter Property="ClipToBounds"
Value="False" />
<Setter Property="RenderTransformOrigin"
Value="50%,50%" />
<Setter Property="RenderTransform">
<ScaleTransform ScaleX="1"
ScaleY="1" />
</Setter>
<Setter Property="Transitions">
<Transitions>
<TransformOperationsTransition Property="RenderTransform"
Duration="0:0:0.12" />
</Transitions>
</Setter>
<Setter Property="Template">
<ControlTemplate>
<Panel Background="Transparent">
<Path Width="{TemplateBinding Width}"
Height="{TemplateBinding Height}"
Data="{TemplateBinding Icon}"
Fill="{TemplateBinding Background}"
Stretch="Uniform" />
<Border x:Name="PART_Icon"
HorizontalAlignment="Left"
VerticalAlignment="Top"
ClipToBounds="True"
IsVisible="False">
<Path Width="{TemplateBinding Width}"
Height="{TemplateBinding Height}"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Data="{TemplateBinding Icon}"
Fill="{TemplateBinding Foreground}"
Stretch="Uniform" />
</Border>
</Panel>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover">
<Setter Property="RenderTransform">
<ScaleTransform ScaleX="1.1"
ScaleY="1.1" />
</Setter>
</Style>
</ControlTheme>
<ControlTheme x:Key="RateBaseStyle"
TargetType="hc:Rate">
<Setter Property="Focusable"
Value="False" />
<Setter Property="ItemWidth"
Value="20" />
<Setter Property="ItemHeight"
Value="20" />
<Setter Property="ItemMargin"
Value="5,0" />
<Setter Property="HorizontalAlignment"
Value="Left" />
<Setter Property="VerticalAlignment"
Value="Top" />
<Setter Property="Icon"
Value="{StaticResource StarGeometry}" />
<Setter Property="Foreground"
Value="{DynamicResource PrimaryBrush}" />
<Setter Property="Background"
Value="{DynamicResource SecondaryRegionBrush}" />
<Setter Property="ItemContainerTheme"
Value="{StaticResource RateItemBaseStyle}" />
<Setter Property="ClipToBounds"
Value="False" />
<Setter Property="ItemsPanel">
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"
Margin="-5,0" />
</ItemsPanelTemplate>
</Setter>
<Setter Property="Template">
<ControlTemplate>
<StackPanel Background="Transparent"
Orientation="Horizontal">
<ItemsPresenter ItemsPanel="{TemplateBinding ItemsPanel}" />
<TextBlock Text="{TemplateBinding Text}"
Margin="10,0,0,0"
VerticalAlignment="Center"
IsVisible="{TemplateBinding ShowText}" />
</StackPanel>
</ControlTemplate>
</Setter>
</ControlTheme>
<ControlTheme x:Key="{x:Type hc:Rate}"
BasedOn="{StaticResource RateBaseStyle}"
TargetType="hc:Rate" />
</ResourceDictionary>