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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +