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 @@ +