From 046f2441b60b0428a8aee5f86f798e6dabc4ee4e Mon Sep 17 00:00:00 2001 From: chinboy Date: Fri, 1 May 2026 08:23:32 +0800 Subject: [PATCH] feat(Message,Breadcrumb): port Message control and Breadcrumb showcase from release/5.0 --- .../LanguageResourceConst.g.cs | 3 +- .../ShowCases/ShowCaseRegister.cs | 1 + .../Navigation/BreadcrumbViewModel.cs | 29 ++ .../Views/Navigation/BreadcrumbShowCase.axaml | 84 +++++ .../Navigation/BreadcrumbShowCase.axaml.cs | 79 +++++ .../Localization/CaseNavigationLang/en_US.cs | 1 + .../Localization/CaseNavigationLang/zh_CN.cs | 1 + .../ViewModels/CaseNavigationViewModel.cs | 1 + .../Workspace/Views/CaseNavigation.axaml | 5 +- .../DesktopControlThemesProvider.axaml | 6 +- .../ControlTokenTypePool.g.cs | 1 + .../TokenResourceConst.g.cs | 21 ++ .../Message/IMessage.cs | 32 ++ .../Message/IMessageManager.cs | 6 + .../Message/Message.cs | 65 ++++ .../Message/MessageCard.cs | 295 ++++++++++++++++++ .../Message/MessageCardPseudoClass.cs | 10 + .../Message/MessageToken.cs | 64 ++++ .../Message/MessageType.cs | 10 + .../Message/Themes/MessageCardTheme.axaml | 98 ++++++ .../Message/Themes/MessageThemeConstants.cs | 14 + .../Message/Themes/MessageThemes.axaml | 8 + .../Themes/WindowMessageManagerTheme.axaml | 23 ++ .../Message/WindowMessageManager.cs | 235 ++++++++++++++ 24 files changed, 1087 insertions(+), 5 deletions(-) create mode 100644 controlgallery/AtomUIGallery/ShowCases/ViewModels/Navigation/BreadcrumbViewModel.cs create mode 100644 controlgallery/AtomUIGallery/ShowCases/Views/Navigation/BreadcrumbShowCase.axaml create mode 100644 controlgallery/AtomUIGallery/ShowCases/Views/Navigation/BreadcrumbShowCase.axaml.cs create mode 100644 src/AtomUI.Desktop.Controls/Message/IMessage.cs create mode 100644 src/AtomUI.Desktop.Controls/Message/IMessageManager.cs create mode 100644 src/AtomUI.Desktop.Controls/Message/Message.cs create mode 100644 src/AtomUI.Desktop.Controls/Message/MessageCard.cs create mode 100644 src/AtomUI.Desktop.Controls/Message/MessageCardPseudoClass.cs create mode 100644 src/AtomUI.Desktop.Controls/Message/MessageToken.cs create mode 100644 src/AtomUI.Desktop.Controls/Message/MessageType.cs create mode 100644 src/AtomUI.Desktop.Controls/Message/Themes/MessageCardTheme.axaml create mode 100644 src/AtomUI.Desktop.Controls/Message/Themes/MessageThemeConstants.cs create mode 100644 src/AtomUI.Desktop.Controls/Message/Themes/MessageThemes.axaml create mode 100644 src/AtomUI.Desktop.Controls/Message/Themes/WindowMessageManagerTheme.axaml create mode 100644 src/AtomUI.Desktop.Controls/Message/WindowMessageManager.cs diff --git a/controlgallery/AtomUIGallery/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.LanguageGenerator/LanguageResourceConst.g.cs b/controlgallery/AtomUIGallery/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.LanguageGenerator/LanguageResourceConst.g.cs index 914a09e73..cda94bece 100644 --- a/controlgallery/AtomUIGallery/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.LanguageGenerator/LanguageResourceConst.g.cs +++ b/controlgallery/AtomUIGallery/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.LanguageGenerator/LanguageResourceConst.g.cs @@ -16,7 +16,8 @@ namespace AtomUIGallery.Localization General_SplitButton, Layout, Layout_Splitter, - Navigation + Navigation, + Navigation_Breadcrumb } public class CaseNavigationLangResourceExtension : LanguageResourceExtension diff --git a/controlgallery/AtomUIGallery/ShowCases/ShowCaseRegister.cs b/controlgallery/AtomUIGallery/ShowCases/ShowCaseRegister.cs index ad05f08a2..f504d739c 100644 --- a/controlgallery/AtomUIGallery/ShowCases/ShowCaseRegister.cs +++ b/controlgallery/AtomUIGallery/ShowCases/ShowCaseRegister.cs @@ -14,5 +14,6 @@ public sealed class ShowCaseViewModule : IViewModule locator.Map(() => new ButtonShowCase()); locator.Map(() => new SplitButtonShowCase()); locator.Map(() => new SplitterShowCase()); + locator.Map(() => new BreadcrumbShowCase()); } } diff --git a/controlgallery/AtomUIGallery/ShowCases/ViewModels/Navigation/BreadcrumbViewModel.cs b/controlgallery/AtomUIGallery/ShowCases/ViewModels/Navigation/BreadcrumbViewModel.cs new file mode 100644 index 000000000..b985b5b80 --- /dev/null +++ b/controlgallery/AtomUIGallery/ShowCases/ViewModels/Navigation/BreadcrumbViewModel.cs @@ -0,0 +1,29 @@ +using AtomUI.Controls; +using AtomUI.Desktop.Controls; +using ReactiveUI; + +namespace AtomUIGallery.ShowCases.ViewModels; + +public class BreadcrumbViewModel : ReactiveObject, IRoutableViewModel, IActivatableViewModel +{ + public static EntityKey ID = "Breadcrumb"; + + public IScreen HostScreen { get; } + public ViewModelActivator Activator { get; } + + public string? UrlPathSegment => ID.ToString(); + + private List? _breadcrumbItems = []; + + public List? BreadcrumbItems + { + get => _breadcrumbItems; + set => this.RaiseAndSetIfChanged(ref _breadcrumbItems, value); + } + + public BreadcrumbViewModel(IScreen screen) + { + Activator = new ViewModelActivator(); + HostScreen = screen; + } +} diff --git a/controlgallery/AtomUIGallery/ShowCases/Views/Navigation/BreadcrumbShowCase.axaml b/controlgallery/AtomUIGallery/ShowCases/Views/Navigation/BreadcrumbShowCase.axaml new file mode 100644 index 000000000..6db8c7221 --- /dev/null +++ b/controlgallery/AtomUIGallery/ShowCases/Views/Navigation/BreadcrumbShowCase.axaml @@ -0,0 +1,84 @@ + + + + + + Home + Application Center + Application List + An Application + + + + + + + + + Application List + Application + + + + + + + + Users + Param + + + + + + + + Home + Application Center + Application List + An Application + + + + + + + + Location + Application Center + Application List + An Application + + + + + + + + + + + + + + + + + diff --git a/controlgallery/AtomUIGallery/ShowCases/Views/Navigation/BreadcrumbShowCase.axaml.cs b/controlgallery/AtomUIGallery/ShowCases/Views/Navigation/BreadcrumbShowCase.axaml.cs new file mode 100644 index 000000000..19d1be241 --- /dev/null +++ b/controlgallery/AtomUIGallery/ShowCases/Views/Navigation/BreadcrumbShowCase.axaml.cs @@ -0,0 +1,79 @@ +using System.Reactive.Disposables; +using System.Reactive.Disposables.Fluent; +using AtomUI.Desktop.Controls; +using AtomUIGallery.ShowCases.ViewModels; +using Avalonia; +using Avalonia.Controls; +using ReactiveUI; +using ReactiveUI.Avalonia; + +namespace AtomUIGallery.ShowCases.Views; + +public partial class BreadcrumbShowCase : ReactiveUserControl +{ + private WindowMessageManager? _messageManager; + + public BreadcrumbShowCase() + { + InitializeComponent(); + this.WhenActivated(disposables => + { + if (DataContext is BreadcrumbViewModel viewModel) + { + viewModel.BreadcrumbItems = [ + new BreadcrumbItemData() + { + Separator = ":", + Content = "Location" + }, + new BreadcrumbItemData() + { + NavigateContext = "#", + Content = "Application Center" + }, + new BreadcrumbItemData() + { + NavigateContext = "#", + Content = "Application List" + }, + new BreadcrumbItemData() + { + Content = "An Application" + } + ]; + + this.OneWayBind(viewModel, vm => vm.BreadcrumbItems, v => v.TplBreadcrumb.ItemsSource) + .DisposeWith(disposables); + + Disposable.Create(() => + { + viewModel.BreadcrumbItems = null; + }).DisposeWith(disposables); + } + }); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + var topLevel = TopLevel.GetTopLevel(this); + _messageManager = new WindowMessageManager(topLevel) + { + MaxItems = 10 + }; + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _messageManager?.Dispose(); + _messageManager = null; + } + + private void HandleNavigateRequest(object? sender, BreadcrumbNavigateEventArgs eventArgs) + { + _messageManager?.Show(new Message( + $"Navigate context: {eventArgs.BreadcrumbItem.NavigateContext}" + )); + } +} diff --git a/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/en_US.cs b/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/en_US.cs index 2a03ba440..68ac262a5 100644 --- a/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/en_US.cs +++ b/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/en_US.cs @@ -16,6 +16,7 @@ internal class en_US : LanguageProvider public const string Layout = "Layout"; public const string Layout_Splitter = "Splitter"; public const string Navigation = "Navigation"; + public const string Navigation_Breadcrumb = "Breadcrumb"; public const string DataEntry = "Data Entry"; public const string DataDisplay = "Data Display"; public const string Feedback = "Feedback"; diff --git a/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/zh_CN.cs b/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/zh_CN.cs index 3eeecabe8..0a0facaa3 100644 --- a/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/zh_CN.cs +++ b/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/zh_CN.cs @@ -16,6 +16,7 @@ internal class zh_CN : LanguageProvider public const string Layout = "布局"; public const string Layout_Splitter = "Splitter 分割面板"; public const string Navigation = "导航"; + public const string Navigation_Breadcrumb = "Breadcrumb 面包屑"; public const string DataEntry = "数据录入"; public const string DataDisplay = "数据展示"; public const string Feedback = "反馈"; diff --git a/controlgallery/AtomUIGallery/Workspace/ViewModels/CaseNavigationViewModel.cs b/controlgallery/AtomUIGallery/Workspace/ViewModels/CaseNavigationViewModel.cs index 3a1c5daa7..1853b38ad 100644 --- a/controlgallery/AtomUIGallery/Workspace/ViewModels/CaseNavigationViewModel.cs +++ b/controlgallery/AtomUIGallery/Workspace/ViewModels/CaseNavigationViewModel.cs @@ -59,6 +59,7 @@ public class CaseNavigationViewModel : ReactiveObject, IActivatableViewModel _showCaseViewModelFactories.Add(ButtonViewModel.ID, () => new ButtonViewModel(HostScreen)); _showCaseViewModelFactories.Add(SplitButtonViewModel.ID, () => new SplitButtonViewModel(HostScreen)); _showCaseViewModelFactories.Add(SplitterViewModel.ID, () => new SplitterViewModel(HostScreen)); + _showCaseViewModelFactories.Add(BreadcrumbViewModel.ID, () => new BreadcrumbViewModel(HostScreen)); } private void DoNavigateTo(EntityKey showCaseId) diff --git a/controlgallery/AtomUIGallery/Workspace/Views/CaseNavigation.axaml b/controlgallery/AtomUIGallery/Workspace/Views/CaseNavigation.axaml index 9aaf81ee2..ae65081b0 100644 --- a/controlgallery/AtomUIGallery/Workspace/Views/CaseNavigation.axaml +++ b/controlgallery/AtomUIGallery/Workspace/Views/CaseNavigation.axaml @@ -30,7 +30,10 @@ + Icon="{antdicons:AntDesignIconProvider Kind=MenuOutlined}"> + + diff --git a/src/AtomUI.Desktop.Controls/DesktopControlThemesProvider.axaml b/src/AtomUI.Desktop.Controls/DesktopControlThemesProvider.axaml index c4c5eac48..656a26297 100644 --- a/src/AtomUI.Desktop.Controls/DesktopControlThemesProvider.axaml +++ b/src/AtomUI.Desktop.Controls/DesktopControlThemesProvider.axaml @@ -9,7 +9,7 @@ - + @@ -37,7 +37,7 @@ - + @@ -60,7 +60,7 @@ - + diff --git a/src/AtomUI.Desktop.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/ControlTokenTypePool.g.cs b/src/AtomUI.Desktop.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/ControlTokenTypePool.g.cs index 8eeca9d74..3949f584d 100644 --- a/src/AtomUI.Desktop.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/ControlTokenTypePool.g.cs +++ b/src/AtomUI.Desktop.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/ControlTokenTypePool.g.cs @@ -25,6 +25,7 @@ namespace AtomUI.Theme tokenTypes.Add(typeof(AtomUI.Desktop.Controls.ListBoxToken)); tokenTypes.Add(typeof(AtomUI.Desktop.Controls.MarqueeLabelToken)); tokenTypes.Add(typeof(AtomUI.Desktop.Controls.MenuToken)); + tokenTypes.Add(typeof(AtomUI.Desktop.Controls.MessageToken)); tokenTypes.Add(typeof(AtomUI.Desktop.Controls.NavMenuToken)); tokenTypes.Add(typeof(AtomUI.Desktop.Controls.OptionButtonToken)); tokenTypes.Add(typeof(AtomUI.Desktop.Controls.PopupHostToken)); diff --git a/src/AtomUI.Desktop.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/TokenResourceConst.g.cs b/src/AtomUI.Desktop.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/TokenResourceConst.g.cs index 67f7857bb..fe40f4d7d 100644 --- a/src/AtomUI.Desktop.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/TokenResourceConst.g.cs +++ b/src/AtomUI.Desktop.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/TokenResourceConst.g.cs @@ -285,6 +285,16 @@ namespace AtomUI.Desktop.Controls.DesignTokens TopLevelItemSelectedColor } + public enum MessageTokenKind + { + CardHeight, + ContentBg, + ContentPadding, + MessageIconMargin, + MessageIconSize, + MessageTopMargin + } + public enum NavMenuTokenKind { ActiveBarHeight, @@ -935,6 +945,17 @@ namespace AtomUI.Desktop.Controls.DesignTokens } } + public class MessageTokenResourceExtension : TokenResourceExtension + { + public MessageTokenResourceExtension() + { + } + + public MessageTokenResourceExtension(MessageTokenKind kind) : base(kind) + { + } + } + public class NavMenuTokenResourceExtension : TokenResourceExtension { public NavMenuTokenResourceExtension() diff --git a/src/AtomUI.Desktop.Controls/Message/IMessage.cs b/src/AtomUI.Desktop.Controls/Message/IMessage.cs new file mode 100644 index 000000000..695d81a66 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Message/IMessage.cs @@ -0,0 +1,32 @@ +using Avalonia.Controls; + +namespace AtomUI.Desktop.Controls; + +public interface IMessage +{ + /// + /// Gets the message. + /// + string Content { get; } + + /// + /// 自定义图标 + /// + PathIcon? Icon { get; } + + /// + /// Gets the of the notification. + /// + MessageType Type { get; } + + /// + /// Gets the expiration time of the notification after which it will automatically close. + /// If the value is then the notification will remain open until the user closes it. + /// + TimeSpan Expiration { get; } + + /// + /// Gets an Action to be run when the message is closed. + /// + Action? OnClose { get; } +} \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Message/IMessageManager.cs b/src/AtomUI.Desktop.Controls/Message/IMessageManager.cs new file mode 100644 index 000000000..9fdecae02 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Message/IMessageManager.cs @@ -0,0 +1,6 @@ +namespace AtomUI.Desktop.Controls; + +public interface IMessageManager +{ + public void Show(IMessage message, string[]? classes = null); +} \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Message/Message.cs b/src/AtomUI.Desktop.Controls/Message/Message.cs new file mode 100644 index 000000000..858a38213 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Message/Message.cs @@ -0,0 +1,65 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using Avalonia.Controls; + +namespace AtomUI.Desktop.Controls; + +public class Message : IMessage, INotifyPropertyChanged +{ + private string _content; + private PathIcon? _icon; + + public Message(string content, + MessageType type = MessageType.Information, + PathIcon? icon = null, + TimeSpan? expiration = null, + Action? onClose = null) + { + _content = content; + _icon = icon; + Type = type; + Expiration = expiration.HasValue ? expiration.Value : TimeSpan.FromSeconds(5); + OnClose = onClose; + } + + public string Content + { + get => _content; + + set + { + if (_content != value) + { + _content = value; + OnPropertyChanged(); + } + } + } + + public PathIcon? Icon + { + get => _icon; + + set + { + if (!ReferenceEquals(_icon, value)) + { + _icon = value; + OnPropertyChanged(); + } + } + } + + public MessageType Type { get; set; } + + public TimeSpan Expiration { get; set; } + + public Action? OnClose { get; set; } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Message/MessageCard.cs b/src/AtomUI.Desktop.Controls/Message/MessageCard.cs new file mode 100644 index 000000000..deb12b990 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Message/MessageCard.cs @@ -0,0 +1,295 @@ +using AtomUI.Controls; +using AtomUI.Icons.AntDesign; +using AtomUI.MotionScene; +using AtomUI.Theme; +using Avalonia; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace AtomUI.Desktop.Controls; + +[PseudoClasses(MessageCardPseudoClass.Error, + MessageCardPseudoClass.Information, + MessageCardPseudoClass.Success, + MessageCardPseudoClass.Warning, + MessageCardPseudoClass.Loading)] +public class MessageCard : TemplatedControl, IMotionAwareControl +{ + internal const double AnimationMaxOffsetY = 100d; + + #region 公共属性定义 + + /// + /// Defines the property. + /// + public static readonly DirectProperty IsClosingProperty = + AvaloniaProperty.RegisterDirect(nameof(IsClosing), o => o.IsClosing); + + /// + /// Defines the property. + /// + public static readonly StyledProperty IsClosedProperty = + AvaloniaProperty.Register(nameof(IsClosed)); + + /// + /// Defines the property + /// + public static readonly StyledProperty MessageTypeProperty = + AvaloniaProperty.Register(nameof(MessageType)); + + /// + /// Defines the event. + /// + public static readonly RoutedEvent MessageClosedEvent = + RoutedEvent.Register(nameof(MessageClosed), RoutingStrategies.Bubble); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + public static readonly StyledProperty MessageProperty = + AvaloniaProperty.Register(nameof(Message)); + + public static readonly StyledProperty IsMotionEnabledProperty = + MotionAwareControlProperty.IsMotionEnabledProperty.AddOwner(); + + /// + /// Determines if the notification is already closing. + /// + public bool IsClosing + { + get => _isClosing; + private set => SetAndRaise(IsClosingProperty, ref _isClosing, value); + } + + /// + /// Determines if the notification is closed. + /// + public bool IsClosed + { + get => GetValue(IsClosedProperty); + set => SetValue(IsClosedProperty, value); + } + + /// + /// Gets or sets the type of the notification + /// + public MessageType MessageType + { + get => GetValue(MessageTypeProperty); + set => SetValue(MessageTypeProperty, value); + } + + /// + /// Raised when the has closed. + /// + public event EventHandler? MessageClosed + { + add => AddHandler(MessageClosedEvent, value); + remove => RemoveHandler(MessageClosedEvent, value); + } + + public PathIcon? Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public string Message + { + get => GetValue(MessageProperty); + set => SetValue(MessageProperty, value); + } + + public bool IsMotionEnabled + { + get => GetValue(IsMotionEnabledProperty); + set => SetValue(IsMotionEnabledProperty, value); + } + + #endregion + + #region 内部属性定义 + + /// 回调:消息卡片关闭时触发,由 WindowMessageManager 设置以避免 lambda 闭包。 + internal Action? OnClose { get; set; } + + internal static readonly DirectProperty OpenCloseMotionDurationProperty = + AvaloniaProperty.RegisterDirect(nameof(OpenCloseMotionDuration), + o => o.OpenCloseMotionDuration, + (o, v) => o.OpenCloseMotionDuration = v); + + private TimeSpan _openCloseMotionDuration; + + internal TimeSpan OpenCloseMotionDuration + { + get => _openCloseMotionDuration; + set => SetAndRaise(OpenCloseMotionDurationProperty, ref _openCloseMotionDuration, value); + } + + #endregion + + private bool _isClosing; + private BaseMotionActor? _motionActor; + + public MessageCard() + { + this.RegisterTokenResourceScope(MessageToken.ScopeProvider); + } + + public void Close() + { + if (IsClosing) + { + return; + } + + IsClosing = true; + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (this.IsAttachedToVisualTree()) + { + if (change.Property == MessageTypeProperty) + { + SetupDefaultMessageIcon(); + UpdatePseudoClasses(); + } + } + + if (change.Property == IconProperty) + { + if (Icon is null) + { + SetupDefaultMessageIcon(); + } + } + + if (change.Property == IsClosedProperty) + { + if (!IsClosing && !IsClosed) + { + return; + } + + RaiseEvent(new RoutedEventArgs(MessageClosedEvent)); + } + else if (change.Property == IsClosingProperty) + { + if (IsClosing) + { + Dispatcher.InvokeAsync(ApplyHideMotionAsync); + } + } + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + _motionActor = e.NameScope.Find(BaseMotionActor.MotionActorPart); + Dispatcher.InvokeAsync(ApplyShowMotionAsync); + UpdatePseudoClasses(); + SetupDefaultMessageIcon(); + } + + private async Task ApplyShowMotionAsync() + { + if (_motionActor is not null) + { + if (IsMotionEnabled) + { + _motionActor.IsVisible = false; + var motion = new MoveUpInMotion(AnimationMaxOffsetY, _openCloseMotionDuration, new CubicEaseOut()); + await motion.RunAsync(_motionActor, () => { _motionActor.IsVisible = true; }); + } + else + { + _motionActor.IsVisible = true; + } + } + } + + private async Task ApplyHideMotionAsync() + { + if (_motionActor is not null) + { + if (IsMotionEnabled) + { + var motion = + new MoveUpOutMotion(AnimationMaxOffsetY, _openCloseMotionDuration, new CubicEaseIn()); + await motion.RunAsync(_motionActor); + IsClosed = true; + } + else + { + IsClosed = true; + } + } + } + + private void UpdatePseudoClasses() + { + switch (MessageType) + { + case MessageType.Error: + PseudoClasses.Add(MessageCardPseudoClass.Error); + break; + + case MessageType.Information: + PseudoClasses.Add(MessageCardPseudoClass.Information); + break; + + case MessageType.Success: + PseudoClasses.Add(MessageCardPseudoClass.Success); + break; + + case MessageType.Warning: + PseudoClasses.Add(MessageCardPseudoClass.Warning); + break; + + case MessageType.Loading: + PseudoClasses.Add(MessageCardPseudoClass.Loading); + break; + } + } + + private void SetupDefaultMessageIcon() + { + Icon? icon = null; + if (MessageType == MessageType.Information) + { + icon = new InfoCircleFilled(); + } + else if (MessageType == MessageType.Success) + { + icon = new CheckCircleFilled(); + } + else if (MessageType == MessageType.Error) + { + icon = new CloseCircleFilled(); + } + else if (MessageType == MessageType.Warning) + { + icon = new ExclamationCircleFilled(); + } + else if (MessageType == MessageType.Loading) + { + icon = new LoadingOutlined(); + icon.LoadingAnimation = IconAnimation.Spin; + } + + if (Icon is null) + { + ClearValue(IconProperty); + SetValue(IconProperty, icon, BindingPriority.Template); + } + } + +} \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Message/MessageCardPseudoClass.cs b/src/AtomUI.Desktop.Controls/Message/MessageCardPseudoClass.cs new file mode 100644 index 000000000..c99e36d83 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Message/MessageCardPseudoClass.cs @@ -0,0 +1,10 @@ +namespace AtomUI.Desktop.Controls; + +public static class MessageCardPseudoClass +{ + public const string Error = ":error"; + public const string Information = ":information"; + public const string Success = ":success"; + public const string Warning = ":warning"; + public const string Loading = ":loading"; +} \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Message/MessageToken.cs b/src/AtomUI.Desktop.Controls/Message/MessageToken.cs new file mode 100644 index 000000000..0f78f9fdf --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Message/MessageToken.cs @@ -0,0 +1,64 @@ +using AtomUI.Desktop.Controls.DesignTokens; +using AtomUI.Theme; +using AtomUI.Theme.TokenSystem; +using Avalonia; +using Avalonia.Media; + +namespace AtomUI.Desktop.Controls; + +[ControlDesignToken] +internal class MessageToken : AbstractControlDesignToken +{ + public const string ID = "Message"; + public static readonly ControlTokenResourceScopeProvider ScopeProvider = new(ID); + + /// + /// 提示框背景色 + /// + public Color ContentBg { get; set; } + + /// + /// 提示框内边距 + /// + public Thickness ContentPadding { get; set; } + + /// + /// 提示框高度 + /// + public double CardHeight { get; set; } + + /// + /// 提醒框图标尺寸 + /// + public double MessageIconSize { get; set; } + + /// + /// 提醒框图标外边距 + /// + public Thickness MessageIconMargin { get; set; } + + /// + /// 提醒框上边缘外边距 + /// + public Thickness MessageTopMargin { get; set; } + + public MessageToken() + : base(ID) + { + } + + public override void CalculateTokenValues(bool isDarkMode) + { + base.CalculateTokenValues(isDarkMode); + ContentBg = SharedToken.ColorBgElevated; + ContentPadding = new Thickness( + (SharedToken.ControlHeightLG - + SharedToken.FontSize * SharedToken.RelativeLineHeight) / 2, + SharedToken.UniformlyPaddingXS); + MessageIconMargin = new Thickness(0, 0, SharedToken.UniformlyMarginXS, 0); + MessageTopMargin = new Thickness(SharedToken.UniformlyMargin, SharedToken.UniformlyMargin, SharedToken.UniformlyMargin, 0); + MessageIconSize = SharedToken.FontSizeSM * SharedToken.RelativeLineHeightSM; + } + + protected override Type GetTokenKindType() => typeof(MessageTokenKind); +} \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Message/MessageType.cs b/src/AtomUI.Desktop.Controls/Message/MessageType.cs new file mode 100644 index 000000000..a3c4cec70 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Message/MessageType.cs @@ -0,0 +1,10 @@ +namespace AtomUI.Desktop.Controls; + +public enum MessageType +{ + Information, + Success, + Warning, + Error, + Loading +} \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Message/Themes/MessageCardTheme.axaml b/src/AtomUI.Desktop.Controls/Message/Themes/MessageCardTheme.axaml new file mode 100644 index 000000000..645067811 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Message/Themes/MessageCardTheme.axaml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Message/Themes/MessageThemeConstants.cs b/src/AtomUI.Desktop.Controls/Message/Themes/MessageThemeConstants.cs new file mode 100644 index 000000000..25f0f47b5 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Message/Themes/MessageThemeConstants.cs @@ -0,0 +1,14 @@ +namespace AtomUI.Desktop.Controls.Themes; + +internal class WindowMessageManagerThemeConstants +{ + public const string ItemsPart = "PART_Items"; +} + +internal class MessageCardThemeConstants +{ + public const string FramePart = "PART_Frame"; + public const string IconContentPart = "PART_IconContent"; + public const string HeaderContainerPart = "PART_HeaderContainer"; + public const string MessagePart = "PART_Message"; +} \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Message/Themes/MessageThemes.axaml b/src/AtomUI.Desktop.Controls/Message/Themes/MessageThemes.axaml new file mode 100644 index 000000000..af6d557fd --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Message/Themes/MessageThemes.axaml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Message/Themes/WindowMessageManagerTheme.axaml b/src/AtomUI.Desktop.Controls/Message/Themes/WindowMessageManagerTheme.axaml new file mode 100644 index 000000000..e7e986094 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Message/Themes/WindowMessageManagerTheme.axaml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Message/WindowMessageManager.cs b/src/AtomUI.Desktop.Controls/Message/WindowMessageManager.cs new file mode 100644 index 000000000..873dbad9a --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Message/WindowMessageManager.cs @@ -0,0 +1,235 @@ +using System.Collections; +using AtomUI.Controls; +using AtomUI.Theme; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Threading; +using Avalonia.Controls.Notifications; + +namespace AtomUI.Desktop.Controls; + +[TemplatePart("PART_Items", typeof(Panel))] +public class WindowMessageManager : TemplatedControl, IMessageManager, IMotionAwareControl, IDisposable +{ + #region 公共属性定义 + + /// + /// Defines the property. + /// + public static readonly StyledProperty PositionProperty = + AvaloniaProperty.Register( + nameof(Position), NotificationPosition.TopRight); + + public static readonly StyledProperty IsMotionEnabledProperty = + MotionAwareControlProperty.IsMotionEnabledProperty.AddOwner(); + + /// + /// Defines which corner of the screen notifications can be displayed in. + /// + /// + public NotificationPosition Position + { + get => GetValue(PositionProperty); + set => SetValue(PositionProperty, value); + } + + /// + /// Defines the property. + /// + public static readonly StyledProperty MaxItemsProperty = + AvaloniaProperty.Register(nameof(MaxItems), 5); + + /// + /// Defines the maximum number of notifications visible at once. + /// + public int MaxItems + { + get => GetValue(MaxItemsProperty); + set => SetValue(MaxItemsProperty, value); + } + + public bool IsMotionEnabled + { + get => GetValue(IsMotionEnabledProperty); + set => SetValue(IsMotionEnabledProperty, value); + } + + #endregion + + private IList? _items; + private TopLevel? _topLevel; + private bool _isDisposed; + private AdornerLayer? _adornerLayer; + + /// + /// Initializes a new instance of the class. + /// + /// The TopLevel that will host the control. + public WindowMessageManager(TopLevel? host) + { + this.RegisterTokenResourceScope(MessageToken.ScopeProvider); + + if (host is not null) + { + _topLevel = host; + InstallFromTopLevel(host); + } + } + + static WindowMessageManager() + { + HorizontalAlignmentProperty.OverrideDefaultValue(HorizontalAlignment.Stretch); + VerticalAlignmentProperty.OverrideDefaultValue(VerticalAlignment.Stretch); + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + var itemsControl = e.NameScope.Find("PART_Items"); + _items = itemsControl?.Children; + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _items?.Clear(); + } + + /// + /// Shows a Notification + /// + /// the content of the message + /// style classes to apply + public void Show(IMessage message, string[]? classes = null) + { + if (_isDisposed || _items is null) + { + return; + } + + Dispatcher.VerifyAccess(); + + var messageControl = new MessageCard + { + Icon = message.Icon, + Message = message.Content, + MessageType = message.Type + }; + messageControl[!MessageCard.IsMotionEnabledProperty] = this[!IsMotionEnabledProperty]; + + // Add style classes if any + if (classes?.Length > 0) + { + foreach (var cls in classes) + { + messageControl.Classes.Add(cls); + } + } + + messageControl.OnClose = message.OnClose; + messageControl.MessageClosed += OnMessageClosed; + + Dispatcher.Post(() => + { + _items.Add(messageControl); + RemoveExcessMessages(); + }); + + // Auto-close after expiration time + if (message.Expiration != TimeSpan.Zero) + { + DispatcherTimer.RunOnce(messageControl.Close, message.Expiration); + } + } + + private void OnMessageClosed(object? sender, RoutedEventArgs e) + { + if (sender is MessageCard card) + { + card.OnClose?.Invoke(); + _items?.Remove(card); + } + } + + /// + /// Removes excess messages when the count exceeds MaxItems. + /// + private void RemoveExcessMessages() + { + var visibleMessages = _items!.OfType().Where(m => !m.IsClosing).ToList(); + var excessCount = visibleMessages.Count - MaxItems; + + if (excessCount > 0) + { + for (int i = 0; i < excessCount; i++) + { + visibleMessages[i].Close(); + } + } + } + + private void InstallFromTopLevel(TopLevel topLevel) + { + topLevel.TemplateApplied += TopLevelOnTemplateApplied; + _adornerLayer = AdornerLayer.GetAdornerLayer(topLevel); + if (_adornerLayer is not null) + { + _adornerLayer.Children.Add(this); + AdornerLayer.SetAdornedElement(this, _adornerLayer); + } + } + + public void Dispose() + { + if (_topLevel is null || _isDisposed) + { + return; + } + + try + { + // 卸载事件订阅 + _topLevel.TemplateApplied -= TopLevelOnTemplateApplied; + _items?.Clear(); + // 从 AdornerLayer 中移除 + if (_adornerLayer is not null) + { + _adornerLayer.Children.Remove(this); + AdornerLayer.SetAdornedElement(this, null); + _adornerLayer = null; + } + _topLevel = null; + _items = null; + _isDisposed = true; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error uninstalling from TopLevel: {ex.Message}"); + } + } + + private void RemoveFromAdornerLayer() + { + if (_adornerLayer is not null) + { + _adornerLayer.Children.Remove(this); + AdornerLayer.SetAdornedElement(this, null); + _adornerLayer = null; + } + } + + private void TopLevelOnTemplateApplied(object? sender, TemplateAppliedEventArgs _) + { + RemoveFromAdornerLayer(); + + // Reinstall notification manager on template reapplied. + var topLevel = (TopLevel)sender!; + topLevel.TemplateApplied -= TopLevelOnTemplateApplied; + InstallFromTopLevel(topLevel); + } +} \ No newline at end of file