diff --git a/demo/Ursa.Demo/Models/MenuKeys.cs b/demo/Ursa.Demo/Models/MenuKeys.cs
index 56e6a49..530349b 100644
--- a/demo/Ursa.Demo/Models/MenuKeys.cs
+++ b/demo/Ursa.Demo/Models/MenuKeys.cs
@@ -20,6 +20,7 @@ public static class MenuKeys
public const string MenuKeyMessageBox = "MessageBox";
public const string MenuKeyNavigation = "Navigation";
public const string MenuKeyNavMenu = "NavMenu";
+ public const string MenuKeyNumberDisplayer = "NumberDisplayer";
public const string MenuKeyNumericUpDown = "NumericUpDown";
public const string MenuKeyPagination = "Pagination";
public const string MenuKeyRangeSlider = "RangeSlider";
diff --git a/demo/Ursa.Demo/Pages/NumberDisplayerDemo.axaml b/demo/Ursa.Demo/Pages/NumberDisplayerDemo.axaml
new file mode 100644
index 0000000..c550a8c
--- /dev/null
+++ b/demo/Ursa.Demo/Pages/NumberDisplayerDemo.axaml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/demo/Ursa.Demo/Pages/NumberDisplayerDemo.axaml.cs b/demo/Ursa.Demo/Pages/NumberDisplayerDemo.axaml.cs
new file mode 100644
index 0000000..35df3ef
--- /dev/null
+++ b/demo/Ursa.Demo/Pages/NumberDisplayerDemo.axaml.cs
@@ -0,0 +1,13 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace Ursa.Demo.Pages;
+
+public partial class NumberDisplayerDemo : UserControl
+{
+ public NumberDisplayerDemo()
+ {
+ InitializeComponent();
+ }
+}
\ No newline at end of file
diff --git a/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs b/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs
index 43166bd..d8c0cb4 100644
--- a/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs
+++ b/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs
@@ -42,6 +42,7 @@ public class MainViewViewModel : ViewModelBase
MenuKeys.MenuKeyMessageBox => new MessageBoxDemoViewModel(),
MenuKeys.MenuKeyNavigation => new NavigationMenuDemoViewModel(),
MenuKeys.MenuKeyNavMenu => new NavMenuDemoViewModel(),
+ MenuKeys.MenuKeyNumberDisplayer => new NumberDisplayerDemoViewModel(),
MenuKeys.MenuKeyNumericUpDown => new NumericUpDownDemoViewModel(),
MenuKeys.MenuKeyPagination => new PaginationDemoViewModel(),
MenuKeys.MenuKeyRangeSlider => new RangeSliderDemoViewModel(),
diff --git a/demo/Ursa.Demo/ViewModels/MenuViewModel.cs b/demo/Ursa.Demo/ViewModels/MenuViewModel.cs
index 6f6da25..c24db24 100644
--- a/demo/Ursa.Demo/ViewModels/MenuViewModel.cs
+++ b/demo/Ursa.Demo/ViewModels/MenuViewModel.cs
@@ -29,7 +29,8 @@ public class MenuViewModel: ViewModelBase
new() { MenuHeader = "Message Box", Key = MenuKeys.MenuKeyMessageBox, Status = "New" },
new() { MenuHeader = "Navigation", Key = MenuKeys.MenuKeyNavigation, Status = "WIP" },
new() { MenuHeader = "Nav Menu", Key = MenuKeys.MenuKeyNavMenu, Status = "WIP"},
- new() { MenuHeader = "NumericUpDown", Key = MenuKeys.MenuKeyNumericUpDown, Status = "New" },
+ new() { MenuHeader = "Number Displayer", Key = MenuKeys.MenuKeyNumberDisplayer, Status = "New" },
+ new() { MenuHeader = "Numeric UpDown", Key = MenuKeys.MenuKeyNumericUpDown, Status = "New" },
new() { MenuHeader = "Pagination", Key = MenuKeys.MenuKeyPagination },
new() { MenuHeader = "RangeSlider", Key = MenuKeys.MenuKeyRangeSlider, Status = "New"},
new() { MenuHeader = "Selection List", Key = MenuKeys.MenuKeySelectionList, Status = "New" },
diff --git a/demo/Ursa.Demo/ViewModels/NumberDisplayerDemoViewModel.cs b/demo/Ursa.Demo/ViewModels/NumberDisplayerDemoViewModel.cs
new file mode 100644
index 0000000..befdd5b
--- /dev/null
+++ b/demo/Ursa.Demo/ViewModels/NumberDisplayerDemoViewModel.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Windows.Input;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace Ursa.Demo.ViewModels;
+
+public partial class NumberDisplayerDemoViewModel: ObservableObject
+{
+ [ObservableProperty] private int _value;
+ [ObservableProperty] private double _doubleValue;
+ [ObservableProperty] private DateTime _dateValue;
+ public ICommand IncreaseCommand { get; }
+ public NumberDisplayerDemoViewModel()
+ {
+ IncreaseCommand = new RelayCommand(OnChange);
+ Value = 0;
+ DoubleValue = 0d;
+ DateValue = DateTime.Now;
+ }
+
+ private void OnChange()
+ {
+ Random r = new Random();
+ Value = r.Next(int.MaxValue);
+ DoubleValue = r.NextDouble() * 100000;
+ DateValue = DateTime.Today.AddDays(r.Next(1000));
+ }
+}
\ No newline at end of file
diff --git a/src/Ursa.Themes.Semi/Controls/NumberDisplayer.axaml b/src/Ursa.Themes.Semi/Controls/NumberDisplayer.axaml
new file mode 100644
index 0000000..1a62aec
--- /dev/null
+++ b/src/Ursa.Themes.Semi/Controls/NumberDisplayer.axaml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Ursa.Themes.Semi/Controls/_index.axaml b/src/Ursa.Themes.Semi/Controls/_index.axaml
index 0b2dae5..dee8aee 100644
--- a/src/Ursa.Themes.Semi/Controls/_index.axaml
+++ b/src/Ursa.Themes.Semi/Controls/_index.axaml
@@ -20,6 +20,7 @@
+
diff --git a/src/Ursa/Controls/NumberDisplayer/Implementations.cs b/src/Ursa/Controls/NumberDisplayer/Implementations.cs
new file mode 100644
index 0000000..a43fa0a
--- /dev/null
+++ b/src/Ursa/Controls/NumberDisplayer/Implementations.cs
@@ -0,0 +1,73 @@
+using Avalonia.Animation;
+
+namespace Ursa.Controls;
+
+public class Int32Displayer : NumberDisplayer
+{
+ protected override Type StyleKeyOverride { get; } = typeof(NumberDisplayerBase);
+
+ protected override InterpolatingAnimator GetAnimator()
+ {
+ return new IntAnimator();
+ }
+
+ private class IntAnimator : InterpolatingAnimator
+ {
+ public override int Interpolate(double progress, int oldValue, int newValue)
+ {
+ return oldValue + (int)((newValue - oldValue) * progress);
+ }
+ }
+
+ protected override string GetString(int value)
+ {
+ return value.ToString(StringFormat);
+ }
+}
+
+public class DoubleDisplayer : NumberDisplayer
+{
+ protected override Type StyleKeyOverride { get; } = typeof(NumberDisplayerBase);
+
+ protected override InterpolatingAnimator GetAnimator()
+ {
+ return new DoubleAnimator();
+ }
+
+ private class DoubleAnimator : InterpolatingAnimator
+ {
+ public override double Interpolate(double progress, double oldValue, double newValue)
+ {
+ return oldValue + (newValue - oldValue) * progress;
+ }
+ }
+
+ protected override string GetString(double value)
+ {
+ return value.ToString(StringFormat);
+ }
+}
+
+public class DateDisplay : NumberDisplayer
+{
+ protected override Type StyleKeyOverride { get; } = typeof(NumberDisplayerBase);
+
+ protected override InterpolatingAnimator GetAnimator()
+ {
+ return new DateAnimator();
+ }
+
+ private class DateAnimator : InterpolatingAnimator
+ {
+ public override DateTime Interpolate(double progress, DateTime oldValue, DateTime newValue)
+ {
+ var diff = (newValue - oldValue).TotalSeconds;
+ return oldValue + TimeSpan.FromSeconds(diff * progress);
+ }
+ }
+
+ protected override string GetString(DateTime value)
+ {
+ return value.ToString(StringFormat);
+ }
+}
\ No newline at end of file
diff --git a/src/Ursa/Controls/NumberDisplayer/NumberDisplayerBase.cs b/src/Ursa/Controls/NumberDisplayer/NumberDisplayerBase.cs
new file mode 100644
index 0000000..25e817d
--- /dev/null
+++ b/src/Ursa/Controls/NumberDisplayer/NumberDisplayerBase.cs
@@ -0,0 +1,133 @@
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Controls.Primitives;
+using Avalonia.Data;
+using Avalonia.Styling;
+
+namespace Ursa.Controls;
+
+public abstract class NumberDisplayerBase : TemplatedControl
+{
+ public static readonly DirectProperty InternalTextProperty = AvaloniaProperty.RegisterDirect(
+ nameof(InternalText), o => o.InternalText, (o, v) => o.InternalText = v);
+ private string _internalText;
+
+ internal string InternalText
+ {
+ get => _internalText;
+ set => SetAndRaise(InternalTextProperty, ref _internalText, value);
+ }
+
+ public static readonly StyledProperty DurationProperty = AvaloniaProperty.Register(
+ nameof(Duration));
+
+ public TimeSpan Duration
+ {
+ get => GetValue(DurationProperty);
+ set => SetValue(DurationProperty, value);
+ }
+
+ public static readonly StyledProperty StringFormatProperty = AvaloniaProperty.Register(
+ nameof(StringFormat));
+
+ public string StringFormat
+ {
+ get => GetValue(StringFormatProperty);
+ set => SetValue(StringFormatProperty, value);
+ }
+
+ public static readonly StyledProperty IsSelectableProperty = AvaloniaProperty.Register(
+ nameof(IsSelectable));
+
+ public bool IsSelectable
+ {
+ get => GetValue(IsSelectableProperty);
+ set => SetValue(IsSelectableProperty, value);
+ }
+}
+
+public abstract class NumberDisplayer: NumberDisplayerBase
+{
+ private Animation? _animation;
+ private CancellationTokenSource _cts = new ();
+
+ public static readonly StyledProperty ValueProperty = AvaloniaProperty.Register, T?>(
+ nameof(Value), defaultBindingMode:BindingMode.TwoWay);
+
+ public T? Value
+ {
+ get => GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ private static readonly StyledProperty InternalValueProperty = AvaloniaProperty.Register, T?>(
+ nameof(InternalValue), defaultBindingMode:BindingMode.TwoWay);
+
+ private T? InternalValue
+ {
+ get => GetValue(InternalValueProperty);
+ set => SetValue(InternalValueProperty, value);
+ }
+
+ static NumberDisplayer()
+ {
+ ValueProperty.Changed.AddClassHandler, T?>((item, args) =>
+ {
+ item.OnValueChanged(args.OldValue.Value, args.NewValue.Value);
+ });
+ InternalValueProperty.Changed.AddClassHandler, T?>((item, args) =>
+ {
+ item.InternalText = args.NewValue.Value is null ? string.Empty : item.GetString(args.NewValue.Value);
+ });
+ DurationProperty.Changed.AddClassHandler, TimeSpan>((item, args) =>item.OnDurationChanged(args));
+ }
+
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+ _animation = new Animation
+ {
+ Duration = Duration,
+ FillMode = FillMode.Forward
+ };
+ _animation.Children.Add(new KeyFrame()
+ {
+ Cue = new Cue(0.0),
+ Setters = { new Setter{Property = InternalValueProperty } }
+ });
+ _animation.Children.Add(new KeyFrame()
+ {
+ Cue = new Cue(1.0),
+ Setters = { new Setter{Property = InternalValueProperty } }
+ });
+ Animation.SetAnimator(_animation.Children[0].Setters[0], GetAnimator());
+ Animation.SetAnimator(_animation.Children[1].Setters[0], GetAnimator());
+
+ // Display value directly to text on initialization in case value equals to default.
+ SetCurrentValue(InternalTextProperty, this.GetString(Value));
+ }
+
+ private void OnDurationChanged(AvaloniaPropertyChangedEventArgs args)
+ {
+ if (_animation is null) return;
+ _animation.Duration = args.NewValue.Value;
+ }
+
+ private void OnValueChanged(T? oldValue, T? newValue)
+ {
+ if (_animation is null)
+ {
+ SetCurrentValue(InternalValueProperty, newValue);
+ return;
+ }
+ _cts.Cancel();
+ _cts = new CancellationTokenSource();
+ (_animation.Children[0].Setters[0] as Setter)!.Value = oldValue;
+ (_animation.Children[1].Setters[0] as Setter)!.Value = newValue;
+ _animation.RunAsync(this, _cts.Token);
+ }
+
+ protected abstract InterpolatingAnimator GetAnimator();
+
+ protected abstract string GetString(T? value);
+}