feat(Message,Breadcrumb): port Message control and Breadcrumb showcase from release/5.0

This commit is contained in:
chinboy
2026-05-01 08:23:32 +08:00
parent a640ddb397
commit 046f2441b6
24 changed files with 1087 additions and 5 deletions
@@ -16,7 +16,8 @@ namespace AtomUIGallery.Localization
General_SplitButton,
Layout,
Layout_Splitter,
Navigation
Navigation,
Navigation_Breadcrumb
}
public class CaseNavigationLangResourceExtension : LanguageResourceExtension<CaseNavigationLangResourceKind>
@@ -14,5 +14,6 @@ public sealed class ShowCaseViewModule : IViewModule
locator.Map<ButtonViewModel, ButtonShowCase>(() => new ButtonShowCase());
locator.Map<SplitButtonViewModel, SplitButtonShowCase>(() => new SplitButtonShowCase());
locator.Map<SplitterViewModel, SplitterShowCase>(() => new SplitterShowCase());
locator.Map<BreadcrumbViewModel, BreadcrumbShowCase>(() => new BreadcrumbShowCase());
}
}
@@ -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<BreadcrumbItemData>? _breadcrumbItems = [];
public List<BreadcrumbItemData>? BreadcrumbItems
{
get => _breadcrumbItems;
set => this.RaiseAndSetIfChanged(ref _breadcrumbItems, value);
}
public BreadcrumbViewModel(IScreen screen)
{
Activator = new ViewModelActivator();
HostScreen = screen;
}
}
@@ -0,0 +1,84 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="AtomUIGallery.ShowCases.Views.BreadcrumbShowCase"
xmlns:atom="https://atomui.net"
xmlns:antdicons="https://atomui.net/icons/antdesign"
xmlns:gallery="https://atomui.net/oss-controls/gallery">
<gallery:ShowCasePanel>
<gallery:ShowCaseItem
Title="Basic Usage"
Description="The simplest use.">
<StackPanel>
<atom:Breadcrumb>
<atom:BreadcrumbItem>Home</atom:BreadcrumbItem>
<atom:BreadcrumbItem NavigateContext="#">Application Center</atom:BreadcrumbItem>
<atom:BreadcrumbItem NavigateContext="#">Application List</atom:BreadcrumbItem>
<atom:BreadcrumbItem>An Application</atom:BreadcrumbItem>
</atom:Breadcrumb>
</StackPanel>
</gallery:ShowCaseItem>
<gallery:ShowCaseItem
Title="With an Icon"
Description="The icon should be placed in front of the text.">
<StackPanel>
<atom:Breadcrumb>
<atom:BreadcrumbItem Icon="{antdicons:AntDesignIconProvider Kind=HomeOutlined}"></atom:BreadcrumbItem>
<atom:BreadcrumbItem Icon="{antdicons:AntDesignIconProvider Kind=UserOutlined}" NavigateContext="#">Application List</atom:BreadcrumbItem>
<atom:BreadcrumbItem>Application</atom:BreadcrumbItem>
</atom:Breadcrumb>
</StackPanel>
</gallery:ShowCaseItem>
<gallery:ShowCaseItem
Title="With Params"
Description="With route params.">
<StackPanel>
<atom:Breadcrumb NavigateRequest="HandleNavigateRequest">
<atom:BreadcrumbItem>Users</atom:BreadcrumbItem>
<atom:BreadcrumbItem NavigateContext="Param(1)">Param</atom:BreadcrumbItem>
</atom:Breadcrumb>
</StackPanel>
</gallery:ShowCaseItem>
<gallery:ShowCaseItem
Title="Configuring the Separator"
Description="The separator can be customized by setting the separator property: separator equals >">
<StackPanel>
<atom:Breadcrumb Separator=">">
<atom:BreadcrumbItem>Home</atom:BreadcrumbItem>
<atom:BreadcrumbItem NavigateContext="#">Application Center</atom:BreadcrumbItem>
<atom:BreadcrumbItem NavigateContext="#">Application List</atom:BreadcrumbItem>
<atom:BreadcrumbItem>An Application</atom:BreadcrumbItem>
</atom:Breadcrumb>
</StackPanel>
</gallery:ShowCaseItem>
<gallery:ShowCaseItem
Title="Configuring the Separator Independently"
Description="Customize separator for each other.">
<StackPanel>
<atom:Breadcrumb>
<atom:BreadcrumbItem Separator=":">Location</atom:BreadcrumbItem>
<atom:BreadcrumbItem NavigateContext="#">Application Center</atom:BreadcrumbItem>
<atom:BreadcrumbItem NavigateContext="#">Application List</atom:BreadcrumbItem>
<atom:BreadcrumbItem>An Application</atom:BreadcrumbItem>
</atom:Breadcrumb>
</StackPanel>
</gallery:ShowCaseItem>
<gallery:ShowCaseItem
Title="Generate BreadcrumbItem by template"
Description="Generate BreadcrumbItem by template.">
<StackPanel>
<atom:Breadcrumb Name="TplBreadcrumb">
<atom:Breadcrumb.ItemTemplate>
<DataTemplate x:DataType="atom:BreadcrumbItemData">
<TextBlock Text="{Binding Content}"/>
</DataTemplate>
</atom:Breadcrumb.ItemTemplate>
</atom:Breadcrumb>
</StackPanel>
</gallery:ShowCaseItem>
</gallery:ShowCasePanel>
</UserControl>
@@ -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<BreadcrumbViewModel>
{
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}"
));
}
}
@@ -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";
@@ -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 = "反馈";
@@ -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)
@@ -30,7 +30,10 @@
</atom:NavMenuNode>
<atom:NavMenuNode Header="{gallery:CaseNavigationLangResource Navigation}"
Icon="{antdicons:AntDesignIconProvider Kind=MenuOutlined}" />
Icon="{antdicons:AntDesignIconProvider Kind=MenuOutlined}">
<atom:NavMenuNode Header="{gallery:CaseNavigationLangResource Navigation_Breadcrumb}"
ItemKey="{x:Static viewmodels:BreadcrumbViewModel.ID}" />
</atom:NavMenuNode>
<atom:NavMenuNode Header="{gallery:CaseNavigationLangResource DataEntry}"
Icon="{antdicons:AntDesignIconProvider Kind=FormOutlined}" />
@@ -9,7 +9,7 @@
<!-- <ResourceInclude Source="Alert/Themes/AlertTheme.axaml" /> -->
<!-- <ResourceInclude Source="Avatar/Themes/AvatarThemes.axaml" /> -->
<!-- <ResourceInclude Source="Badge/Themes/BadgeThemes.axaml" /> -->
<!-- <ResourceInclude Source="Breadcrumb/Themes/BreadcrumbThemes.axaml" /> -->
<ResourceInclude Source="Breadcrumb/Themes/BreadcrumbThemes.axaml" />
<ResourceInclude Source="Buttons/Themes/ButtonThemes.axaml" />
<!-- <ResourceInclude Source="ButtonSpinner/Themes/ButtonSpinnerThemes.axaml" /> -->
<!-- <ResourceInclude Source="Cascader/Themes/CascaderThemes.axaml" /> -->
@@ -37,7 +37,7 @@
<!-- <ResourceInclude Source="ListBox/Themes/ListBoxThemes.axaml" /> -->
<!-- <ResourceInclude Source="Mentions/Themes/MentionsThemes.axaml" /> -->
<ResourceInclude Source="Menu/Themes/MenuThemes.axaml" />
<!-- <ResourceInclude Source="Message/Themes/MessageThemes.axaml" /> -->
<ResourceInclude Source="Message/Themes/MessageThemes.axaml" />
<!-- <ResourceInclude Source="MessageBox/Themes/MessageBoxThemes.axaml" /> -->
<ResourceInclude Source="NavMenu/Themes/NavMenuThemes.axaml" />
<!-- <ResourceInclude Source="Notifications/Themes/NotificationsThemes.axaml" /> -->
@@ -60,7 +60,7 @@
<!-- <ResourceInclude Source="Slider/Themes/SliderThemes.axaml" /> -->
<ResourceInclude Source="Space/Themes/SpaceThemes.axaml" />
<!-- <ResourceInclude Source="Spin/Themes/SpinThemes.axaml" /> -->
<!-- <ResourceInclude Source="Splitter/Themes/SplitterThemes.axaml" /> -->
<ResourceInclude Source="Splitter/Themes/SplitterThemes.axaml" />
<!-- <ResourceInclude Source="SplitView/Themes/SplitViewTheme.axaml" /> -->
<!-- <ResourceInclude Source="Statistic/Themes/StatisticThemes.axaml" /> -->
<!-- <ResourceInclude Source="Steps/Themes/StepsThemes.axaml" /> -->
@@ -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));
@@ -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<MessageTokenKind>
{
public MessageTokenResourceExtension()
{
}
public MessageTokenResourceExtension(MessageTokenKind kind) : base(kind)
{
}
}
public class NavMenuTokenResourceExtension : TokenResourceExtension<NavMenuTokenKind>
{
public NavMenuTokenResourceExtension()
@@ -0,0 +1,32 @@
using Avalonia.Controls;
namespace AtomUI.Desktop.Controls;
public interface IMessage
{
/// <summary>
/// Gets the message.
/// </summary>
string Content { get; }
/// <summary>
/// 自定义图标
/// </summary>
PathIcon? Icon { get; }
/// <summary>
/// Gets the <see cref="MessageType" /> of the notification.
/// </summary>
MessageType Type { get; }
/// <summary>
/// Gets the expiration time of the notification after which it will automatically close.
/// If the value is <see cref="TimeSpan.Zero" /> then the notification will remain open until the user closes it.
/// </summary>
TimeSpan Expiration { get; }
/// <summary>
/// Gets an Action to be run when the message is closed.
/// </summary>
Action? OnClose { get; }
}
@@ -0,0 +1,6 @@
namespace AtomUI.Desktop.Controls;
public interface IMessageManager
{
public void Show(IMessage message, string[]? classes = null);
}
@@ -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));
}
}
@@ -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 公共属性定义
/// <summary>
/// Defines the <see cref="IsClosing" /> property.
/// </summary>
public static readonly DirectProperty<MessageCard, bool> IsClosingProperty =
AvaloniaProperty.RegisterDirect<MessageCard, bool>(nameof(IsClosing), o => o.IsClosing);
/// <summary>
/// Defines the <see cref="IsClosed" /> property.
/// </summary>
public static readonly StyledProperty<bool> IsClosedProperty =
AvaloniaProperty.Register<MessageCard, bool>(nameof(IsClosed));
/// <summary>
/// Defines the <see cref="NotificationType" /> property
/// </summary>
public static readonly StyledProperty<MessageType> MessageTypeProperty =
AvaloniaProperty.Register<MessageCard, MessageType>(nameof(MessageType));
/// <summary>
/// Defines the <see cref="MessageClosed" /> event.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> MessageClosedEvent =
RoutedEvent.Register<MessageCard, RoutedEventArgs>(nameof(MessageClosed), RoutingStrategies.Bubble);
public static readonly StyledProperty<PathIcon?> IconProperty =
AvaloniaProperty.Register<MessageCard, PathIcon?>(nameof(Icon));
public static readonly StyledProperty<string> MessageProperty =
AvaloniaProperty.Register<MessageCard, string>(nameof(Message));
public static readonly StyledProperty<bool> IsMotionEnabledProperty =
MotionAwareControlProperty.IsMotionEnabledProperty.AddOwner<MessageCard>();
/// <summary>
/// Determines if the notification is already closing.
/// </summary>
public bool IsClosing
{
get => _isClosing;
private set => SetAndRaise(IsClosingProperty, ref _isClosing, value);
}
/// <summary>
/// Determines if the notification is closed.
/// </summary>
public bool IsClosed
{
get => GetValue(IsClosedProperty);
set => SetValue(IsClosedProperty, value);
}
/// <summary>
/// Gets or sets the type of the notification
/// </summary>
public MessageType MessageType
{
get => GetValue(MessageTypeProperty);
set => SetValue(MessageTypeProperty, value);
}
/// <summary>
/// Raised when the <see cref="MessageCard" /> has closed.
/// </summary>
public event EventHandler<RoutedEventArgs>? 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 内部属性定义
/// <summary>回调:消息卡片关闭时触发,由 WindowMessageManager 设置以避免 lambda 闭包。</summary>
internal Action? OnClose { get; set; }
internal static readonly DirectProperty<MessageCard, TimeSpan> OpenCloseMotionDurationProperty =
AvaloniaProperty.RegisterDirect<MessageCard, TimeSpan>(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>(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);
}
}
}
@@ -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";
}
@@ -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);
/// <summary>
/// 提示框背景色
/// </summary>
public Color ContentBg { get; set; }
/// <summary>
/// 提示框内边距
/// </summary>
public Thickness ContentPadding { get; set; }
/// <summary>
/// 提示框高度
/// </summary>
public double CardHeight { get; set; }
/// <summary>
/// 提醒框图标尺寸
/// </summary>
public double MessageIconSize { get; set; }
/// <summary>
/// 提醒框图标外边距
/// </summary>
public Thickness MessageIconMargin { get; set; }
/// <summary>
/// 提醒框上边缘外边距
/// </summary>
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);
}
@@ -0,0 +1,10 @@
namespace AtomUI.Desktop.Controls;
public enum MessageType
{
Information,
Success,
Warning,
Error,
Loading
}
@@ -0,0 +1,98 @@
<ResourceDictionary
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:atom="https://atomui.net"
x:ClassModifier="internal">
<ControlTheme x:Key="{x:Type atom:MessageCard}" TargetType="atom:MessageCard">
<Setter Property="Template">
<ControlTemplate>
<atom:MotionActor Name="{x:Static atom:BaseMotionActor.MotionActorPart}"
ClipToBounds="False">
<Border Name="{x:Static atom:MessageCardThemeConstants.FramePart}">
<DockPanel Name="{x:Static atom:MessageCardThemeConstants.HeaderContainerPart}"
LastChildFill="True">
<atom:IconPresenter Name="{x:Static atom:MessageCardThemeConstants.IconContentPart}"
DockPanel.Dock="Left"
IsVisible="{TemplateBinding Icon, Converter={x:Static ObjectConverters.IsNotNull}}"
Icon="{TemplateBinding Icon}" />
<SelectableTextBlock Name="{x:Static atom:MessageCardThemeConstants.MessagePart}"
Text="{TemplateBinding Message}" />
</DockPanel>
</Border>
</atom:MotionActor>
</ControlTemplate>
</Setter>
<Setter Property="IsMotionEnabled" Value="{atom:SharedTokenResource EnableMotion}" />
<Style Selector="^ /template/ atom|IconPresenter">
<Setter Property="Width" Value="{atom:MessageTokenResource MessageIconSize}" />
<Setter Property="Height" Value="{atom:MessageTokenResource MessageIconSize}" />
</Style>
<Style Selector="^[MessageType=Information]">
<Style Selector="^ /template/ atom|IconPresenter">
<Setter Property="IconBrush"
Value="{atom:SharedTokenResource ColorPrimary}" />
</Style>
</Style>
<Style Selector="^[MessageType=Loading]">
<Style Selector="^ /template/ atom|IconPresenter">
<Setter Property="IconBrush"
Value="{atom:SharedTokenResource ColorPrimary}" />
</Style>
</Style>
<Style Selector="^[MessageType=Error]">
<Style Selector="^ /template/ atom|IconPresenter">
<Setter Property="IconBrush"
Value="{atom:SharedTokenResource ColorError}" />
</Style>
</Style>
<Style Selector="^[MessageType=Success]">
<Style Selector="^ /template/ atom|IconPresenter">
<Setter Property="IconBrush"
Value="{atom:SharedTokenResource ColorSuccess}" />
</Style>
</Style>
<Style Selector="^[MessageType=Warning]">
<Style Selector="^ /template/ atom|IconPresenter">
<Setter Property="IconBrush"
Value="{atom:SharedTokenResource ColorWarning}" />
</Style>
</Style>
<Setter Property="OpenCloseMotionDuration"
Value="{atom:SharedTokenResource MotionDurationMid}" />
<Setter Property="ClipToBounds" Value="False" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Style Selector="^ /template/ Border#PART_Frame">
<Setter Property="Margin" Value="{atom:MessageTokenResource MessageTopMargin}" />
<Setter Property="Padding" Value="{atom:MessageTokenResource ContentPadding}" />
<Setter Property="BoxShadow" Value="{atom:SharedTokenResource BoxShadows}" />
<Setter Property="Background" Value="{atom:MessageTokenResource ContentBg}" />
<Setter Property="CornerRadius" Value="{atom:SharedTokenResource BorderRadiusLG}" />
</Style>
<Style Selector="^[IsClosed=True]">
<Setter Property="Margin" Value="0" />
</Style>
<Style Selector="^ /template/ SelectableTextBlock#PART_Message">
<Setter Property="LineHeight" Value="{atom:SharedTokenResource FontHeight}" />
<Setter Property="FontSize" Value="{atom:SharedTokenResource FontSize}" />
<Setter Property="Foreground" Value="{atom:SharedTokenResource ColorText}" />
<Setter Property="SelectionBrush" Value="{atom:SharedTokenResource SelectionBackground}" />
<Setter Property="SelectionForegroundBrush" Value="{atom:SharedTokenResource SelectionForeground}" />
</Style>
<Style Selector="^ /template/ atom|IconPresenter#PART_IconContent">
<Setter Property="Margin" Value="{atom:MessageTokenResource MessageIconMargin}" />
</Style>
</ControlTheme>
</ResourceDictionary>
@@ -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";
}
@@ -0,0 +1,8 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:ClassModifier="internal">
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="MessageCardTheme.axaml"/>
<ResourceInclude Source="WindowMessageManagerTheme.axaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
@@ -0,0 +1,23 @@
<ResourceDictionary
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:atom="https://atomui.net"
x:ClassModifier="internal">
<ControlTheme x:Key="{x:Type atom:WindowMessageManager}" TargetType="atom:WindowMessageManager">
<Setter Property="Template">
<ControlTemplate>
<ReversibleStackPanel Name="{x:Static atom:WindowMessageManagerThemeConstants.ItemsPart}" />
</ControlTemplate>
</Setter>
<Setter Property="IsMotionEnabled" Value="{atom:SharedTokenResource EnableMotion}" />
<Style Selector="^:topcenter">
<Style Selector="^ /template/ ReversibleStackPanel#PART_Items">
<Setter Property="ReverseOrder" Value="True"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
<Setter Property="VerticalAlignment" Value="Top"/>
</Style>
</Style>
</ControlTheme>
</ResourceDictionary>
@@ -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 公共属性定义
/// <summary>
/// Defines the <see cref="Position" /> property.
/// </summary>
public static readonly StyledProperty<NotificationPosition> PositionProperty =
AvaloniaProperty.Register<WindowMessageManager, NotificationPosition>(
nameof(Position), NotificationPosition.TopRight);
public static readonly StyledProperty<bool> IsMotionEnabledProperty =
MotionAwareControlProperty.IsMotionEnabledProperty.AddOwner<WindowMessageManager>();
/// <summary>
/// Defines which corner of the screen notifications can be displayed in.
/// </summary>
/// <seealso cref="NotificationPosition" />
public NotificationPosition Position
{
get => GetValue(PositionProperty);
set => SetValue(PositionProperty, value);
}
/// <summary>
/// Defines the <see cref="MaxItems" /> property.
/// </summary>
public static readonly StyledProperty<int> MaxItemsProperty =
AvaloniaProperty.Register<WindowMessageManager, int>(nameof(MaxItems), 5);
/// <summary>
/// Defines the maximum number of notifications visible at once.
/// </summary>
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;
/// <summary>
/// Initializes a new instance of the <see cref="WindowNotificationManager" /> class.
/// </summary>
/// <param name="host">The TopLevel that will host the control.</param>
public WindowMessageManager(TopLevel? host)
{
this.RegisterTokenResourceScope(MessageToken.ScopeProvider);
if (host is not null)
{
_topLevel = host;
InstallFromTopLevel(host);
}
}
static WindowMessageManager()
{
HorizontalAlignmentProperty.OverrideDefaultValue<WindowMessageManager>(HorizontalAlignment.Stretch);
VerticalAlignmentProperty.OverrideDefaultValue<WindowMessageManager>(VerticalAlignment.Stretch);
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
var itemsControl = e.NameScope.Find<Panel>("PART_Items");
_items = itemsControl?.Children;
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
_items?.Clear();
}
/// <summary>
/// Shows a Notification
/// </summary>
/// <param name="message">the content of the message</param>
/// <param name="classes">style classes to apply</param>
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);
}
}
/// <summary>
/// Removes excess messages when the count exceeds MaxItems.
/// </summary>
private void RemoveExcessMessages()
{
var visibleMessages = _items!.OfType<MessageCard>().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);
}
}