diff --git a/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json b/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json
index a024474f..b81c0c7f 100644
--- a/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json
+++ b/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json
@@ -53,7 +53,8 @@
[ "HoneycombPanel", "HoneycombPanelDemo", "Brush.PolygonHexagon", "", "Layout" ],
[ "UniformSpacingPanel", "UniformSpacingPanelDemo", "Brush.IncreaseHorizontalSpacing", "", "Layout" ],
[ "ProgressBar", "ProgressBarDemo", "Brush.ProgressBar", "", "Feedback" ],
- [ "Rate", "RateDemo", "Brush.Favorite", "", "Feedback" ]
+ [ "Rate", "RateDemo", "Brush.Favorite", "", "Feedback" ],
+ [ "Loading", "LoadingDemo", "Brush.Loading", "", "Feedback" ]
]
}
]
diff --git a/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/LoadingDemo.axaml b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/LoadingDemo.axaml
new file mode 100644
index 00000000..e8e6a741
--- /dev/null
+++ b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/LoadingDemo.axaml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/LoadingDemo.axaml.cs b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/LoadingDemo.axaml.cs
new file mode 100644
index 00000000..fa4d5b4d
--- /dev/null
+++ b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/LoadingDemo.axaml.cs
@@ -0,0 +1,9 @@
+namespace HandyControlDemo.UserControl;
+
+public partial class LoadingDemo : Avalonia.Controls.UserControl
+{
+ public LoadingDemo()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/Avalonia/HandyControl_Avalonia/Controls/Loading/LoadingBase.cs b/src/Avalonia/HandyControl_Avalonia/Controls/Loading/LoadingBase.cs
new file mode 100644
index 00000000..cc3da066
--- /dev/null
+++ b/src/Avalonia/HandyControl_Avalonia/Controls/Loading/LoadingBase.cs
@@ -0,0 +1,178 @@
+using System.Collections.Generic;
+using System.Threading;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Data;
+using Avalonia.Media;
+
+namespace HandyControl.Controls;
+
+public abstract class LoadingBase : ContentControl
+{
+ protected readonly List AnimationTokens = new();
+
+ public static readonly StyledProperty IsRunningProperty =
+ AvaloniaProperty.Register(nameof(IsRunning), true);
+
+ public static readonly StyledProperty DotCountProperty =
+ AvaloniaProperty.Register(nameof(DotCount), 5);
+
+ public static readonly StyledProperty DotIntervalProperty =
+ AvaloniaProperty.Register(nameof(DotInterval), 10d);
+
+ public static readonly StyledProperty DotBorderBrushProperty =
+ AvaloniaProperty.Register(nameof(DotBorderBrush));
+
+ public static readonly StyledProperty DotBorderThicknessProperty =
+ AvaloniaProperty.Register(nameof(DotBorderThickness), 0d);
+
+ public static readonly StyledProperty DotDiameterProperty =
+ AvaloniaProperty.Register(nameof(DotDiameter), 6d);
+
+ public static readonly StyledProperty DotSpeedProperty =
+ AvaloniaProperty.Register(nameof(DotSpeed), 4d);
+
+ public static readonly StyledProperty DotDelayTimeProperty =
+ AvaloniaProperty.Register(nameof(DotDelayTime), 80d);
+
+ protected readonly Canvas PrivateCanvas = new()
+ {
+ ClipToBounds = true
+ };
+
+ protected LoadingBase()
+ {
+ Content = PrivateCanvas;
+ }
+
+ public bool IsRunning
+ {
+ get => GetValue(IsRunningProperty);
+ set => SetValue(IsRunningProperty, value);
+ }
+
+ public int DotCount
+ {
+ get => GetValue(DotCountProperty);
+ set => SetValue(DotCountProperty, value);
+ }
+
+ public double DotInterval
+ {
+ get => GetValue(DotIntervalProperty);
+ set => SetValue(DotIntervalProperty, value);
+ }
+
+ public IBrush? DotBorderBrush
+ {
+ get => GetValue(DotBorderBrushProperty);
+ set => SetValue(DotBorderBrushProperty, value);
+ }
+
+ public double DotBorderThickness
+ {
+ get => GetValue(DotBorderThicknessProperty);
+ set => SetValue(DotBorderThicknessProperty, value);
+ }
+
+ public double DotDiameter
+ {
+ get => GetValue(DotDiameterProperty);
+ set => SetValue(DotDiameterProperty, value);
+ }
+
+ public double DotSpeed
+ {
+ get => GetValue(DotSpeedProperty);
+ set => SetValue(DotSpeedProperty, value);
+ }
+
+ public double DotDelayTime
+ {
+ get => GetValue(DotDelayTimeProperty);
+ set => SetValue(DotDelayTimeProperty, value);
+ }
+
+ protected abstract void UpdateDots();
+
+ protected void StopAnimations()
+ {
+ foreach (var cts in AnimationTokens)
+ {
+ try { cts.Cancel(); cts.Dispose(); } catch { }
+ }
+ AnimationTokens.Clear();
+ }
+
+ protected override void OnSizeChanged(SizeChangedEventArgs e)
+ {
+ base.OnSizeChanged(e);
+ if (IsRunning)
+ {
+ StopAnimations();
+ UpdateDots();
+ }
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == IsRunningProperty)
+ {
+ if (IsRunning)
+ {
+ UpdateDots();
+ }
+ else
+ {
+ StopAnimations();
+ }
+ return;
+ }
+
+ if (change.Property == DotCountProperty
+ || change.Property == DotIntervalProperty
+ || change.Property == DotBorderBrushProperty
+ || change.Property == DotBorderThicknessProperty
+ || change.Property == DotDiameterProperty
+ || change.Property == DotSpeedProperty
+ || change.Property == DotDelayTimeProperty
+ || change.Property == ForegroundProperty)
+ {
+ if (IsRunning)
+ {
+ StopAnimations();
+ UpdateDots();
+ }
+ }
+ }
+
+ protected override void OnLoaded(Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ base.OnLoaded(e);
+ if (IsRunning)
+ {
+ StopAnimations();
+ UpdateDots();
+ }
+ }
+
+ protected override void OnUnloaded(Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ base.OnUnloaded(e);
+ StopAnimations();
+ }
+
+ protected virtual Ellipse CreateEllipse(int index)
+ {
+ var ellipse = new Ellipse();
+ ellipse.Bind(WidthProperty, new Binding(nameof(DotDiameter)) { Source = this });
+ ellipse.Bind(HeightProperty, new Binding(nameof(DotDiameter)) { Source = this });
+ ellipse.Bind(Shape.FillProperty, new Binding(nameof(Foreground)) { Source = this });
+ ellipse.Bind(Shape.StrokeThicknessProperty, new Binding(nameof(DotBorderThickness)) { Source = this });
+ ellipse.Bind(Shape.StrokeProperty, new Binding(nameof(DotBorderBrush)) { Source = this });
+ return ellipse;
+ }
+}
diff --git a/src/Avalonia/HandyControl_Avalonia/Controls/Loading/LoadingCircle.cs b/src/Avalonia/HandyControl_Avalonia/Controls/Loading/LoadingCircle.cs
new file mode 100644
index 00000000..42570e16
--- /dev/null
+++ b/src/Avalonia/HandyControl_Avalonia/Controls/Loading/LoadingCircle.cs
@@ -0,0 +1,176 @@
+using System;
+using System.Threading;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Styling;
+
+namespace HandyControl.Controls;
+
+public class LoadingCircle : LoadingBase
+{
+ public static readonly StyledProperty DotOffSetProperty =
+ AvaloniaProperty.Register(nameof(DotOffSet), 20d);
+
+ public double DotOffSet
+ {
+ get => GetValue(DotOffSetProperty);
+ set => SetValue(DotOffSetProperty, value);
+ }
+
+ public static readonly StyledProperty NeedHiddenProperty =
+ AvaloniaProperty.Register(nameof(NeedHidden), true);
+
+ public bool NeedHidden
+ {
+ get => GetValue(NeedHiddenProperty);
+ set => SetValue(NeedHiddenProperty, value);
+ }
+
+ static LoadingCircle()
+ {
+ DotSpeedProperty.OverrideDefaultValue(6d);
+ DotDelayTimeProperty.OverrideDefaultValue(220d);
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ if (change.Property == DotOffSetProperty || change.Property == NeedHiddenProperty)
+ {
+ if (IsRunning)
+ {
+ StopAnimations();
+ UpdateDots();
+ }
+ }
+ base.OnPropertyChanged(change);
+ }
+
+ protected sealed override void UpdateDots()
+ {
+ var dotCount = DotCount;
+ var dotInterval = DotInterval;
+ var dotSpeed = DotSpeed;
+ var dotDelayTime = DotDelayTime;
+ var needHidden = NeedHidden;
+ var dotOffset = DotOffSet;
+
+ if (dotCount < 1) return;
+ PrivateCanvas.Children.Clear();
+
+ for (var i = 0; i < dotCount; i++)
+ {
+ var rotate = new RotateTransform();
+ var border = CreateBorder(i, dotInterval, needHidden, rotate);
+ PrivateCanvas.Children.Add(border);
+
+ var subAngle = -dotInterval * i;
+ rotate.Angle = subAngle;
+
+ var rotateAnimation = new Animation
+ {
+ Duration = TimeSpan.FromSeconds(dotSpeed),
+ Delay = TimeSpan.FromMilliseconds(dotDelayTime * i),
+ IterationCount = IterationCount.Infinite,
+ FillMode = FillMode.Forward,
+ Easing = new LinearEasing(),
+ Children =
+ {
+ BuildAngleFrame(0d / 7d, 0 + subAngle, null),
+ BuildAngleFrame(0.75d / 7d, 180 + subAngle, EaseOutSpline),
+ BuildAngleFrame(2.75d / 7d, 180 + dotOffset + subAngle, null),
+ BuildAngleFrame(3.5d / 7d, 360 + subAngle, EaseInSpline),
+ BuildAngleFrame(4.25d / 7d, 540 + subAngle, EaseOutSpline),
+ BuildAngleFrame(6.25d / 7d, 540 + dotOffset + subAngle, null),
+ BuildAngleFrame(1d, 720 + subAngle, EaseInSpline),
+ }
+ };
+
+ var rotateCts = new CancellationTokenSource();
+ AnimationTokens.Add(rotateCts);
+ _ = rotateAnimation.RunAsync(border, rotateCts.Token);
+
+ if (needHidden)
+ {
+ var totalSeconds = dotSpeed + 0.4d;
+ var visibleCue = dotSpeed / totalSeconds;
+
+ var opacityAnimation = new Animation
+ {
+ Duration = TimeSpan.FromSeconds(totalSeconds),
+ Delay = TimeSpan.FromMilliseconds(dotDelayTime * i),
+ IterationCount = IterationCount.Infinite,
+ FillMode = FillMode.Forward,
+ Easing = new LinearEasing(),
+ Children =
+ {
+ new KeyFrame
+ {
+ Cue = new Cue(0d),
+ Setters = { new Setter(Visual.OpacityProperty, 1d) }
+ },
+ new KeyFrame
+ {
+ Cue = new Cue(Math.Max(0d, visibleCue - 0.0001d)),
+ Setters = { new Setter(Visual.OpacityProperty, 1d) }
+ },
+ new KeyFrame
+ {
+ Cue = new Cue(visibleCue),
+ Setters = { new Setter(Visual.OpacityProperty, 0d) }
+ },
+ new KeyFrame
+ {
+ Cue = new Cue(1d),
+ Setters = { new Setter(Visual.OpacityProperty, 0d) }
+ }
+ }
+ };
+
+ var opacityCts = new CancellationTokenSource();
+ AnimationTokens.Add(opacityCts);
+ _ = opacityAnimation.RunAsync(border, opacityCts.Token);
+ }
+ }
+ }
+
+ private static readonly KeySpline EaseOutSpline = new(0d, 0d, 0.5d, 1d);
+ private static readonly KeySpline EaseInSpline = new(0.5d, 0d, 1d, 1d);
+
+ private static KeyFrame BuildAngleFrame(double cue, double angle, KeySpline? spline)
+ {
+ var frame = new KeyFrame
+ {
+ Cue = new Cue(Math.Min(1d, Math.Max(0d, cue))),
+ Setters = { new Setter(RotateTransform.AngleProperty, angle) }
+ };
+ if (spline != null)
+ {
+ frame.KeySpline = spline;
+ }
+ return frame;
+ }
+
+ private Border CreateBorder(int index, double dotInterval, bool needHidden, RotateTransform rotate)
+ {
+ var ellipse = CreateEllipse(index);
+ ellipse.HorizontalAlignment = HorizontalAlignment.Center;
+ ellipse.VerticalAlignment = VerticalAlignment.Bottom;
+
+ var border = new Border
+ {
+ Child = ellipse,
+ RenderTransformOrigin = RelativePoint.Center,
+ RenderTransform = rotate,
+ Opacity = needHidden ? 0d : 1d
+ };
+ border.Bind(WidthProperty, new Binding(nameof(Width)) { Source = this });
+ border.Bind(HeightProperty, new Binding(nameof(Height)) { Source = this });
+
+ return border;
+ }
+}
diff --git a/src/Avalonia/HandyControl_Avalonia/Controls/Loading/LoadingLine.cs b/src/Avalonia/HandyControl_Avalonia/Controls/Loading/LoadingLine.cs
new file mode 100644
index 00000000..d5913132
--- /dev/null
+++ b/src/Avalonia/HandyControl_Avalonia/Controls/Loading/LoadingLine.cs
@@ -0,0 +1,105 @@
+using System;
+using System.Threading;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Controls.Shapes;
+using Avalonia.Data;
+using Avalonia.Layout;
+using Avalonia.Styling;
+
+namespace HandyControl.Controls;
+
+public class LoadingLine : LoadingBase
+{
+ private const double MoveLength = 80;
+ private const double UniformScale = 0.6;
+
+ private static readonly KeySpline EaseOutSpline = new(0d, 0d, 0.5d, 1d);
+ private static readonly KeySpline EaseInSpline = new(0.5d, 0d, 1d, 1d);
+
+ public LoadingLine()
+ {
+ this.Bind(HeightProperty, new Binding(nameof(DotDiameter)) { Source = this });
+ }
+
+ protected sealed override void UpdateDots()
+ {
+ var dotCount = DotCount;
+ var dotInterval = DotInterval;
+ var dotDiameter = DotDiameter;
+ var dotSpeed = DotSpeed;
+ var dotDelayTime = DotDelayTime;
+
+ if (dotCount < 1) return;
+ PrivateCanvas.Children.Clear();
+
+ var actualWidth = Bounds.Width;
+ if (double.IsNaN(actualWidth) || actualWidth <= 0)
+ {
+ // Bounds 还没准备好;OnSizeChanged 之后会再触发一次
+ return;
+ }
+
+ var centerWidth = dotDiameter * dotCount + dotInterval * (dotCount - 1) + MoveLength;
+ var speedDownLength = (actualWidth - MoveLength) / 2;
+ var speedUniformLength = centerWidth / 2;
+
+ for (var i = 0; i < dotCount; i++)
+ {
+ var ellipse = CreateLineEllipse(i, dotInterval, dotDiameter);
+ PrivateCanvas.Children.Add(ellipse);
+
+ var startLeft = ellipse.Margin.Left;
+
+ var animation = new Animation
+ {
+ Duration = TimeSpan.FromSeconds(dotSpeed),
+ Delay = TimeSpan.FromMilliseconds(dotDelayTime * i),
+ IterationCount = IterationCount.Infinite,
+ FillMode = FillMode.Forward,
+ // WPF 用 PowerEase EaseOut/EaseIn + Linear,Avalonia 单缓动近似为 LinearEasing,
+ // 关键帧自身位置已经表达了"快/匀/慢"的节奏。
+ Easing = new LinearEasing(),
+ Children =
+ {
+ new KeyFrame
+ {
+ Cue = new Cue(0d),
+ Setters = { new Setter(MarginProperty, new Thickness(startLeft, 0, 0, 0)) }
+ },
+ new KeyFrame
+ {
+ Cue = new Cue((1 - UniformScale) / 2),
+ KeySpline = EaseOutSpline,
+ Setters = { new Setter(MarginProperty, new Thickness(speedDownLength + startLeft, 0, 0, 0)) }
+ },
+ new KeyFrame
+ {
+ Cue = new Cue((1 + UniformScale) / 2),
+ Setters = { new Setter(MarginProperty, new Thickness(speedDownLength + speedUniformLength + startLeft, 0, 0, 0)) }
+ },
+ new KeyFrame
+ {
+ Cue = new Cue(1d),
+ KeySpline = EaseInSpline,
+ Setters = { new Setter(MarginProperty, new Thickness(actualWidth + startLeft + speedUniformLength, 0, 0, 0)) }
+ }
+ }
+ };
+
+ var cts = new CancellationTokenSource();
+ AnimationTokens.Add(cts);
+ _ = animation.RunAsync(ellipse, cts.Token);
+ }
+ }
+
+ private Ellipse CreateLineEllipse(int index, double dotInterval, double dotDiameter)
+ {
+ var ellipse = base.CreateEllipse(index);
+ ellipse.HorizontalAlignment = HorizontalAlignment.Left;
+ ellipse.VerticalAlignment = VerticalAlignment.Top;
+ ellipse.Margin = new Thickness(-(dotInterval + dotDiameter) * index, 0, 0, 0);
+ return ellipse;
+ }
+}
diff --git a/src/Avalonia/HandyControl_Avalonia/Themes/Styles/Loading.axaml b/src/Avalonia/HandyControl_Avalonia/Themes/Styles/Loading.axaml
new file mode 100644
index 00000000..811c805a
--- /dev/null
+++ b/src/Avalonia/HandyControl_Avalonia/Themes/Styles/Loading.axaml
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Avalonia/HandyControl_Avalonia/Themes/Styles/MergedStyles.axaml b/src/Avalonia/HandyControl_Avalonia/Themes/Styles/MergedStyles.axaml
index 28fb54b6..742bef18 100644
--- a/src/Avalonia/HandyControl_Avalonia/Themes/Styles/MergedStyles.axaml
+++ b/src/Avalonia/HandyControl_Avalonia/Themes/Styles/MergedStyles.axaml
@@ -51,6 +51,7 @@
+