From fbc37ffbd28660c67bf5a54647ec77033da4d8cd Mon Sep 17 00:00:00 2001 From: chinboy Date: Mon, 3 Nov 2025 14:26:24 +0800 Subject: [PATCH] Add Responsive maxTagCount support for Select --- .../TokenResourceConst.g.cs | 3 + src/AtomUI.Controls/Select/Select.cs | 32 ++- .../Select/SelectMaxTagAwarePanel.cs | 189 ++++++++++++++++++ .../Select/SelectResultOptionsBox.cs | 155 ++++++++++++-- src/AtomUI.Controls/Select/SelectTag.cs | 5 + src/AtomUI.Controls/Select/SelectToken.cs | 19 ++ .../Themes/SelectAddOnDecoratedBoxTheme.axaml | 22 ++ .../Themes/SelectResultOptionsBoxTheme.axaml | 17 +- .../Select/Themes/SelectTheme.axaml | 2 + .../Select/Themes/SelectThemeConstants.cs | 1 + src/AtomUI.Controls/Tag/Tag.cs | 3 +- 11 files changed, 417 insertions(+), 31 deletions(-) create mode 100644 src/AtomUI.Controls/Select/SelectMaxTagAwarePanel.cs diff --git a/src/AtomUI.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/TokenResourceConst.g.cs b/src/AtomUI.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/TokenResourceConst.g.cs index aba822709..2ed88c936 100644 --- a/src/AtomUI.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/TokenResourceConst.g.cs +++ b/src/AtomUI.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/TokenResourceConst.g.cs @@ -718,6 +718,9 @@ namespace AtomUI.Controls.DesignTokens public static readonly TokenResourceKey OptionSelectedBg = new TokenResourceKey("Select.OptionSelectedBg"); public static readonly TokenResourceKey OptionSelectedColor = new TokenResourceKey("Select.OptionSelectedColor"); public static readonly TokenResourceKey OptionSelectedFontWeight = new TokenResourceKey("Select.OptionSelectedFontWeight"); + public static readonly TokenResourceKey Padding = new TokenResourceKey("Select.Padding"); + public static readonly TokenResourceKey PaddingLG = new TokenResourceKey("Select.PaddingLG"); + public static readonly TokenResourceKey PaddingSM = new TokenResourceKey("Select.PaddingSM"); public static readonly TokenResourceKey PopupBorderRadius = new TokenResourceKey("Select.PopupBorderRadius"); public static readonly TokenResourceKey PopupBoxShadows = new TokenResourceKey("Select.PopupBoxShadows"); public static readonly TokenResourceKey PopupContentPadding = new TokenResourceKey("Select.PopupContentPadding"); diff --git a/src/AtomUI.Controls/Select/Select.cs b/src/AtomUI.Controls/Select/Select.cs index 8de5dbf34..6545b402d 100644 --- a/src/AtomUI.Controls/Select/Select.cs +++ b/src/AtomUI.Controls/Select/Select.cs @@ -89,8 +89,11 @@ public class Select : TemplatedControl, public static readonly StyledProperty IsShowMaxCountIndicatorProperty = AvaloniaProperty.Register(nameof(IsShowMaxCountIndicator)); - public static readonly StyledProperty MaxTagCountProperty = - AvaloniaProperty.Register(nameof(MaxTagCount), int.MaxValue); + public static readonly StyledProperty MaxTagCountProperty = + AvaloniaProperty.Register(nameof(MaxTagCount)); + + public static readonly StyledProperty IsResponsiveMaxTagCountProperty = + AvaloniaProperty.Register(nameof(IsResponsiveMaxTagCount)); public static readonly StyledProperty MaxTagPlaceholderProperty = AvaloniaProperty.Register(nameof(MaxTagPlaceholder)); @@ -264,12 +267,18 @@ public class Select : TemplatedControl, set => SetValue(IsShowMaxCountIndicatorProperty, value); } - public int MaxTagCount + public int? MaxTagCount { get => GetValue(MaxTagCountProperty); set => SetValue(MaxTagCountProperty, value); } + public bool? IsResponsiveMaxTagCount + { + get => GetValue(IsResponsiveMaxTagCountProperty); + set => SetValue(IsResponsiveMaxTagCountProperty, value); + } + public string? MaxTagPlaceholder { get => GetValue(MaxTagPlaceholderProperty); @@ -652,7 +661,22 @@ public class Select : TemplatedControl, } else if (PseudoClasses.Contains(StdPseudoClass.Pressed)) { - SetCurrentValue(IsDropDownOpenProperty, !IsDropDownOpen); + var clickInTagCloseButton = false; + if (e.Source is Icon icon) + { + var parent = icon.FindAncestorOfType(); + var tag = parent?.FindAncestorOfType(); + if (tag != null) + { + clickInTagCloseButton = true; + } + } + + if (!clickInTagCloseButton) + { + SetCurrentValue(IsDropDownOpenProperty, !IsDropDownOpen); + } + e.Handled = true; } } diff --git a/src/AtomUI.Controls/Select/SelectMaxTagAwarePanel.cs b/src/AtomUI.Controls/Select/SelectMaxTagAwarePanel.cs new file mode 100644 index 000000000..824bab103 --- /dev/null +++ b/src/AtomUI.Controls/Select/SelectMaxTagAwarePanel.cs @@ -0,0 +1,189 @@ +using System.Collections.Specialized; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Layout; + +namespace AtomUI.Controls; + +internal class SelectMaxTagAwarePanel : StackPanel +{ + private SelectRemainInfoTag? _infoTag; + private SelectSearchTextBox? _searchTextBox; + + private int _remainCount = 0; + + protected override Size MeasureOverride(Size availableSize) + { + var size = base.MeasureOverride(availableSize); + _remainCount = 0; + var effectiveWidth = 0d; + if (Children.Count > 0) + { + // 计算需要隐藏几个 + var i = 0; + var tagInfoWidth = _infoTag?.DesiredSize.Width ?? 0d; + var offsetX = tagInfoWidth; + for (; i < Children.Count; i++) + { + var child = Children[i]; + if (child != _infoTag) + { + offsetX += child.DesiredSize.Width + Spacing; + } + + if (offsetX > availableSize.Width) + { + break; + } + effectiveWidth += child.DesiredSize.Width + Spacing; + } + + if (offsetX > availableSize.Width) + { + _remainCount = Math.Max(0, Children.Count - 2 - i); + if (_remainCount > 0) + { + ConfigureInfoTagText(_remainCount); + } + + effectiveWidth += tagInfoWidth; + } + } + + return size.WithWidth(effectiveWidth); + } + + protected override Size ArrangeOverride(Size finalSize) + { + if (Children.Count == 2) + { + _searchTextBox?.Arrange(new Rect(0, 0, finalSize.Width, finalSize.Height)); + } + else + { + var children = Children; + bool fHorizontal = (Orientation == Orientation.Horizontal); + Rect rcChild = new Rect(finalSize); + double previousChildSize = 0.0; + var spacing = Spacing; + Control? latestChild = null; + + for (int i = 0, count = children.Count; i < count; ++i) + { + var child = children[i]; + + if (!child.IsVisible) + { + continue; + } + if (child is SelectRemainInfoTag) + { + continue; + } + + if (_remainCount > 0) + { + if (i > count - _remainCount - 3) + { + child.Arrange(new Rect(int.MaxValue, 0, child.DesiredSize.Width, child.DesiredSize.Height)); + continue; + } + } + + if (fHorizontal) + { + rcChild = rcChild.WithX(rcChild.X + previousChildSize); + previousChildSize = child.DesiredSize.Width; + rcChild = rcChild.WithWidth(previousChildSize); + rcChild = rcChild.WithHeight(Math.Max(finalSize.Height, child.DesiredSize.Height)); + previousChildSize += spacing; + } + else + { + rcChild = rcChild.WithY(rcChild.Y + previousChildSize); + previousChildSize = child.DesiredSize.Height; + rcChild = rcChild.WithHeight(previousChildSize); + rcChild = rcChild.WithWidth(Math.Max(finalSize.Width, child.DesiredSize.Width)); + previousChildSize += spacing; + } + child.Arrange(rcChild); + latestChild = child; + } + + if (_remainCount > 0 ) + { + if (_infoTag != null) + { + if (latestChild != null) { + _infoTag.Arrange(new Rect(latestChild.Bounds.Right + Spacing, latestChild.Bounds.Y, _infoTag.DesiredSize.Width, _infoTag.DesiredSize.Height)); + } + else + { + _infoTag.Arrange(new Rect(0, 0, _infoTag.DesiredSize.Width, _infoTag.DesiredSize.Height)); + } + var offsetX = _infoTag.Bounds.Right + Spacing; + var searchBoxBoundsWidth = Math.Max(0, finalSize.Width - offsetX); + _searchTextBox?.Arrange(new Rect(offsetX, _infoTag.Bounds.Y, searchBoxBoundsWidth, finalSize.Height)); + } + } + else + { + _infoTag?.Arrange(new Rect(int.MaxValue, 0, _infoTag.DesiredSize.Width, _infoTag.DesiredSize.Height)); + if (latestChild != null) + { + var offsetX = latestChild.Bounds.Right - Spacing; + var searchBoxBoundsWidth = Math.Max(0, finalSize.Width - offsetX); + _searchTextBox?.Arrange(new Rect(offsetX, latestChild.Bounds.Y, searchBoxBoundsWidth, finalSize.Height)); + } + } + + RaiseEvent(new RoutedEventArgs(Orientation == Orientation.Horizontal ? HorizontalSnapPointsChangedEvent : VerticalSnapPointsChangedEvent)); + } + + return finalSize; + } + + protected override void ChildrenChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + base.ChildrenChanged(sender, e); + if (e.OldItems != null) + { + foreach (var oldItem in e.OldItems) + { + if (oldItem is SelectRemainInfoTag) + { + _infoTag = null; + } + if (oldItem is SelectSearchTextBox) + { + _searchTextBox = null; + } + } + } + + if (e.NewItems != null) + { + foreach (var newItem in e.NewItems) + { + if (newItem is SelectRemainInfoTag selectTag) + { + _infoTag = selectTag; + ConfigureInfoTagText(0); + } + if (newItem is SelectSearchTextBox searchTextBox) + { + _searchTextBox = searchTextBox; + } + } + } + } + + private void ConfigureInfoTagText(int remainCount) + { + if (_infoTag != null) + { + _infoTag.TagText = $"+ {remainCount} ..."; + } + } +} \ No newline at end of file diff --git a/src/AtomUI.Controls/Select/SelectResultOptionsBox.cs b/src/AtomUI.Controls/Select/SelectResultOptionsBox.cs index 0658d08fd..e1ea5850f 100644 --- a/src/AtomUI.Controls/Select/SelectResultOptionsBox.cs +++ b/src/AtomUI.Controls/Select/SelectResultOptionsBox.cs @@ -11,6 +11,8 @@ namespace AtomUI.Controls; internal class SelectResultOptionsBox : TemplatedControl { + #region 公共属性定义 + public static readonly DirectProperty SelectedOptionsProperty = AvaloniaProperty.RegisterDirect( nameof(SelectedOptions), @@ -29,6 +31,12 @@ internal class SelectResultOptionsBox : TemplatedControl public static readonly StyledProperty SizeTypeProperty = SizeTypeAwareControlProperty.SizeTypeProperty.AddOwner(); + public static readonly StyledProperty MaxTagCountProperty = + Select.MaxTagCountProperty.AddOwner(); + + public static readonly StyledProperty IsResponsiveMaxTagCountProperty = + Select.IsResponsiveMaxTagCountProperty.AddOwner(); + private IList? _selectedOptions; public IList? SelectedOptions @@ -60,9 +68,42 @@ internal class SelectResultOptionsBox : TemplatedControl get => GetValue(SizeTypeProperty); set => SetValue(SizeTypeProperty, value); } + + public int? MaxTagCount + { + get => GetValue(MaxTagCountProperty); + set => SetValue(MaxTagCountProperty, value); + } + + public bool? IsResponsiveMaxTagCount + { + get => GetValue(IsResponsiveMaxTagCountProperty); + set => SetValue(IsResponsiveMaxTagCountProperty, value); + } + + #endregion + + #region 内部属性定义 + + public static readonly DirectProperty IsShowDefaultPanelProperty = + AvaloniaProperty.RegisterDirect( + nameof(IsShowDefaultPanel), + o => o.IsShowDefaultPanel, + (o, v) => o.IsShowDefaultPanel = v); + + private bool _isShowDefaultPanel; + + public bool IsShowDefaultPanel + { + get => _isShowDefaultPanel; + set => SetAndRaise(IsShowDefaultPanelProperty, ref _isShowDefaultPanel, value); + } + #endregion private WrapPanel? _defaultPanel; + private SelectMaxTagAwarePanel? _maxCountAwarePanel; private SelectSearchTextBox? _searchTextBox; + private SelectRemainInfoTag? _collapsedInfoTag; private protected readonly Dictionary _tagsBindingDisposables = new(); protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) @@ -81,6 +122,24 @@ internal class SelectResultOptionsBox : TemplatedControl { ConfigureSearchTextReadOnly(); } + else if (change.Property == IsResponsiveMaxTagCountProperty || + change.Property == MaxTagCountProperty) + { + if (IsResponsiveMaxTagCount == true) + { + SetCurrentValue(IsShowDefaultPanelProperty, false); + } + else + { + SetCurrentValue(IsShowDefaultPanelProperty, true); + } + } + else if (change.Property == IsShowDefaultPanelProperty) + { + _defaultPanel?.Children.Clear(); + _maxCountAwarePanel?.Children.Clear(); + HandleSelectedOptionsChanged(); + } } protected override void OnPointerPressed(PointerPressedEventArgs e) @@ -99,16 +158,24 @@ internal class SelectResultOptionsBox : TemplatedControl { base.OnApplyTemplate(e); _defaultPanel = e.NameScope.Find(SelectResultOptionsBoxThemeConstants.DefaultPanelPart); + _maxCountAwarePanel = e.NameScope.Find(SelectResultOptionsBoxThemeConstants.MaxCountAwarePanelPart); _searchTextBox = new SelectSearchTextBox { HorizontalAlignment = HorizontalAlignment.Stretch }; + _collapsedInfoTag = new SelectRemainInfoTag() + { + IsClosable = false + }; BindUtils.RelayBind(this, SizeTypeProperty, _searchTextBox, SizeTypeProperty); if (IsSearchEnabled) { if (Mode == SelectMode.Multiple) { - _defaultPanel?.Children.Add(_searchTextBox); + if (IsShowDefaultPanel) + { + _defaultPanel?.Children.Add(_searchTextBox); + } } } @@ -118,36 +185,80 @@ internal class SelectResultOptionsBox : TemplatedControl private void HandleSelectedOptionsChanged() { - if (_defaultPanel != null) + if (_isShowDefaultPanel) { - _searchTextBox?.Clear(); - _defaultPanel.Children.Clear(); - foreach (var entry in _tagsBindingDisposables) + if (_defaultPanel != null) { - entry.Value.Dispose(); - } - _tagsBindingDisposables.Clear(); - if (_selectedOptions != null) - { - foreach (var item in _selectedOptions) + _searchTextBox?.Clear(); + _defaultPanel.Children.Clear(); + foreach (var entry in _tagsBindingDisposables) { - if (item is SelectOption option) + entry.Value.Dispose(); + } + _tagsBindingDisposables.Clear(); + if (_selectedOptions != null) + { + foreach (var item in _selectedOptions) { - var tag = new SelectTag + if (item is SelectOption option) { - TagText = option.Header, - Option = option - }; - _tagsBindingDisposables.Add(tag, BindUtils.RelayBind(this, SizeTypeProperty, tag, SizeTypeProperty)); - _defaultPanel.Children.Add(tag); + var tag = new SelectTag + { + TagText = option.Header, + Option = option + }; + _tagsBindingDisposables.Add(tag, BindUtils.RelayBind(this, SizeTypeProperty, tag, SizeTypeProperty)); + _defaultPanel.Children.Add(tag); + } } } - } - if (_searchTextBox != null) + if (_searchTextBox != null) + { + _defaultPanel.Children.Add(_searchTextBox); + _searchTextBox.Focus(); + } + } + } + else + { + if (_maxCountAwarePanel != null) { - _defaultPanel.Children.Add(_searchTextBox); - _searchTextBox.Focus(); + _searchTextBox?.Clear(); + _maxCountAwarePanel.Children.Clear(); + foreach (var entry in _tagsBindingDisposables) + { + entry.Value.Dispose(); + } + + _tagsBindingDisposables.Clear(); + if (_selectedOptions != null) + { + foreach (var item in _selectedOptions) + { + if (item is SelectOption option) + { + var tag = new SelectTag + { + TagText = option.Header, + Option = option + }; + _tagsBindingDisposables.Add(tag, BindUtils.RelayBind(this, SizeTypeProperty, tag, SizeTypeProperty)); + _maxCountAwarePanel.Children.Add(tag); + } + } + } + + if (_collapsedInfoTag != null) + { + _maxCountAwarePanel.Children.Add(_collapsedInfoTag); + } + + if (_searchTextBox != null) + { + _maxCountAwarePanel.Children.Add(_searchTextBox); + _searchTextBox.Focus(); + } } } } diff --git a/src/AtomUI.Controls/Select/SelectTag.cs b/src/AtomUI.Controls/Select/SelectTag.cs index d2e35623d..143ec3d25 100644 --- a/src/AtomUI.Controls/Select/SelectTag.cs +++ b/src/AtomUI.Controls/Select/SelectTag.cs @@ -24,4 +24,9 @@ internal class SelectTag : Tag CloseButton.IsPassthroughMouseEvent = true; } } +} + +internal class SelectRemainInfoTag : SelectTag +{ + protected override Type StyleKeyOverride { get; } = typeof(SelectTag); } \ No newline at end of file diff --git a/src/AtomUI.Controls/Select/SelectToken.cs b/src/AtomUI.Controls/Select/SelectToken.cs index e35e184f2..29ebf6ce4 100644 --- a/src/AtomUI.Controls/Select/SelectToken.cs +++ b/src/AtomUI.Controls/Select/SelectToken.cs @@ -126,6 +126,21 @@ internal class SelectToken : AbstractControlDesignToken /// public Thickness MultiModePaddingLG { get; set; } + /// + /// 输入框内边距 + /// + public Thickness Padding { get; set; } + + /// + /// 小号输入框内边距 + /// + public Thickness PaddingSM { get; set; } + + /// + /// 多选模式下的大号输入框内边距 + /// + public Thickness PaddingLG { get; set; } + public SelectToken() : base(ID) { @@ -180,5 +195,9 @@ internal class SelectToken : AbstractControlDesignToken var multiPaddingRightLG = SharedToken.ControlPaddingHorizontal - lineWidth; MultiModePaddingLG = new Thickness(multiPaddingVertical, multiPaddingVertical, multiPaddingRightLG, multiPaddingVertical); + + Padding = new Thickness(multiPaddingRight, multiPaddingVertical); + PaddingSM = new Thickness(multiPaddingRightSM, multiPaddingVertical); + PaddingLG = new Thickness(multiPaddingRightLG, multiPaddingVertical); } } \ No newline at end of file diff --git a/src/AtomUI.Controls/Select/Themes/SelectAddOnDecoratedBoxTheme.axaml b/src/AtomUI.Controls/Select/Themes/SelectAddOnDecoratedBoxTheme.axaml index 3e4e46eb2..d9986e0da 100644 --- a/src/AtomUI.Controls/Select/Themes/SelectAddOnDecoratedBoxTheme.axaml +++ b/src/AtomUI.Controls/Select/Themes/SelectAddOnDecoratedBoxTheme.axaml @@ -32,6 +32,28 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/AtomUI.Controls/Select/Themes/SelectTheme.axaml b/src/AtomUI.Controls/Select/Themes/SelectTheme.axaml index 5a03fe61f..b23678e8e 100644 --- a/src/AtomUI.Controls/Select/Themes/SelectTheme.axaml +++ b/src/AtomUI.Controls/Select/Themes/SelectTheme.axaml @@ -80,6 +80,8 @@ SelectedOptions="{TemplateBinding SelectedOptions}" IsSearchEnabled="{TemplateBinding IsSearchEnabled}" IsDropDownOpen="{TemplateBinding IsDropDownOpen}" + MaxTagCount="{TemplateBinding MaxTagCount}" + IsResponsiveMaxTagCount="{TemplateBinding IsResponsiveMaxTagCount}" IsVisible="False" Focusable="True"/> diff --git a/src/AtomUI.Controls/Select/Themes/SelectThemeConstants.cs b/src/AtomUI.Controls/Select/Themes/SelectThemeConstants.cs index 438c2bda1..d575acc8f 100644 --- a/src/AtomUI.Controls/Select/Themes/SelectThemeConstants.cs +++ b/src/AtomUI.Controls/Select/Themes/SelectThemeConstants.cs @@ -15,4 +15,5 @@ internal static class SelectThemeConstants internal static class SelectResultOptionsBoxThemeConstants { public const string DefaultPanelPart = "PART_DefaultPanel"; + public const string MaxCountAwarePanelPart = "PART_MaxCountAwarePanel"; } \ No newline at end of file diff --git a/src/AtomUI.Controls/Tag/Tag.cs b/src/AtomUI.Controls/Tag/Tag.cs index b8d99f5b4..08c431238 100644 --- a/src/AtomUI.Controls/Tag/Tag.cs +++ b/src/AtomUI.Controls/Tag/Tag.cs @@ -183,7 +183,8 @@ public class Tag : TemplatedControl, IControlSharedTokenResourcesHost StatusColorMap = new Dictionary(); AffectsMeasure(BorderedProperty, IconProperty, - IsClosableProperty); + IsClosableProperty, + TagTextProperty); AffectsRender(TagColorProperty, ForegroundProperty, BackgroundProperty,