diff --git a/controlgallery/AtomUIGallery/ShowCases/ShowCaseRegister.cs b/controlgallery/AtomUIGallery/ShowCases/ShowCaseRegister.cs index dda8ecd1d..77ac22e5d 100644 --- a/controlgallery/AtomUIGallery/ShowCases/ShowCaseRegister.cs +++ b/controlgallery/AtomUIGallery/ShowCases/ShowCaseRegister.cs @@ -23,5 +23,6 @@ public sealed class ShowCaseViewModule : IViewModule locator.Map(() => new SpinShowCase()); locator.Map(() => new FlexPanelShowCase()); locator.Map(() => new CarouselShowCase()); + locator.Map(() => new AvatarShowCase()); } } diff --git a/controlgallery/AtomUIGallery/ShowCases/ViewModels/DataDisplay/AvatarViewModel.cs b/controlgallery/AtomUIGallery/ShowCases/ViewModels/DataDisplay/AvatarViewModel.cs new file mode 100644 index 000000000..cec385227 --- /dev/null +++ b/controlgallery/AtomUIGallery/ShowCases/ViewModels/DataDisplay/AvatarViewModel.cs @@ -0,0 +1,81 @@ +using System.Reactive; +using System.Reactive.Disposables; +using AtomUI.Controls; +using ReactiveUI; + +namespace AtomUIGallery.ShowCases.ViewModels; + +public class AvatarViewModel : ReactiveObject, IRoutableViewModel, IActivatableViewModel +{ + public static EntityKey ID = "Avatar"; + + public ViewModelActivator Activator { get; } + public IScreen HostScreen { get; } + public string UrlPathSegment { get; } = ID.ToString(); + + private string? _avatarText; + + public string? AvatarText + { + get => _avatarText; + set => this.RaiseAndSetIfChanged(ref _avatarText, value); + } + + private double? _avatarGap; + + public double? AvatarGap + { + get => _avatarGap; + set => this.RaiseAndSetIfChanged(ref _avatarGap, value); + } + + private string? _avatarBackground; + + public string? AvatarBackground + { + get => _avatarBackground; + set => this.RaiseAndSetIfChanged(ref _avatarBackground, value); + } + + private int _textCurrentIndex = 0; + private int _gapCurrentIndex = 0; + + private readonly List _userList; + private readonly List _colorList; + private readonly List _gapList; + + public ReactiveCommand ChangeUserCommand { get; } + public ReactiveCommand ChangeGapCommand { get; } + + public AvatarViewModel(IScreen screen) + { + Activator = new ViewModelActivator(); + HostScreen = screen; + + _userList = ["U", "Lucy", "Tom", "Edward"]; + _colorList = ["#f56a00", "#7265e6", "#ffbf00", "#00a2ae"]; + _gapList = [4, 3, 2, 1]; + + ChangeUserCommand = ReactiveCommand.Create(SetupAvatarText); + ChangeGapCommand = ReactiveCommand.Create(SetupAvatarGap); + + this.WhenActivated((CompositeDisposable disposables) => + { + SetupAvatarText(); + SetupAvatarGap(); + }); + } + + private void SetupAvatarText() + { + var index = (_textCurrentIndex++) % 4; + AvatarText = _userList[index]; + AvatarBackground = _colorList[index]; + } + + private void SetupAvatarGap() + { + var index = (_gapCurrentIndex++) % 4; + AvatarGap = _gapList[index]; + } +} diff --git a/controlgallery/AtomUIGallery/ShowCases/Views/DataDisplay/AvatarShowCase.axaml b/controlgallery/AtomUIGallery/ShowCases/Views/DataDisplay/AvatarShowCase.axaml new file mode 100644 index 000000000..5efaaa2ae --- /dev/null +++ b/controlgallery/AtomUIGallery/ShowCases/Views/DataDisplay/AvatarShowCase.axaml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + U + USER + + U + + + + + + + + + + ChangeUser + + + ChangeGap + + + + + + + + + K + + + + + + + K + + + + + + + K + + + + + + + K + + + + + + A + K + + + + + + + diff --git a/controlgallery/AtomUIGallery/ShowCases/Views/DataDisplay/AvatarShowCase.axaml.cs b/controlgallery/AtomUIGallery/ShowCases/Views/DataDisplay/AvatarShowCase.axaml.cs new file mode 100644 index 000000000..646205475 --- /dev/null +++ b/controlgallery/AtomUIGallery/ShowCases/Views/DataDisplay/AvatarShowCase.axaml.cs @@ -0,0 +1,21 @@ +using System.Reactive.Disposables.Fluent; +using AtomUIGallery.ShowCases.ViewModels; +using ReactiveUI; +using ReactiveUI.Avalonia; + +namespace AtomUIGallery.ShowCases.Views; + +public partial class AvatarShowCase : ReactiveUserControl +{ + public AvatarShowCase() + { + InitializeComponent(); + this.WhenActivated(disposables => + { + this.BindCommand(ViewModel!, vm => vm.ChangeUserCommand, v => v.ChangeUserButton) + .DisposeWith(disposables); + this.BindCommand(ViewModel!, vm => vm.ChangeGapCommand, v => v.ChangeGapButton) + .DisposeWith(disposables); + }); + } +} diff --git a/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/en_US.cs b/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/en_US.cs index b508f8a04..225521e17 100644 --- a/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/en_US.cs +++ b/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/en_US.cs @@ -21,6 +21,7 @@ internal class en_US : LanguageProvider public const string Navigation_ButtonSpinner = "ButtonSpinner"; public const string DataEntry = "Data Entry"; public const string DataDisplay = "Data Display"; + public const string DataDisplay_Avatar = "Avatar"; public const string DataDisplay_Carousel = "Carousel"; public const string Feedback = "Feedback"; public const string Feedback_Alert = "Alert"; diff --git a/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/zh_CN.cs b/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/zh_CN.cs index 33fd0fec3..4f695dfb4 100644 --- a/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/zh_CN.cs +++ b/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/zh_CN.cs @@ -21,6 +21,7 @@ internal class zh_CN : LanguageProvider public const string Navigation_ButtonSpinner = "ButtonSpinner 按钮微调器"; public const string DataEntry = "数据录入"; public const string DataDisplay = "数据展示"; + public const string DataDisplay_Avatar = "Avatar 头像"; public const string DataDisplay_Carousel = "Carousel 走马灯"; public const string Feedback = "反馈"; public const string Feedback_Alert = "Alert 警告提示"; diff --git a/controlgallery/AtomUIGallery/Workspace/ViewModels/CaseNavigationViewModel.cs b/controlgallery/AtomUIGallery/Workspace/ViewModels/CaseNavigationViewModel.cs index 5505cde1d..28708aecd 100644 --- a/controlgallery/AtomUIGallery/Workspace/ViewModels/CaseNavigationViewModel.cs +++ b/controlgallery/AtomUIGallery/Workspace/ViewModels/CaseNavigationViewModel.cs @@ -68,6 +68,7 @@ public class CaseNavigationViewModel : ReactiveObject, IActivatableViewModel _showCaseViewModelFactories.Add(SpinViewModel.ID, () => new SpinViewModel(HostScreen)); _showCaseViewModelFactories.Add(FlexPanelViewModel.ID, () => new FlexPanelViewModel(HostScreen)); _showCaseViewModelFactories.Add(CarouselViewModel.ID, () => new CarouselViewModel(HostScreen)); + _showCaseViewModelFactories.Add(AvatarViewModel.ID, () => new AvatarViewModel(HostScreen)); } private void DoNavigateTo(EntityKey showCaseId) diff --git a/controlgallery/AtomUIGallery/Workspace/Views/CaseNavigation.axaml b/controlgallery/AtomUIGallery/Workspace/Views/CaseNavigation.axaml index 9e0211efb..324dec25c 100644 --- a/controlgallery/AtomUIGallery/Workspace/Views/CaseNavigation.axaml +++ b/controlgallery/AtomUIGallery/Workspace/Views/CaseNavigation.axaml @@ -44,6 +44,8 @@ + diff --git a/src/AtomUI.Controls/Avatar/AbstractAvatar.cs b/src/AtomUI.Controls/Avatar/AbstractAvatar.cs new file mode 100644 index 000000000..f995ac711 --- /dev/null +++ b/src/AtomUI.Controls/Avatar/AbstractAvatar.cs @@ -0,0 +1,290 @@ +using System.Diagnostics; +using AtomUI.Media; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Media; +using Avalonia.Media.Transformation; +using Avalonia.Metadata; + +namespace AtomUI.Controls.Commons; + +internal enum AvatarContentType +{ + Icon, + BitmapImage, + SvgImage, + Text +} + +[TemplatePart("PART_TextPresenter", typeof(TextBlock))] +public abstract class AbstractAvatar : TemplatedControl, IMotionAwareControl +{ + #region 公共属性定义 + + public static readonly StyledProperty GapProperty = + AvaloniaProperty.Register(nameof(Gap), defaultValue: 4.0); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + public static readonly StyledProperty BitmapSrcProperty = + AvaloniaProperty.Register(nameof(BitmapSrc)); + + public static readonly StyledProperty SrcProperty = + AvaloniaProperty.Register(nameof(Src)); + + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof (Text)); + + public static readonly StyledProperty SizeTypeProperty = + AvaloniaProperty.Register(nameof(SizeType), + CustomizableSizeType.Middle); + + public static readonly StyledProperty SizeProperty = + AvaloniaProperty.Register(nameof(Size), Double.NaN); + + public static readonly StyledProperty ShapeProperty = + AvaloniaProperty.Register(nameof(Shape), AvatarShape.Circle); + + public static readonly StyledProperty IsMotionEnabledProperty = + MotionAwareControlProperty.IsMotionEnabledProperty.AddOwner(); + + public double Gap + { + get => GetValue(GapProperty); + set => SetValue(GapProperty, value); + } + + public PathIcon? Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public IImage? BitmapSrc + { + get => GetValue(BitmapSrcProperty); + set => SetValue(BitmapSrcProperty, value); + } + + public string? Src + { + get => GetValue(SrcProperty); + set => SetValue(SrcProperty, value); + } + + [Content] + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public CustomizableSizeType SizeType + { + get => GetValue(SizeTypeProperty); + set => SetValue(SizeTypeProperty, value); + } + + public double Size + { + get => GetValue(SizeProperty); + set => SetValue(SizeProperty, value); + } + + public AvatarShape Shape + { + get => GetValue(ShapeProperty); + set => SetValue(ShapeProperty, value); + } + + public bool IsMotionEnabled + { + get => GetValue(IsMotionEnabledProperty); + set => SetValue(IsMotionEnabledProperty, value); + } + + #endregion + + #region 内部属性定义 + internal static readonly StyledProperty EffectiveIconSizeProperty = + AvaloniaProperty.Register(nameof(EffectiveIconSize)); + + internal static readonly StyledProperty TextRenderTransformProperty = + AvaloniaProperty.Register(nameof(TextRenderTransform)); + + internal static readonly DirectProperty ContentTypeProperty = + AvaloniaProperty.RegisterDirect( + nameof(ContentType), + o => o.ContentType, + (o, v) => o.ContentType = v); + + internal double EffectiveIconSize + { + get => GetValue(EffectiveIconSizeProperty); + set => SetValue(EffectiveIconSizeProperty, value); + } + + public ITransform? TextRenderTransform + { + get => GetValue(TextRenderTransformProperty); + set => SetValue(TextRenderTransformProperty, value); + } + + private AvatarContentType _contentType = AvatarContentType.Icon; + + internal AvatarContentType ContentType + { + get => _contentType; + set => SetAndRaise(ContentTypeProperty, ref _contentType, value); + } + #endregion + + private CustomizableSizeType? _originSizeType; + private TextBlock? _textPresenter; + + static AbstractAvatar() + { + AffectsMeasure(SizeTypeProperty, TextProperty); + AffectsRender(ShapeProperty, IconProperty, SrcProperty, GapProperty); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == SizeProperty) + { + if (!double.IsNaN(Size)) + { + _originSizeType = SizeType; + SizeType = CustomizableSizeType.Custom; + } + else + { + if (_originSizeType.HasValue) + { + SizeType = _originSizeType.Value; + } + } + } + else if (change.Property == SizeTypeProperty) + { + ConfigureIconSize(); + } + else if (change.Property == SrcProperty || + change.Property == IconProperty || + change.Property == TextProperty) + { + ConfigureContentType(); + } + if (change.Property == ContentTypeProperty || + change.Property == GapProperty) + { + ConfigureTextRenderTransform(); + } + else if (change.Property == ShapeProperty) + { + ConfigureShape(); + } + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + if (_textPresenter != null) + { + _textPresenter.SizeChanged -= HandleTextPresenterSizeChanged; + } + _textPresenter = e.NameScope.Find("PART_TextPresenter"); + if (_textPresenter != null) + { + _textPresenter.SizeChanged += HandleTextPresenterSizeChanged; + } + ConfigureShape(); + ConfigureIconSize(); + ConfigureContentType(); + } + + private void HandleTextPresenterSizeChanged(object? sender, SizeChangedEventArgs e) + { + ConfigureTextRenderTransform(); + } + + private void ConfigureIconSize() + { + if (SizeType == CustomizableSizeType.Custom) + { + Debug.Assert(!double.IsNaN(Size)); + // 不影响模板设置 + SetValue(WidthProperty, Size, BindingPriority.Template); + SetValue(HeightProperty, Size, BindingPriority.Template); + SetValue(EffectiveIconSizeProperty, + (Icon != null || Src != null) ? Size / 2 : 18, BindingPriority.Template); + } + } + + private void ConfigureContentType() + { + if (Src != null) + { + ContentType = AvatarContentType.SvgImage; + } + else if (BitmapSrc != null) + { + ContentType = AvatarContentType.BitmapImage; + } + else if (Text != null) + { + ContentType = AvatarContentType.Text; + } + else + { + ContentType = AvatarContentType.Icon; + } + } + + private void ConfigureTextRenderTransform() + { + if (ContentType != AvatarContentType.Text) + { + TextRenderTransform = null; + } + else + { + if (_textPresenter != null && Gap * 2 < Width) + { + double scale = 1; + double offsetX = 0; + var textWidth = TextUtils.CalculateTextSize(Text ?? string.Empty, FontSize, FontFamily).Width; + if (Gap * 2 < Width) + { + scale = (Width - Gap * 2) / textWidth; + scale = Math.Min(scale, 1.0); + offsetX = (scale * (textWidth - Width)) / 2; + } + var builder = new TransformOperations.Builder(2); + builder.AppendScale(scale, scale); + if (scale < 1.0) + { + builder.AppendTranslate(-offsetX, 0); + } + TextRenderTransform = builder.Build(); + } + else + { + TextRenderTransform = null; + } + } + } + + private void ConfigureShape() + { + if (Shape == AvatarShape.Circle) + { + SetValue(CornerRadiusProperty, new CornerRadius(Width / 2), BindingPriority.Template); + } + } +} \ No newline at end of file diff --git a/src/AtomUI.Controls/Avatar/AvatarPseudoClass.cs b/src/AtomUI.Controls/Avatar/AvatarPseudoClass.cs new file mode 100644 index 000000000..4611ecb33 --- /dev/null +++ b/src/AtomUI.Controls/Avatar/AvatarPseudoClass.cs @@ -0,0 +1,9 @@ +namespace AtomUI.Controls.Commons; + +public static class AvatarPseudoClass +{ + public const string Large = ":large"; + public const string Middle = ":middle"; + public const string Small = ":small"; + public const string Custom = ":custom-size"; +} \ No newline at end of file diff --git a/src/AtomUI.Controls/Avatar/AvatarShape.cs b/src/AtomUI.Controls/Avatar/AvatarShape.cs new file mode 100644 index 000000000..81901da54 --- /dev/null +++ b/src/AtomUI.Controls/Avatar/AvatarShape.cs @@ -0,0 +1,7 @@ +namespace AtomUI.Controls; + +public enum AvatarShape +{ + Circle, + Square +} \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Avatar/Avatar.cs b/src/AtomUI.Desktop.Controls/Avatar/Avatar.cs new file mode 100644 index 000000000..2604bea06 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Avatar/Avatar.cs @@ -0,0 +1,12 @@ +using AtomUI.Controls.Commons; +using AtomUI.Theme; + +namespace AtomUI.Desktop.Controls; + +public class Avatar : AbstractAvatar +{ + public Avatar() + { + this.RegisterTokenResourceScope(AvatarToken.ScopeProvider); + } +} \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Avatar/AvatarGroup.cs b/src/AtomUI.Desktop.Controls/Avatar/AvatarGroup.cs new file mode 100644 index 000000000..4b8d380d7 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Avatar/AvatarGroup.cs @@ -0,0 +1,376 @@ +using System.Collections.Specialized; +using AtomUI.Controls; +using AtomUI.Theme; +using AtomUI.Utils; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace AtomUI.Desktop.Controls; + +using ControlList = Avalonia.Controls.Controls; + +public class AvatarGroup : TemplatedControl, IMotionAwareControl +{ + #region 公共属性定义 + + public static readonly StyledProperty FoldInfoAvatarBackgroundProperty = + AvaloniaProperty.Register(nameof (FoldInfoAvatarBackground)); + + public static readonly StyledProperty FoldInfoAvatarForegroundProperty = + AvaloniaProperty.Register(nameof (FoldInfoAvatarForeground)); + + public static readonly StyledProperty FoldAvatarFlyoutTriggerTypeProperty = + AvaloniaProperty.Register(nameof(FoldAvatarFlyoutTriggerType), FlyoutTriggerType.Hover); + + public static readonly StyledProperty MaxDisplayCountProperty = + AvaloniaProperty.Register(nameof(MaxDisplayCount)); + + public static readonly StyledProperty SizeTypeProperty = + Avatar.SizeTypeProperty.AddOwner(); + + public static readonly StyledProperty SizeProperty = + Avatar.SizeProperty.AddOwner(); + + public static readonly StyledProperty ShapeProperty = + Avatar.ShapeProperty.AddOwner(); + + public static readonly StyledProperty IsMotionEnabledProperty = + MotionAwareControlProperty.IsMotionEnabledProperty.AddOwner(); + + public IBrush? FoldInfoAvatarBackground + { + get => GetValue(FoldInfoAvatarBackgroundProperty); + set => SetValue(FoldInfoAvatarBackgroundProperty, value); + } + + public IBrush? FoldInfoAvatarForeground + { + get => GetValue(FoldInfoAvatarForegroundProperty); + set => SetValue(FoldInfoAvatarForegroundProperty, value); + } + + public FlyoutTriggerType FoldAvatarFlyoutTriggerType + { + get => GetValue(FoldAvatarFlyoutTriggerTypeProperty); + set => SetValue(FoldAvatarFlyoutTriggerTypeProperty, value); + } + + public int? MaxDisplayCount + { + get => GetValue(MaxDisplayCountProperty); + set => SetValue(MaxDisplayCountProperty, value); + } + + public CustomizableSizeType SizeType + { + get => GetValue(SizeTypeProperty); + set => SetValue(SizeTypeProperty, value); + } + + public double Size + { + get => GetValue(SizeProperty); + set => SetValue(SizeProperty, value); + } + + public AvatarShape Shape + { + get => GetValue(ShapeProperty); + set => SetValue(ShapeProperty, value); + } + + public bool IsMotionEnabled + { + get => GetValue(IsMotionEnabledProperty); + set => SetValue(IsMotionEnabledProperty, value); + } + + [Content] public ControlList Children { get; } = new(); + + #endregion + + #region 内部属性定义 + + internal static readonly StyledProperty GroupSpaceProperty = + AvaloniaProperty.Register(nameof (GroupSpace)); + + internal static readonly StyledProperty GroupOverlappingProperty = + AvaloniaProperty.Register(nameof (GroupOverlapping)); + + internal double GroupSpace + { + get => GetValue(GroupSpaceProperty); + set => SetValue(GroupSpaceProperty, value); + } + + internal double GroupOverlapping + { + get => GetValue(GroupOverlappingProperty); + set => SetValue(GroupOverlappingProperty, value); + } + #endregion + + private Avatar? _foldCountAvatar; + private FlyoutHost? _foldCountFlyout; + private StackPanel? _foldCountStackPanel; + + static AvatarGroup() + { + AffectsMeasure(GroupOverlappingProperty, GroupSpaceProperty); + } + + public AvatarGroup() + { + Children.CollectionChanged += ChildrenChanged; + this.RegisterTokenResourceScope(AvatarToken.ScopeProvider); + this.ConfigureMotionBindingStyle(); + } + + protected virtual void ChildrenChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (var item in e.NewItems!.OfType()) + { + if (item is Avatar avatar) + { + ConfigureAvatar(avatar); + } + } + if (!MaxDisplayCount.HasValue || LogicalChildren.Count < MaxDisplayCount.Value) + { + var startingIndex = e.NewStartingIndex; + foreach (var item in e.NewItems!.OfType()) + { + LogicalChildren.Insert(startingIndex, item); + VisualChildren.Insert(startingIndex, item); + startingIndex++; + } + } + break; + + case NotifyCollectionChangedAction.Move: + if (!MaxDisplayCount.HasValue) + { + LogicalChildren.MoveRange(e.OldStartingIndex, e.OldItems!.Count, e.NewStartingIndex); + VisualChildren.MoveRange(e.OldStartingIndex, e.OldItems!.Count, e.NewStartingIndex); + } + else + { + if (e.OldStartingIndex < MaxDisplayCount.Value && + e.OldStartingIndex + e.OldItems!.Count <= MaxDisplayCount.Value && + e.NewStartingIndex <= MaxDisplayCount.Value) + { + LogicalChildren.MoveRange(e.OldStartingIndex, e.OldItems!.Count, e.NewStartingIndex); + VisualChildren.MoveRange(e.OldStartingIndex, e.OldItems!.Count, e.NewStartingIndex); + } + } + break; + + case NotifyCollectionChangedAction.Remove: + var removedItems = e.OldItems!.OfType(); + LogicalChildren.RemoveAll(removedItems); + VisualChildren.RemoveAll(removedItems); + break; + + case NotifyCollectionChangedAction.Replace: + if (!MaxDisplayCount.HasValue) + { + for (var i = 0; i < e.OldItems!.Count; ++i) + { + var index = i + e.OldStartingIndex; + var child = (Control)e.NewItems![i]!; + LogicalChildren[index] = child; + VisualChildren[index] = child; + } + } + else + { + for (var i = 0; i < e.OldItems!.Count; ++i) + { + var index = i + e.OldStartingIndex; + if (index < MaxDisplayCount.Value) + { + var child = (Control)e.NewItems![i]!; + LogicalChildren[index] = child; + VisualChildren[index] = child; + } + } + } + + break; + + case NotifyCollectionChangedAction.Reset: + throw new NotSupportedException(); + } + + UpdateChildrenZIndex(e); + ConfigureFoldInfo(); + InvalidateMeasureOnChildrenChanged(); + } + + private void UpdateChildrenZIndex(NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + case NotifyCollectionChangedAction.Remove: + case NotifyCollectionChangedAction.Replace: + // Update ZIndex for all children after the change + var startIndex = e.Action == NotifyCollectionChangedAction.Remove + ? e.OldStartingIndex + : e.NewStartingIndex; + for (var i = startIndex; i < Children.Count; i++) + { + Children[i].ZIndex = i + 1; + } + break; + + case NotifyCollectionChangedAction.Move: + // Update ZIndex for affected range + var minIndex = Math.Min(e.OldStartingIndex, e.NewStartingIndex); + var maxIndex = Math.Max(e.OldStartingIndex + e.OldItems!.Count, + e.NewStartingIndex + e.OldItems!.Count); + for (var i = minIndex; i < Math.Min(maxIndex, Children.Count); i++) + { + Children[i].ZIndex = i + 1; + } + break; + } + } + + private void BindAvatarProperties(Avatar avatar) + { + avatar[!BorderThicknessProperty] = this[!BorderThicknessProperty]; + avatar[!ShapeProperty] = this[!ShapeProperty]; + avatar[!SizeProperty] = this[!SizeProperty]; + avatar[!SizeTypeProperty] = this[!SizeTypeProperty]; + } + + private void ConfigureAvatar(Avatar avatar) + { + BindAvatarProperties(avatar); + avatar[!IsMotionEnabledProperty] = this[!IsMotionEnabledProperty]; + } + + private Avatar GetFoldCountAvatar() + { + if (_foldCountAvatar == null) + { + _foldCountAvatar = new Avatar(); + + BindAvatarProperties(_foldCountAvatar); + _foldCountAvatar[!ForegroundProperty] = this[!FoldInfoAvatarForegroundProperty]; + _foldCountAvatar[!BackgroundProperty] = this[!FoldInfoAvatarBackgroundProperty]; + } + + return _foldCountAvatar; + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + var foldCountAvatar = GetFoldCountAvatar(); + if (_foldCountFlyout == null) + { + _foldCountFlyout = new FlyoutHost(); + _foldCountFlyout.ZIndex = Int32.MaxValue; + _foldCountFlyout.Content = foldCountAvatar; + _foldCountStackPanel = new StackPanel(); + _foldCountStackPanel.Orientation = Orientation.Horizontal; + + _foldCountStackPanel[!StackPanel.SpacingProperty] = this[!GroupSpaceProperty]; + _foldCountFlyout[!FlyoutHost.TriggerProperty] = this[!FoldAvatarFlyoutTriggerTypeProperty]; + + _foldCountFlyout.Flyout = new Flyout + { + Content = _foldCountStackPanel + }; + } + + ConfigureFoldInfo(); + } + + private void ConfigureFoldInfo() + { + var foldCountAvatar = GetFoldCountAvatar(); + if (_foldCountFlyout != null && _foldCountStackPanel != null) + { + if (MaxDisplayCount.HasValue && Children.Count > MaxDisplayCount.Value) + { + foldCountAvatar.Text = $"+{Children.Count - MaxDisplayCount.Value}"; + LogicalChildren.Add(_foldCountFlyout); + VisualChildren.Add(_foldCountFlyout); + for (var i = MaxDisplayCount.Value; i < Children.Count; ++i) + { + _foldCountStackPanel.Children.Add(Children[i]); + } + } + else + { + LogicalChildren.Remove(_foldCountFlyout); + VisualChildren.Remove(_foldCountFlyout); + _foldCountStackPanel.Children.Clear(); + } + } + } + + private protected virtual void InvalidateMeasureOnChildrenChanged() + { + InvalidateMeasure(); + } + + protected override Size MeasureOverride(Size availableSize) + { + var size = base.MeasureOverride(availableSize); + // 理论上是统一的,我们的孩子都一样大 + var count = LogicalChildren.Count; + var totalWidth = count * size.Width - (count - 1) * GroupOverlapping; + return size.WithWidth(totalWidth); + } + + protected override Size ArrangeOverride(Size finalSize) + { + var offsetX = 0.0; + for (var i = 0; i < LogicalChildren.Count; i++) + { + var child = LogicalChildren[i]; + if (child is Control avatar) + { + var childSize = avatar.DesiredSize; + avatar.Arrange(new Rect(offsetX, 0, childSize.Width, childSize.Height)); + offsetX += childSize.Width - GroupOverlapping; + } + } + return finalSize; + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == FoldAvatarFlyoutTriggerTypeProperty) + { + ConfigureFoldAvatarCursor(); + } + } + + private void ConfigureFoldAvatarCursor() + { + var foldCountAvatar = GetFoldCountAvatar(); + if (FoldAvatarFlyoutTriggerType == FlyoutTriggerType.Click) + { + + foldCountAvatar.Cursor = new Cursor(StandardCursorType.Hand); + } + else + { + foldCountAvatar.Cursor = new Cursor(StandardCursorType.Arrow); + } + } +} \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Avatar/AvatarToken.cs b/src/AtomUI.Desktop.Controls/Avatar/AvatarToken.cs new file mode 100644 index 000000000..8f7a3aaed --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Avatar/AvatarToken.cs @@ -0,0 +1,86 @@ +using AtomUI.Desktop.Controls.DesignTokens; +using AtomUI.Theme; +using AtomUI.Theme.TokenSystem; +using Avalonia.Media; + +namespace AtomUI.Desktop.Controls; + +[ControlDesignToken] +internal class AvatarToken : AbstractControlDesignToken +{ + public const string ID = "Avatar"; + public static readonly ControlTokenResourceScopeProvider ScopeProvider = new(ID); + + /// + /// 头像尺寸 + /// + public double ContainerSize { get; set; } + + /// + /// 大号头像尺寸 + /// + public double ContainerSizeLG { get; set; } + + /// + /// 小号头像尺寸 + /// + public double ContainerSizeSM { get; set; } + + /// + /// 头像文字大小 + /// + public double TextFontSize { get; set; } + + /// + /// 大号头像文字大小 + /// + public double TextFontSizeLG { get; set; } + + /// + /// 小号头像文字大小 + /// + public double TextFontSizeSM { get; set; } + + /// + /// 头像组间距 + /// + public double GroupSpace { get; set; } + + /// + /// 头像组重叠宽度 + /// + public double GroupOverlapping { get; set; } + + /// + /// 头像组边框颜色 + /// + public Color GroupBorderColor { get; set; } + + // 内部使用别名 + public Color AvatarBg { get; set; } + public Color AvatarColor { get; set; } + + public AvatarToken() + : base(ID) + { + } + + public override void CalculateTokenValues(bool isDarkMode) + { + base.CalculateTokenValues(isDarkMode); + ContainerSize = SharedToken.ControlHeight; + ContainerSizeLG = SharedToken.ControlHeightLG; + ContainerSizeSM = SharedToken.ControlHeightSM; + TextFontSize = Math.Round((SharedToken.FontSizeLG + SharedToken.FontSizeXL) / 2); + TextFontSizeLG = SharedToken.FontSizeHeading3; + TextFontSizeSM = SharedToken.FontSize; + GroupSpace = SharedToken.UniformlyMarginXXS; + GroupOverlapping = SharedToken.UniformlyMarginXS; + GroupBorderColor = SharedToken.ColorBorderBg; + + AvatarBg = SharedToken.ColorTextPlaceholder; + AvatarColor = SharedToken.ColorTextLightSolid; + } + + protected override Type GetTokenKindType() => typeof(AvatarTokenKind); +} \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Avatar/Themes/AvatarGroupTheme.axaml b/src/AtomUI.Desktop.Controls/Avatar/Themes/AvatarGroupTheme.axaml new file mode 100644 index 000000000..ef9f06b64 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Avatar/Themes/AvatarGroupTheme.axaml @@ -0,0 +1,16 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Avatar/Themes/AvatarTheme.axaml b/src/AtomUI.Desktop.Controls/Avatar/Themes/AvatarTheme.axaml new file mode 100644 index 000000000..c00951c2a --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Avatar/Themes/AvatarTheme.axaml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Avatar/Themes/AvatarThemes.axaml b/src/AtomUI.Desktop.Controls/Avatar/Themes/AvatarThemes.axaml new file mode 100644 index 000000000..9d535666e --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Avatar/Themes/AvatarThemes.axaml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file