feat(Statistic): introduce Statistic and TimerStatistic components with formatting, theming, and responsive layout support

This commit is contained in:
chinboy
2026-05-07 08:45:28 +08:00
parent 459c550394
commit b67832a7b3
24 changed files with 959 additions and 1 deletions
@@ -20,6 +20,7 @@ namespace AtomUIGallery.Localization
DataDisplay_List,
DataDisplay_QRCode,
DataDisplay_Segmented,
DataDisplay_Statistic,
DataDisplay_Tag,
DataDisplay_Timeline,
DataDisplay_Tour,
@@ -46,6 +46,7 @@ public sealed class ShowCaseViewModule : IViewModule
locator.Map<GroupBoxViewModel, GroupBoxShowCase>(() => new GroupBoxShowCase());
locator.Map<ResultViewModel, ResultShowCase>(() => new ResultShowCase());
locator.Map<BadgeViewModel, BadgeShowCase>(() => new BadgeShowCase());
locator.Map<StatisticViewModel, StatisticShowCase>(() => new StatisticShowCase());
locator.Map<TimelineViewModel, TimelineShowCase>(() => new TimelineShowCase());
locator.Map<QRCodeViewModel, QRCodeShowCase>(() => new QRCodeShowCase());
locator.Map<LineEditViewModel, LineEditShowCase>(() => new LineEditShowCase());
@@ -0,0 +1,50 @@
using AtomUI.Controls;
using ReactiveUI;
namespace AtomUIGallery.ShowCases.ViewModels;
public class StatisticViewModel : ReactiveObject, IRoutableViewModel
{
public static EntityKey ID = "Statistic";
public IScreen HostScreen { get; }
public string? UrlPathSegment => ID.ToString();
private double _values = 112893;
public double Values
{
get => _values;
set => this.RaiseAndSetIfChanged(ref _values, value);
}
private DateTime _deadline;
public DateTime Deadline
{
get => _deadline;
set => this.RaiseAndSetIfChanged(ref _deadline, value);
}
private DateTime _tenSecondsLater;
public DateTime TenSecondsLater
{
get => _tenSecondsLater;
set => this.RaiseAndSetIfChanged(ref _tenSecondsLater, value);
}
private DateTime _before;
public DateTime Before
{
get => _before;
set => this.RaiseAndSetIfChanged(ref _before, value);
}
public StatisticViewModel(IScreen screen)
{
HostScreen = screen;
}
}
@@ -0,0 +1,81 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:atom="https://atomui.net"
xmlns:antdicons="https://atomui.net/icons/antdesign"
xmlns:gallery="https://atomui.net/oss-controls/gallery"
xmlns:vm="using:AtomUIGallery.ShowCases.ViewModels"
x:Class="AtomUIGallery.ShowCases.Views.StatisticShowCase"
x:DataType="vm:StatisticViewModel">
<gallery:ShowCasePanel>
<gallery:ShowCaseItem Title="Basic" Description="Simplest Usage." IsOccupyEntireRow="True">
<UniformGrid Columns="2" Rows="2">
<atom:Statistic Header="Active Users" Value="112893" />
<StackPanel Orientation="Vertical" Spacing="16">
<atom:Statistic Header="Account Balance (CNY)" Value="112893" Precision="2" />
<atom:Button ButtonType="Primary">Recharge</atom:Button>
</StackPanel>
<atom:Statistic Header="Active Users" Value="112893" IsLoading="True" />
</UniformGrid>
</gallery:ShowCaseItem>
<gallery:ShowCaseItem Title="Unit"
Description="Add unit through prefix and suffix."
IsOccupyEntireRow="True">
<UniformGrid Columns="2" Rows="1">
<atom:Statistic Header="Feedback" Value="1128" ValuePrefixAddOn="{antdicons:AntDesignIconProvider LikeOutlined}" />
<atom:Statistic Header="Unmerged" Value="93" ValueSuffixAddOn="/ 100" />
</UniformGrid>
</gallery:ShowCaseItem>
<gallery:ShowCaseItem Title="In Card"
Description="Display statistic data in Card."
IsOccupyEntireRow="True">
<Border Background="#F0F2F5" Padding="20, 30">
<UniformGrid Columns="2" Rows="1" ColumnSpacing="20">
<atom:Card SizeType="Large">
<atom:Statistic Header="Active"
Value="11.28"
Precision="2"
ValuePrefixAddOn="{antdicons:AntDesignIconProvider ArrowUpOutlined}"
ValueSuffixAddOn="%"
ContentForeground="#3f8600" />
</atom:Card>
<atom:Card SizeType="Large">
<atom:Statistic Header="Idle"
Value="9.3"
Precision="2"
ValuePrefixAddOn="{antdicons:AntDesignIconProvider ArrowDownOutlined}"
ValueSuffixAddOn="%"
ContentForeground="#cf1322" />
</atom:Card>
</UniformGrid>
</Border>
</gallery:ShowCaseItem>
<gallery:ShowCaseItem Title="Animated number" Description="Animated number with StatisticCountUp." IsOccupyEntireRow="True">
<UniformGrid Columns="2" Rows="2">
<atom:Statistic Header="Active Users" Value="112893">
<atom:Statistic.Content>
<atom:StatisticCountUp EndValue="{Binding Values}" Precision="2" />
</atom:Statistic.Content>
</atom:Statistic>
<atom:Statistic Header="Account Balance (CNY)" Value="112893">
<atom:Statistic.Content>
<atom:StatisticCountUp EndValue="{Binding Values}" Precision="2" />
</atom:Statistic.Content>
</atom:Statistic>
</UniformGrid>
</gallery:ShowCaseItem>
<gallery:ShowCaseItem Title="Timer" Description="Timer component." IsOccupyEntireRow="True">
<UniformGrid Columns="2" Rows="3" RowSpacing="10">
<atom:TimerStatistic Value="{Binding Deadline}" />
<atom:TimerStatistic Header="Million Seconds" Value="{Binding Deadline}" Format="hh\:mm\:ss\.fff" />
<atom:TimerStatistic Header="Countdown" Value="{Binding TenSecondsLater}" />
<atom:TimerStatistic Header="Countup" Value="{Binding Before}" />
<atom:TimerStatistic Header="Day Level (Countdown)" Value="{Binding Deadline}" Format="d\ \天\ h\ \时\ m\ \分\ s\ \秒" />
<atom:TimerStatistic Header="Day Level (Countup)" Value="{Binding Before}" Format="d\ \天\ h\ \时\ m\ \分\ s\ \秒" />
</UniformGrid>
</gallery:ShowCaseItem>
</gallery:ShowCasePanel>
</UserControl>
@@ -0,0 +1,22 @@
using AtomUIGallery.ShowCases.ViewModels;
using ReactiveUI;
using ReactiveUI.Avalonia;
namespace AtomUIGallery.ShowCases.Views;
public partial class StatisticShowCase : ReactiveUserControl<StatisticViewModel>
{
public StatisticShowCase()
{
this.WhenActivated(disposables =>
{
if (DataContext is StatisticViewModel viewModel)
{
viewModel.Deadline = DateTime.Now.Add(TimeSpan.FromSeconds(60 * 60 * 24 * 2 + 30));
viewModel.Before = DateTime.Now.Subtract(TimeSpan.FromSeconds(60 * 60 * 24 * 2 + 30));
viewModel.TenSecondsLater = DateTime.Now.AddSeconds(10);
}
});
InitializeComponent();
}
}
@@ -54,6 +54,7 @@ internal class en_US : LanguageProvider
public const string DataDisplay_Segmented = "Segmented";
public const string DataDisplay_GroupBox = "GroupBox";
public const string DataDisplay_Badge = "Badge";
public const string DataDisplay_Statistic = "Statistic";
public const string DataDisplay_Timeline = "Timeline";
public const string DataDisplay_QRCode = "QRCode";
public const string DataDisplay_InfoFlyout = "InfoFlyout";
@@ -54,6 +54,7 @@ internal class zh_CN : LanguageProvider
public const string DataDisplay_Segmented = "Segmented 分段控制器";
public const string DataDisplay_GroupBox = "GroupBox 分组框";
public const string DataDisplay_Badge = "Badge 徽标";
public const string DataDisplay_Statistic = "Statistic 统计数值";
public const string DataDisplay_Timeline = "Timeline 时间轴";
public const string DataDisplay_QRCode = "QRCode 二维码";
public const string DataDisplay_InfoFlyout = "InfoFlyout 信息浮层";
@@ -91,6 +91,7 @@ public class CaseNavigationViewModel : ReactiveObject, IActivatableViewModel
_showCaseViewModelFactories.Add(GroupBoxViewModel.ID, () => new GroupBoxViewModel(HostScreen));
_showCaseViewModelFactories.Add(ResultViewModel.ID, () => new ResultViewModel(HostScreen));
_showCaseViewModelFactories.Add(BadgeViewModel.ID, () => new BadgeViewModel(HostScreen));
_showCaseViewModelFactories.Add(StatisticViewModel.ID, () => new StatisticViewModel(HostScreen));
_showCaseViewModelFactories.Add(TimelineViewModel.ID, () => new TimelineViewModel(HostScreen));
_showCaseViewModelFactories.Add(QRCodeViewModel.ID, () => new QRCodeViewModel(HostScreen));
_showCaseViewModelFactories.Add(LineEditViewModel.ID, () => new LineEditViewModel(HostScreen));
@@ -111,6 +111,8 @@
ItemKey="{x:Static viewmodels:GroupBoxViewModel.ID}" />
<atom:NavMenuNode Header="{gallery:CaseNavigationLangResource DataDisplay_Badge}"
ItemKey="{x:Static viewmodels:BadgeViewModel.ID}" />
<atom:NavMenuNode Header="{gallery:CaseNavigationLangResource DataDisplay_Statistic}"
ItemKey="{x:Static viewmodels:StatisticViewModel.ID}" />
<atom:NavMenuNode Header="{gallery:CaseNavigationLangResource DataDisplay_Timeline}"
ItemKey="{x:Static viewmodels:TimelineViewModel.ID}" />
<atom:NavMenuNode Header="{gallery:CaseNavigationLangResource DataDisplay_QRCode}"
@@ -62,7 +62,7 @@
<ResourceInclude Source="Spin/Themes/SpinThemes.axaml" />
<ResourceInclude Source="Splitter/Themes/SplitterThemes.axaml" />
<ResourceInclude Source="SplitView/Themes/SplitViewTheme.axaml" />
<!-- <ResourceInclude Source="Statistic/Themes/StatisticThemes.axaml" /> -->
<ResourceInclude Source="Statistic/Themes/StatisticThemes.axaml" />
<!-- <ResourceInclude Source="Steps/Themes/StepsThemes.axaml" /> -->
<ResourceInclude Source="Switch/Themes/ToggleSwitchThemes.axaml" />
<ResourceInclude Source="TabControl/Themes/TabControlThemes.axaml" />
@@ -63,6 +63,7 @@ namespace AtomUI.Theme
tokenTypes.Add(typeof(AtomUI.Desktop.Controls.SpinToken));
tokenTypes.Add(typeof(AtomUI.Desktop.Controls.SplitterToken));
tokenTypes.Add(typeof(AtomUI.Desktop.Controls.SplitViewToken));
tokenTypes.Add(typeof(AtomUI.Desktop.Controls.StatisticToken));
tokenTypes.Add(typeof(AtomUI.Desktop.Controls.StepsToken));
tokenTypes.Add(typeof(AtomUI.Desktop.Controls.TabControlToken));
tokenTypes.Add(typeof(AtomUI.Desktop.Controls.TagToken));
@@ -922,6 +922,12 @@ namespace AtomUI.Desktop.Controls.DesignTokens
PaneOpenMotionDuration
}
public enum StatisticTokenKind
{
ContentFontSize,
TitleFontSize
}
public enum StepsTokenKind
{
CustomIconFontSize,
@@ -1773,6 +1779,17 @@ namespace AtomUI.Desktop.Controls.DesignTokens
}
}
public class StatisticTokenResourceExtension : TokenResourceExtension<StatisticTokenKind>
{
public StatisticTokenResourceExtension()
{
}
public StatisticTokenResourceExtension(StatisticTokenKind kind) : base(kind)
{
}
}
public class StepsTokenResourceExtension : TokenResourceExtension<StepsTokenKind>
{
public StepsTokenResourceExtension()
@@ -0,0 +1,144 @@
using AtomUI.Controls;
using AtomUI.Reflection;
using AtomUI.Theme;
using Avalonia;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.LogicalTree;
using Avalonia.Media;
using Avalonia.Metadata;
namespace AtomUI.Desktop.Controls;
public abstract class AbstractStatistic : HeaderedContentControl
{
#region 公共属性定义
public static readonly StyledProperty<string> DecimalSeparatorProperty =
AvaloniaProperty.Register<AbstractStatistic, string>(nameof(DecimalSeparator), ".");
public static readonly StyledProperty<string> GroupSeparatorProperty =
AvaloniaProperty.Register<AbstractStatistic, string>(nameof(GroupSeparator), ",");
public static readonly StyledProperty<int> PrecisionProperty =
AvaloniaProperty.Register<AbstractStatistic, int>(nameof(Precision), 0);
public static readonly StyledProperty<bool> IsLoadingProperty =
AvaloniaProperty.Register<AbstractStatistic, bool>(nameof(IsLoading));
public static readonly StyledProperty<object?> ValuePrefixAddOnProperty =
AvaloniaProperty.Register<AbstractStatistic, object?>(nameof(ValuePrefixAddOn));
public static readonly StyledProperty<IDataTemplate?> ValuePrefixAddOnTemplateProperty =
AvaloniaProperty.Register<AbstractStatistic, IDataTemplate?>(nameof(ValuePrefixAddOnTemplate));
public static readonly StyledProperty<object?> ValueSuffixAddOnProperty =
AvaloniaProperty.Register<AbstractStatistic, object?>(nameof(ValueSuffixAddOn));
public static readonly StyledProperty<IDataTemplate?> ValueSuffixAddOnTemplateProperty =
AvaloniaProperty.Register<AbstractStatistic, IDataTemplate?>(nameof(ValueSuffixAddOnTemplate));
public static readonly StyledProperty<IBrush?> ContentForegroundProperty =
AvaloniaProperty.Register<AbstractStatistic, IBrush?>(nameof(ContentForeground));
public static readonly StyledProperty<double> ContentFontSizeProperty =
AvaloniaProperty.Register<AbstractStatistic, double>(nameof(ContentFontSize));
public string DecimalSeparator
{
get => GetValue(DecimalSeparatorProperty);
set => SetValue(DecimalSeparatorProperty, value);
}
public string GroupSeparator
{
get => GetValue(GroupSeparatorProperty);
set => SetValue(GroupSeparatorProperty, value);
}
public int Precision
{
get => GetValue(PrecisionProperty);
set => SetValue(PrecisionProperty, value);
}
public bool IsLoading
{
get => GetValue(IsLoadingProperty);
set => SetValue(IsLoadingProperty, value);
}
[DependsOn(nameof(ValuePrefixAddOnTemplate))]
public object? ValuePrefixAddOn
{
get => GetValue(ValuePrefixAddOnProperty);
set => SetValue(ValuePrefixAddOnProperty, value);
}
public IDataTemplate? ValuePrefixAddOnTemplate
{
get => GetValue(ValuePrefixAddOnTemplateProperty);
set => SetValue(ValuePrefixAddOnTemplateProperty, value);
}
[DependsOn(nameof(ValueSuffixAddOnTemplate))]
public object? ValueSuffixAddOn
{
get => GetValue(ValueSuffixAddOnProperty);
set => SetValue(ValueSuffixAddOnProperty, value);
}
public IDataTemplate? ValueSuffixAddOnTemplate
{
get => GetValue(ValueSuffixAddOnTemplateProperty);
set => SetValue(ValueSuffixAddOnTemplateProperty, value);
}
public IBrush? ContentForeground
{
get => GetValue(ContentForegroundProperty);
set => SetValue(ContentForegroundProperty, value);
}
public double ContentFontSize
{
get => GetValue(ContentFontSizeProperty);
set => SetValue(ContentFontSizeProperty, value);
}
#endregion
static AbstractStatistic()
{
HeaderProperty.Changed.AddClassHandler<AbstractStatistic>((x, e) => x.HandleChildChanged(e));
ValuePrefixAddOnProperty.Changed.AddClassHandler<AbstractStatistic>((x, e) => x.HandleChildChanged(e));
ValueSuffixAddOnProperty.Changed.AddClassHandler<AbstractStatistic>((x, e) => x.HandleChildChanged(e));
AffectsMeasure<AbstractStatistic>(DecimalSeparatorProperty, GroupSeparatorProperty, PrecisionProperty);
}
public AbstractStatistic()
{
this.RegisterTokenResourceScope(StatisticToken.ScopeProvider);
}
private void HandleChildChanged(AvaloniaPropertyChangedEventArgs e)
{
if (e.OldValue is ILogical oldChild)
{
LogicalChildren.Remove(oldChild);
if (oldChild is Icon icon)
{
icon.SetTemplatedParent(null);
}
}
if (e.NewValue is ILogical newChild)
{
LogicalChildren.Add(newChild);
if (newChild is Icon icon)
{
icon.SetTemplatedParent(this);
}
}
}
}
@@ -0,0 +1,85 @@
using Avalonia;
namespace AtomUI.Desktop.Controls;
public class Statistic : AbstractStatistic
{
#region 公共属性定义
public static readonly StyledProperty<object?> ValueProperty =
AvaloniaProperty.Register<AbstractStatistic, object?>(nameof(Value));
public object? Value
{
get => GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
public Func<Statistic, object?, string?>? Formatter { get; set; }
#endregion
#region 内部属性定义
internal static readonly DirectProperty<Statistic, string?> EffectiveValueProperty =
AvaloniaProperty.RegisterDirect<Statistic, string?>(nameof(EffectiveValue),
o => o.EffectiveValue,
(o, v) => o.EffectiveValue = v);
private string? _effectiveValue;
internal string? EffectiveValue
{
get => _effectiveValue;
set => SetAndRaise(EffectiveValueProperty, ref _effectiveValue, value);
}
#endregion
static Statistic()
{
ContentProperty.Changed.AddClassHandler<Statistic>((x, e) => x.HandleContentChanged(e));
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == ValueProperty ||
change.Property == DecimalSeparatorProperty ||
change.Property == GroupSeparatorProperty ||
change.Property == PrecisionProperty)
{
GenerateEffectiveValue();
}
}
protected override void OnInitialized()
{
base.OnInitialized();
if (Content == null)
{
SetCurrentValue(ContentProperty, EffectiveValue);
}
}
private void GenerateEffectiveValue()
{
if (Formatter != null)
{
SetCurrentValue(EffectiveValueProperty, Formatter(this, Value));
}
else if (Value != null)
{
var effectiveValue = StatisticUtils.FormatNumber(Value, GroupSeparator, DecimalSeparator, Precision);
SetCurrentValue(EffectiveValueProperty, effectiveValue);
}
}
private void HandleContentChanged(AvaloniaPropertyChangedEventArgs e)
{
if (e.NewValue is StatisticCountUp countUp)
{
countUp.DataContext = this;
}
}
}
@@ -0,0 +1,114 @@
using AtomUI.Theme;
using Avalonia;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
namespace AtomUI.Desktop.Controls;
public class StatisticCountUp : TemplatedControl
{
#region 公共属性定义
public static readonly StyledProperty<double> EndValueProperty =
AvaloniaProperty.Register<StatisticCountUp, double>(nameof(EndValue));
public static readonly StyledProperty<string> DecimalSeparatorProperty =
AvaloniaProperty.Register<StatisticCountUp, string>(nameof(DecimalSeparator), ".");
public static readonly StyledProperty<string> GroupSeparatorProperty =
AvaloniaProperty.Register<StatisticCountUp, string>(nameof(GroupSeparator), ",");
public static readonly StyledProperty<int> PrecisionProperty =
AvaloniaProperty.Register<StatisticCountUp, int>(nameof(Precision), 0);
public double EndValue
{
get => GetValue(EndValueProperty);
set => SetValue(EndValueProperty, value);
}
public string DecimalSeparator
{
get => GetValue(DecimalSeparatorProperty);
set => SetValue(DecimalSeparatorProperty, value);
}
public string GroupSeparator
{
get => GetValue(GroupSeparatorProperty);
set => SetValue(GroupSeparatorProperty, value);
}
public int Precision
{
get => GetValue(PrecisionProperty);
set => SetValue(PrecisionProperty, value);
}
#endregion
#region 内部属性定义
public static readonly StyledProperty<double> AnimatingValueProperty =
AvaloniaProperty.Register<StatisticCountUp, double>(nameof(AnimatingValue), 0);
internal static readonly DirectProperty<StatisticCountUp, string?> FormattedValueProperty =
AvaloniaProperty.RegisterDirect<StatisticCountUp, string?>(nameof(FormattedValue),
o => o.FormattedValue,
(o, v) => o.FormattedValue = v);
public double AnimatingValue
{
get => GetValue(AnimatingValueProperty);
set => SetValue(AnimatingValueProperty, value);
}
private string? _formattedValue;
internal string? FormattedValue
{
get => _formattedValue;
set => SetAndRaise(FormattedValueProperty, ref _formattedValue, value);
}
#endregion
static StatisticCountUp()
{
AffectsMeasure<StatisticCountUp>(FormattedValueProperty);
}
public StatisticCountUp()
{
this.RegisterTokenResourceScope(StatisticToken.ScopeProvider);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (this.IsAttachedToVisualTree())
{
if (change.Property == EndValueProperty)
{
SetCurrentValue(AnimatingValueProperty, EndValue);
}
}
if (change.Property == AnimatingValueProperty ||
change.Property == GroupSeparatorProperty ||
change.Property == PrecisionProperty ||
change.Property == DecimalSeparatorProperty)
{
var effectiveValue = StatisticUtils.FormatNumber(AnimatingValue, GroupSeparator, DecimalSeparator, Precision);
SetCurrentValue(FormattedValueProperty, effectiveValue);
}
}
protected override void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
AnimatingValue = EndValue;
}
}
@@ -0,0 +1,38 @@
using AtomUI.Desktop.Controls.DesignTokens;
using AtomUI.Theme;
using AtomUI.Theme.TokenSystem;
namespace AtomUI.Desktop.Controls;
[ControlDesignToken]
internal class StatisticToken : AbstractControlDesignToken
{
public const string ID = "Statistic";
public static readonly ControlTokenResourceScopeProvider ScopeProvider = new(ID);
/// <summary>
/// 标题字体大小
/// Title font size
/// </summary>
public double TitleFontSize { get; set; }
/// <summary>
/// 内容字体大小
/// Content font size
/// </summary>
public double ContentFontSize { get; set; }
public StatisticToken()
: base(ID)
{
}
public override void CalculateTokenValues(bool isDarkMode)
{
base.CalculateTokenValues(isDarkMode);
TitleFontSize = SharedToken.FontSize;
ContentFontSize = SharedToken.FontSizeHeading3;
}
protected override Type GetTokenKindType() => typeof(StatisticTokenKind);
}
@@ -0,0 +1,50 @@
using System.Globalization;
namespace AtomUI.Desktop.Controls;
internal static class StatisticUtils
{
public static string? FormatNumber(object value, string groupSeparator, string decimalSeparator, int precision)
{
var strValue = value.ToString();
var customFormat = new CultureInfo("en-US");
customFormat.NumberFormat.NumberGroupSeparator = groupSeparator;
customFormat.NumberFormat.NumberDecimalSeparator = decimalSeparator;
customFormat.NumberFormat.NumberDecimalDigits = precision;
string? effectiveValue = null;
if (int.TryParse(strValue, out var intValue))
{
effectiveValue = intValue.ToString("N", customFormat);
}
else if (uint.TryParse(strValue, out var uintValue))
{
effectiveValue = uintValue.ToString("N", customFormat);
}
else if (long.TryParse(strValue, out var longValue))
{
effectiveValue = longValue.ToString("N", customFormat);
}
else if (long.TryParse(strValue, out var ulongValue))
{
effectiveValue = ulongValue.ToString("N", customFormat);
}
else if (float.TryParse(strValue, out var floatValue))
{
effectiveValue = floatValue.ToString("N", customFormat);
}
else if (double.TryParse(strValue, out var doubleValue))
{
effectiveValue = doubleValue.ToString("N", customFormat);
}
else if (decimal.TryParse(strValue, out var decimalValue))
{
effectiveValue = decimalValue.ToString("N", customFormat);
}
else
{
effectiveValue = strValue;
}
return effectiveValue;
}
}
@@ -0,0 +1,59 @@
<ControlTheme TargetType="atom:AbstractStatistic"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:atom="https://atomui.net"
x:Class="AtomUI.Desktop.Controls.Themes.AbstractStatisticTheme"
x:ClassModifier="internal">
<Setter Property="Template">
<ControlTemplate TargetType="atom:AbstractStatistic">
<Border Name="Frame"
Background="{TemplateBinding Background}"
CornerRadius="{TemplateBinding CornerRadius}">
<StackPanel Name="RootLayout">
<ContentPresenter Name="HeaderPresenter"
Content="{TemplateBinding Header}"
ContentTemplate="{TemplateBinding HeaderTemplate}"
IsVisible="{TemplateBinding Header, Converter={x:Static ObjectConverters.IsNotNull}}" />
<atom:Skeleton IsLoading="{TemplateBinding IsLoading}" IsActive="True" IsShowParagraph="False">
<StackPanel Orientation="Horizontal" Name="ContentLayout">
<ContentPresenter Name="ValuePrefixAddOn"
Foreground="{TemplateBinding ContentForeground}"
FontSize="{TemplateBinding ContentFontSize}"
Content="{TemplateBinding ValuePrefixAddOn}"
ContentTemplate="{TemplateBinding ValuePrefixAddOnTemplate}"
IsVisible="{TemplateBinding ValuePrefixAddOn, Converter={x:Static ObjectConverters.IsNotNull}}" />
<ContentPresenter Name="ContentPresenter"
Foreground="{TemplateBinding ContentForeground}"
FontSize="{TemplateBinding ContentFontSize}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}" />
<ContentPresenter Name="ValueSuffixAddOn"
Foreground="{TemplateBinding ContentForeground}"
FontSize="{TemplateBinding ContentFontSize}"
Content="{TemplateBinding ValueSuffixAddOn}"
ContentTemplate="{TemplateBinding ValueSuffixAddOnTemplate}"
IsVisible="{TemplateBinding ValueSuffixAddOn, Converter={x:Static ObjectConverters.IsNotNull}}" />
</StackPanel>
</atom:Skeleton>
</StackPanel>
</Border>
</ControlTemplate>
</Setter>
<Style Selector="^:is(atom|AbstractStatistic)">
<Style Selector="^ /template/ ContentPresenter#HeaderPresenter">
<Setter Property="Foreground" Value="{atom:SharedTokenResource ColorTextDescription}" />
<Setter Property="FontSize" Value="{atom:StatisticTokenResource TitleFontSize}" />
</Style>
<Setter Property="ContentForeground" Value="{atom:SharedTokenResource ColorTextHeading}" />
<Setter Property="ContentFontSize" Value="{atom:StatisticTokenResource ContentFontSize}" />
<Style Selector="^ /template/ atom|Icon">
<Setter Property="FillBrush" Value="{TemplateBinding ContentForeground}" />
<Setter Property="StrokeBrush" Value="{TemplateBinding ContentForeground}" />
<Setter Property="Width" Value="{atom:StatisticTokenResource ContentFontSize}" />
<Setter Property="Height" Value="{atom:StatisticTokenResource ContentFontSize}" />
</Style>
</Style>
</ControlTheme>
@@ -0,0 +1,7 @@
using Avalonia.Styling;
namespace AtomUI.Desktop.Controls.Themes;
internal class AbstractStatisticTheme : ControlTheme
{
}
@@ -0,0 +1,24 @@
<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:StatisticCountUp}" TargetType="atom:StatisticCountUp">
<Setter Property="Template">
<ControlTemplate>
<TextBlock Text="{TemplateBinding FormattedValue}"/>
</ControlTemplate>
</Setter>
<Setter Property="Foreground" Value="{atom:SharedTokenResource ColorTextHeading}"/>
<Setter Property="FontSize" Value="{atom:StatisticTokenResource ContentFontSize}"/>
<Style Selector="^">
<Setter Property="Transitions">
<Transitions>
<DoubleTransition Property="AnimatingValue" Duration="{atom:SharedTokenResourceValue Kind=MotionDurationVerySlow}" Easing="ExponentialEaseOut" />
</Transitions>
</Setter>
</Style>
</ControlTheme>
</ResourceDictionary>
@@ -0,0 +1,26 @@
<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:Statistic}" TargetType="atom:Statistic">
<ControlTheme.BasedOn>
<atom:AbstractStatisticTheme TargetType="atom:Statistic"/>
</ControlTheme.BasedOn>
<Style Selector="^ /template/ StackPanel#RootLayout">
<Setter Property="Spacing" Value="{atom:SharedTokenResource SpacingXXS}"/>
</Style>
<Style Selector="^[IsLoading=True]">
<Style Selector="^ /template/ StackPanel#RootLayout">
<Setter Property="Spacing" Value="{atom:SharedTokenResource Spacing}"/>
</Style>
</Style>
<Style Selector="^ /template/ StackPanel#ContentLayout">
<Setter Property="Spacing" Value="{atom:SharedTokenResource SpacingXXS}"/>
</Style>
</ControlTheme>
</ResourceDictionary>
@@ -0,0 +1,11 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:atom="https://atomui.net"
x:ClassModifier="internal">
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="StatisticTheme.axaml"/>
<ResourceInclude Source="TimerStatisticTheme.axaml"/>
<ResourceInclude Source="StatisticCountUpTheme.axaml"/>
</ResourceDictionary.MergedDictionaries>
<atom:AbstractStatisticTheme x:Key="{x:Type atom:AbstractStatistic}" TargetType="atom:AbstractStatistic"/>
</ResourceDictionary>
@@ -0,0 +1,45 @@
<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:TimerStatistic}" TargetType="atom:TimerStatistic">
<ControlTheme.BasedOn>
<atom:AbstractStatisticTheme TargetType="atom:TimerStatistic"/>
</ControlTheme.BasedOn>
<Setter Property="Template">
<ControlTemplate>
<Border Name="Frame"
Background="{TemplateBinding Background}"
CornerRadius="{TemplateBinding CornerRadius}">
<StackPanel Name="RootLayout">
<ContentPresenter Name="HeaderPresenter"
Content="{TemplateBinding Header}"
ContentTemplate="{TemplateBinding HeaderTemplate}"
IsVisible="{TemplateBinding Header, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<atom:Skeleton IsLoading="{TemplateBinding IsLoading}" IsActive="True" IsShowParagraph="False">
<StackPanel Orientation="Horizontal" Name="ContentLayout">
<ContentPresenter Name="ValuePrefixAddOn"
Foreground="{TemplateBinding ContentForeground}"
FontSize="{TemplateBinding ContentFontSize}"
Content="{TemplateBinding ValuePrefixAddOn}"
ContentTemplate="{TemplateBinding ValuePrefixAddOnTemplate}"
IsVisible="{TemplateBinding ValuePrefixAddOn, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<ContentPresenter Name="ContentPresenter"
Foreground="{TemplateBinding ContentForeground}"
FontSize="{TemplateBinding ContentFontSize}"
Content="{TemplateBinding RemainingTimeText}"/>
<ContentPresenter Name="ValueSuffixAddOn"
Foreground="{TemplateBinding ContentForeground}"
FontSize="{TemplateBinding ContentFontSize}"
Content="{TemplateBinding ValueSuffixAddOn}"
ContentTemplate="{TemplateBinding ValueSuffixAddOnTemplate}"
IsVisible="{TemplateBinding ValueSuffixAddOn, Converter={x:Static ObjectConverters.IsNotNull}}"/>
</StackPanel>
</atom:Skeleton>
</StackPanel>
</Border>
</ControlTemplate>
</Setter>
</ControlTheme>
</ResourceDictionary>
@@ -0,0 +1,177 @@
using Avalonia;
using Avalonia.Threading;
namespace AtomUI.Desktop.Controls;
public class TimerStatistic : AbstractStatistic
{
#region 公共属性定义
public static readonly StyledProperty<DateTime> ValueProperty =
AvaloniaProperty.Register<AbstractStatistic, DateTime>(nameof(Value));
public static readonly StyledProperty<string?> FormatProperty =
AvaloniaProperty.Register<AbstractStatistic, string?>(nameof(Format));
public static readonly StyledProperty<TimeSpan> RefreshDurationProperty =
AvaloniaProperty.Register<AbstractStatistic, TimeSpan>(nameof(RefreshDuration), TimeSpan.FromMilliseconds(10));
public DateTime Value
{
get => GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
public string? Format
{
get => GetValue(FormatProperty);
set => SetValue(FormatProperty, value);
}
public TimeSpan RefreshDuration
{
get => GetValue(RefreshDurationProperty);
set => SetValue(RefreshDurationProperty, value);
}
public Func<TimeSpan, string>? Formatter;
#endregion
#region 公共事件定义
public event EventHandler? CountdownFinished;
#endregion
#region 内部属性定义
internal static readonly DirectProperty<TimerStatistic, TimeSpan> RemainingTimeProperty =
AvaloniaProperty.RegisterDirect<TimerStatistic, TimeSpan>(nameof(RemainingTime),
o => o.RemainingTime,
(o, v) => o.RemainingTime = v);
internal static readonly DirectProperty<TimerStatistic, string?> RemainingTimeTextProperty =
AvaloniaProperty.RegisterDirect<TimerStatistic, string?>(nameof(RemainingTimeText),
o => o.RemainingTimeText,
(o, v) => o.RemainingTimeText = v);
private TimeSpan _remainingTime;
internal TimeSpan RemainingTime
{
get => _remainingTime;
set => SetAndRaise(RemainingTimeProperty, ref _remainingTime, value);
}
private string? _remainingTimeText;
internal string? RemainingTimeText
{
get => _remainingTimeText;
set => SetAndRaise(RemainingTimeTextProperty, ref _remainingTimeText, value);
}
#endregion
private DispatcherTimer? _timer;
static TimerStatistic()
{
AffectsMeasure<TimerStatistic>(ValueProperty, FormatProperty, RemainingTimeTextProperty);
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
_timer?.Start();
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
if (_timer != null)
{
_timer.Stop();
_timer.Tick -= HandleTickElapsed;
_timer = null;
}
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == RemainingTimeProperty ||
change.Property == FormatProperty)
{
GenerateRemainingTimeText();
}
if (change.Property == ValueProperty ||
change.Property == RefreshDurationProperty)
{
BuildTimer(true);
}
}
private void GenerateRemainingTimeText()
{
string? formattedText;
if (Formatter != null)
{
formattedText = Formatter(RemainingTime);
}
else
{
if (Format != null)
{
formattedText = RemainingTime.ToString(Format);
}
else if (RemainingTime.TotalHours >= 1)
{
formattedText = $"{(int)RemainingTime.TotalHours:00}:{RemainingTime.Minutes:00}:{RemainingTime.Seconds:00}";
}
else
{
formattedText = $"{(int)RemainingTime.TotalMinutes:00}:{RemainingTime.Seconds:00}";
}
}
SetCurrentValue(RemainingTimeTextProperty, formattedText);
}
private void BuildTimer(bool start)
{
// 先清理旧定时器
if (_timer != null)
{
_timer.Stop();
_timer.Tick -= HandleTickElapsed;
}
_timer = new DispatcherTimer();
_timer.Interval = RefreshDuration;
_timer.Tick += HandleTickElapsed;
if (start)
{
_timer.Start();
}
}
private void HandleTickElapsed(object? sender, EventArgs args)
{
if (Value > DateTime.Now)
{
RemainingTime = Value - DateTime.Now;
}
else
{
RemainingTime = DateTime.Now - Value;
}
if (RemainingTime <= TimeSpan.Zero)
{
RemainingTime = TimeSpan.Zero;
_timer?.Stop();
CountdownFinished?.Invoke(this, EventArgs.Empty);
}
}
}