diff --git a/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json b/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json index 06e7a0ed..85486a72 100644 --- a/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json +++ b/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json @@ -58,7 +58,8 @@ [ "Tag", "TagDemo", "Brush.Tag", "", "DataDisplay" ], [ "Badge", "BadgeDemo", "Brush.CheckDot", "", "DataDisplay" ], [ "Shield", "ShieldDemo", "Brush.SplitKeyValuePair", "", "DataDisplay" ], - [ "ImageBlock", "ImageBlockDemo", "Brush.ImageStack", "", "DataDisplay" ] + [ "ImageBlock", "ImageBlockDemo", "Brush.ImageStack", "", "DataDisplay" ], + [ "AnimationPath", "AnimationPathDemo", "Brush.Path", "", "DataDisplay" ] ] } ] diff --git a/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/AnimationPathDemo.axaml b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/AnimationPathDemo.axaml new file mode 100644 index 00000000..e3514f03 --- /dev/null +++ b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/AnimationPathDemo.axaml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/AnimationPathDemo.axaml.cs b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/AnimationPathDemo.axaml.cs new file mode 100644 index 00000000..95e0df5b --- /dev/null +++ b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/AnimationPathDemo.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace HandyControlDemo.UserControl; + +public partial class AnimationPathDemo : Avalonia.Controls.UserControl +{ + public AnimationPathDemo() + { + InitializeComponent(); + } +} diff --git a/src/Avalonia/HandyControl_Avalonia/Controls/Other/AnimationPath.cs b/src/Avalonia/HandyControl_Avalonia/Controls/Other/AnimationPath.cs new file mode 100644 index 00000000..4307b6a9 --- /dev/null +++ b/src/Avalonia/HandyControl_Avalonia/Controls/Other/AnimationPath.cs @@ -0,0 +1,424 @@ +using System; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Collections; +using Avalonia.Controls.Shapes; +using Avalonia.Media; +using Avalonia.Threading; +using HandyControl.Tools; + +namespace HandyControl.Controls; + +/// +/// A shape that draws itself along a path over time. +/// Uses animation on the geometry +/// to create a "drawing" or "reveal" effect. +/// +public class AnimationPath : Shape +{ + private readonly DispatcherTimer _timer; + private TimeSpan _elapsed; + private double _pathLength; + private int _remainingRepeats; + private bool _isDisposed; + + public AnimationPath() + { + _timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; // ~60 fps + _timer.Tick += OnTimerTick; + } + + ~AnimationPath() => Dispose(); + + public void Dispose() + { + if (_isDisposed) return; + _timer.Tick -= OnTimerTick; + _timer.Stop(); + LayoutUpdated -= OnLayoutUpdated; + _isDisposed = true; + GC.SuppressFinalize(this); + } + + // ── Properties ── + + /// + /// The geometry path to draw. + /// + public static readonly StyledProperty DataProperty = + AvaloniaProperty.Register(nameof(Data)); + + public Geometry? Data + { + get => GetValue(DataProperty); + set => SetValue(DataProperty, value); + } + + protected override Geometry? CreateDefiningGeometry() => Data; + + /// + /// Explicit path length. If ≤ 0, computed automatically from geometry. + /// + public static readonly StyledProperty PathLengthProperty = + AvaloniaProperty.Register(nameof(PathLength)); + + public double PathLength + { + get => GetValue(PathLengthProperty); + set => SetValue(PathLengthProperty, value); + } + + /// + /// Duration of one complete animation cycle. + /// + public static readonly StyledProperty DurationProperty = + AvaloniaProperty.Register(nameof(Duration), + defaultValue: TimeSpan.FromSeconds(2)); + + public TimeSpan Duration + { + get => GetValue(DurationProperty); + set => SetValue(DurationProperty, value); + } + + /// + /// Whether the animation is currently playing. + /// + public static readonly StyledProperty IsPlayingProperty = + AvaloniaProperty.Register(nameof(IsPlaying), + defaultValue: true); + + public bool IsPlaying + { + get => GetValue(IsPlayingProperty); + set => SetValue(IsPlayingProperty, value); + } + + /// + /// How many times to repeat. 0 = infinite (forever). Default = 0. + /// + public static readonly StyledProperty RepeatCountProperty = + AvaloniaProperty.Register(nameof(RepeatCount), defaultValue: 0, + validate: v => v >= 0); + + public int RepeatCount + { + get => GetValue(RepeatCountProperty); + set => SetValue(RepeatCountProperty, value); + } + + /// + /// Controls the end value of the animation. + /// animates from +pathLength to -pathLength (stroke disappears); + /// animates from +pathLength to 0 (stroke stays drawn). + /// + public static readonly StyledProperty FillModeProperty = + AvaloniaProperty.Register(nameof(FillMode), + defaultValue: FillMode.None); + + public FillMode FillMode + { + get => GetValue(FillModeProperty); + set => SetValue(FillModeProperty, value); + } + + // ── Events ── + + public event EventHandler? Completed; + + // ── Static constructor ── + + static AnimationPath() + { + AffectsRender( + DataProperty, PathLengthProperty, DurationProperty, + IsPlayingProperty, RepeatCountProperty, FillModeProperty); + + DataProperty.Changed.AddClassHandler((o, e) => o.OnPropertyChanged()); + PathLengthProperty.Changed.AddClassHandler((o, e) => o.OnPropertyChanged()); + DurationProperty.Changed.AddClassHandler((o, e) => o.OnPropertyChanged()); + IsPlayingProperty.Changed.AddClassHandler((o, e) => o.OnIsPlayingChanged(e)); + RepeatCountProperty.Changed.AddClassHandler((o, e) => o.OnPropertyChanged()); + FillModeProperty.Changed.AddClassHandler((o, e) => o.OnPropertyChanged()); + } + + // ── Lifecycle ── + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + // Bounds may not be ready at attach time; retry after layout + LayoutUpdated += OnLayoutUpdated; + UpdatePath(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + LayoutUpdated -= OnLayoutUpdated; + _timer.Stop(); + } + + private void OnLayoutUpdated(object? sender, EventArgs e) + { + if (!MathHelper.IsVerySmall(Bounds.Width) && !MathHelper.IsVerySmall(Bounds.Height)) + { + LayoutUpdated -= OnLayoutUpdated; + UpdatePath(); + } + } + + // ── Property handlers ── + + private void OnPropertyChanged() => UpdatePath(); + + private void OnIsPlayingChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.GetNewValue()) + { + UpdatePath(); + } + else + { + StopAnimation(); + } + } + + // ── Animation core ── + + private void UpdatePath() + { + _timer.Stop(); + if (!IsPlaying || Data == null) return; + + // Compute path length + _pathLength = PathLength > 0 ? PathLength : GetScaledTotalLength(); + if (MathHelper.IsVerySmall(_pathLength)) return; + + // Dash array: two dashes each exactly the full path length + StrokeDashArray = new AvaloniaList { _pathLength, _pathLength }; + StrokeDashOffset = _pathLength; + + // Reset iteration state + _elapsed = TimeSpan.Zero; + _remainingRepeats = RepeatCount; + + if (!MathHelper.IsVerySmall(Duration.TotalMilliseconds)) + { + _timer.Start(); + } + } + + private void StopAnimation() + { + _timer.Stop(); + _elapsed = TimeSpan.Zero; + } + + private void OnTimerTick(object? sender, EventArgs e) + { + _elapsed += _timer.Interval; + + var duration = Duration; + if (duration.TotalMilliseconds <= 0) return; + + var progress = Math.Min(_elapsed.TotalMilliseconds / duration.TotalMilliseconds, 1.0); + + // Interpolate StrokeDashOffset linearly + double startValue = _pathLength; + double endValue = FillMode == FillMode.None ? -_pathLength : 0; + StrokeDashOffset = startValue + (endValue - startValue) * progress; + + if (progress >= 1.0) + { + // Cycle finished + Completed?.Invoke(this, EventArgs.Empty); + + bool shouldRepeat; + if (_remainingRepeats == 0) + { + shouldRepeat = true; // Infinite + } + else + { + _remainingRepeats--; + shouldRepeat = _remainingRepeats > 0; + } + + if (shouldRepeat) + { + _elapsed = TimeSpan.Zero; + StrokeDashOffset = startValue; // Reset for next cycle + } + else + { + _timer.Stop(); + } + } + } + + // ── Path length calculation ── + + private double GetScaledTotalLength() + { + var geometry = Data; + if (geometry == null) return 0; + + var renderSize = Bounds.Size; + if (MathHelper.IsVerySmall(renderSize.Width) || MathHelper.IsVerySmall(renderSize.Height)) + return 0; + + var length = GetGeometryLength(geometry); + if (MathHelper.IsVerySmall(length)) return 0; + + // Scale from native geometry space to render space (Stretch.Uniform) + var sw = geometry.Bounds.Width / renderSize.Width; + var sh = geometry.Bounds.Height / renderSize.Height; + var min = Math.Min(sw, sh); + + if (MathHelper.IsVerySmall(min) || MathHelper.IsVerySmall(StrokeThickness)) + return 0; + + length /= min; + return length / StrokeThickness; + } + + private static double GetGeometryLength(Geometry geometry) + { + if (geometry is PathGeometry { Figures: { } figures }) + { + double totalLength = 0; + foreach (var figure in figures) + { + totalLength += GetFigureLength(figure); + } + return totalLength; + } + + // Fallback: estimate from bounding box diagonal + var bounds = geometry.Bounds; + return Math.Sqrt(bounds.Width * bounds.Width + bounds.Height * bounds.Height) * 2; + } + + private static double PointLen(Point p1, Point p2) + { + var dx = p1.X - p2.X; + var dy = p1.Y - p2.Y; + return Math.Sqrt(dx * dx + dy * dy); + } + + private static double GetFigureLength(PathFigure figure) + { + double length = 0; + var current = figure.StartPoint; + + if (figure.Segments is null) return length; + + foreach (var segment in figure.Segments) + { + switch (segment) + { + case ArcSegment arc: + length += PointLen(arc.Point, current); + current = arc.Point; + break; + + case BezierSegment bezier: + length += ApproximateCubicBezierLength(current, bezier.Point1, bezier.Point2, bezier.Point3); + current = bezier.Point3; + break; + + case LineSegment line: + length += PointLen(line.Point, current); + current = line.Point; + break; + + case PolyLineSegment { Points: { } polyLinePoints }: + foreach (var pt in polyLinePoints) + { + length += PointLen(pt, current); + current = pt; + } + break; + + case PolyBezierSegment { Points: { } polyBezierPoints }: + for (var i = 0; i + 2 < polyBezierPoints.Count; i += 3) + { + length += ApproximateCubicBezierLength( + current, + polyBezierPoints[i], + polyBezierPoints[i + 1], + polyBezierPoints[i + 2]); + current = polyBezierPoints[i + 2]; + } + break; + + case QuadraticBezierSegment quad: + length += ApproximateQuadraticBezierLength(current, quad.Point1, quad.Point2); + current = quad.Point2; + break; + } + } + + if (figure.IsClosed) + { + length += PointLen(figure.StartPoint, current); + } + + return length; + } + + /// + /// Approximate cubic bezier length by subdividing into line segments. + /// + private static double ApproximateCubicBezierLength(Point p0, Point p1, Point p2, Point p3, int steps = 8) + { + double length = 0; + var prev = p0; + + for (var i = 1; i <= steps; i++) + { + var t = i / (double)steps; + var u = 1.0 - t; + var uu = u * u; + var uuu = uu * u; + var tt = t * t; + var ttt = tt * t; + + var pt = new Point( + uuu * p0.X + 3 * uu * t * p1.X + 3 * u * tt * p2.X + ttt * p3.X, + uuu * p0.Y + 3 * uu * t * p1.Y + 3 * u * tt * p2.Y + ttt * p3.Y + ); + + length += PointLen(pt, prev); + prev = pt; + } + + return length; + } + + /// + /// Approximate quadratic bezier length by subdividing into line segments. + /// + private static double ApproximateQuadraticBezierLength(Point p0, Point p1, Point p2, int steps = 8) + { + double length = 0; + var prev = p0; + + for (var i = 1; i <= steps; i++) + { + var t = i / (double)steps; + var u = 1.0 - t; + + var pt = new Point( + u * u * p0.X + 2 * u * t * p1.X + t * t * p2.X, + u * u * p0.Y + 2 * u * t * p1.Y + t * t * p2.Y + ); + + length += PointLen(pt, prev); + prev = pt; + } + + return length; + } +}