feat: Add Loading styles and demos.

This commit is contained in:
NaBian committed 2026-06-16 00:23:53 +08:00
1 parent db0ced2f00
commit 5bfbbe1904
8 files changed
+587 -1

No files matched your search

@@ -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" ]
]
}
]
@@ -0,0 +1,27 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:hc="https://handyorg.github.io/handycontrol"
x:Class="HandyControlDemo.UserControl.LoadingDemo"
Background="{DynamicResource RegionBrush}">
<TransitioningContentControl>
<StackPanel Width="600"
Margin="32"
VerticalAlignment="Center">
<hc:LoadingLine />
<hc:LoadingLine Margin="0,30"
Foreground="BlueViolet"
Theme="{StaticResource LoadingLineLarge}" />
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Center">
<hc:LoadingCircle />
<Border VerticalAlignment="Center"
Margin="32,0,0,0"
Background="{DynamicResource PrimaryBrush}"
CornerRadius="10">
<hc:LoadingCircle Theme="{StaticResource LoadingCircleLight}"
Margin="10" />
</Border>
</StackPanel>
</StackPanel>
</TransitioningContentControl>
</UserControl>
@@ -0,0 +1,9 @@
namespace HandyControlDemo.UserControl;
public partial class LoadingDemo : Avalonia.Controls.UserControl
{
public LoadingDemo()
{
InitializeComponent();
}
}
@@ -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<CancellationTokenSource> AnimationTokens = new();
public static readonly StyledProperty<bool> IsRunningProperty =
AvaloniaProperty.Register<LoadingBase, bool>(nameof(IsRunning), true);
public static readonly StyledProperty<int> DotCountProperty =
AvaloniaProperty.Register<LoadingBase, int>(nameof(DotCount), 5);
public static readonly StyledProperty<double> DotIntervalProperty =
AvaloniaProperty.Register<LoadingBase, double>(nameof(DotInterval), 10d);
public static readonly StyledProperty<IBrush?> DotBorderBrushProperty =
AvaloniaProperty.Register<LoadingBase, IBrush?>(nameof(DotBorderBrush));
public static readonly StyledProperty<double> DotBorderThicknessProperty =
AvaloniaProperty.Register<LoadingBase, double>(nameof(DotBorderThickness), 0d);
public static readonly StyledProperty<double> DotDiameterProperty =
AvaloniaProperty.Register<LoadingBase, double>(nameof(DotDiameter), 6d);
public static readonly StyledProperty<double> DotSpeedProperty =
AvaloniaProperty.Register<LoadingBase, double>(nameof(DotSpeed), 4d);
public static readonly StyledProperty<double> DotDelayTimeProperty =
AvaloniaProperty.Register<LoadingBase, double>(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;
}
}
@@ -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<double> DotOffSetProperty =
AvaloniaProperty.Register<LoadingCircle, double>(nameof(DotOffSet), 20d);
public double DotOffSet
{
get => GetValue(DotOffSetProperty);
set => SetValue(DotOffSetProperty, value);
}
public static readonly StyledProperty<bool> NeedHiddenProperty =
AvaloniaProperty.Register<LoadingCircle, bool>(nameof(NeedHidden), true);
public bool NeedHidden
{
get => GetValue(NeedHiddenProperty);
set => SetValue(NeedHiddenProperty, value);
}
static LoadingCircle()
{
DotSpeedProperty.OverrideDefaultValue<LoadingCircle>(6d);
DotDelayTimeProperty.OverrideDefaultValue<LoadingCircle>(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;
}
}
@@ -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;
}
}
@@ -0,0 +1,89 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:ClassModifier="internal"
xmlns:hc="clr-namespace:HandyControl.Controls">
<ControlTheme x:Key="LoadingBaseStyle"
TargetType="hc:LoadingBase">
<Setter Property="Focusable" Value="False" />
<Setter Property="DotDiameter" Value="10" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryBrush}" />
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter Content="{TemplateBinding Content}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
</ControlTemplate>
</Setter>
</ControlTheme>
<!-- LoadingLine -->
<ControlTheme x:Key="LoadingLineBaseStyle"
BasedOn="{StaticResource LoadingBaseStyle}"
TargetType="hc:LoadingLine">
<Setter Property="DotDiameter" Value="5" />
</ControlTheme>
<ControlTheme x:Key="{x:Type hc:LoadingLine}"
BasedOn="{StaticResource LoadingLineBaseStyle}"
TargetType="hc:LoadingLine" />
<ControlTheme x:Key="LoadingLineLight"
BasedOn="{StaticResource LoadingLineBaseStyle}"
TargetType="hc:LoadingLine">
<Setter Property="Foreground" Value="{DynamicResource TextIconBrush}" />
</ControlTheme>
<ControlTheme x:Key="LoadingLineLarge"
BasedOn="{StaticResource LoadingLineBaseStyle}"
TargetType="hc:LoadingLine">
<Setter Property="DotDiameter" Value="10" />
</ControlTheme>
<ControlTheme x:Key="LoadingLineLargeLight"
BasedOn="{StaticResource LoadingLineLarge}"
TargetType="hc:LoadingLine">
<Setter Property="Foreground" Value="{DynamicResource TextIconBrush}" />
</ControlTheme>
<!-- LoadingCircle -->
<ControlTheme x:Key="LoadingCircleBaseStyle"
BasedOn="{StaticResource LoadingBaseStyle}"
TargetType="hc:LoadingCircle">
<Setter Property="Width" Value="40" />
<Setter Property="Height" Value="40" />
<Setter Property="DotDiameter" Value="5" />
<Setter Property="DotInterval" Value="30" />
<Setter Property="DotOffSet" Value="60" />
<Setter Property="DotDelayTime" Value="90" />
<Setter Property="DotSpeed" Value="4" />
</ControlTheme>
<ControlTheme x:Key="{x:Type hc:LoadingCircle}"
BasedOn="{StaticResource LoadingCircleBaseStyle}"
TargetType="hc:LoadingCircle" />
<ControlTheme x:Key="LoadingCircleLight"
BasedOn="{StaticResource LoadingCircleBaseStyle}"
TargetType="hc:LoadingCircle">
<Setter Property="Foreground" Value="{DynamicResource TextIconBrush}" />
</ControlTheme>
<ControlTheme x:Key="LoadingCircleLarge"
BasedOn="{StaticResource LoadingCircleBaseStyle}"
TargetType="hc:LoadingCircle">
<Setter Property="Width" Value="100" />
<Setter Property="Height" Value="100" />
<Setter Property="DotDiameter" Value="10" />
<Setter Property="DotInterval" Value="20" />
<Setter Property="DotOffSet" Value="40" />
<Setter Property="DotDelayTime" Value="120" />
</ControlTheme>
<ControlTheme x:Key="LoadingCircleLargeLight"
BasedOn="{StaticResource LoadingCircleLarge}"
TargetType="hc:LoadingCircle">
<Setter Property="Foreground" Value="{DynamicResource TextIconBrush}" />
</ControlTheme>
</ResourceDictionary>
@@ -51,6 +51,7 @@
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/Rate.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/Expander.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/ProgressBar.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/Loading.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/Calendar.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/CalendarDatePicker.axaml" />
<MergeResourceInclude Source="avares://HandyControl/Themes/Styles/DatePicker.axaml" />