diff --git a/samples/RenderDemo/MainWindow.xaml b/samples/RenderDemo/MainWindow.xaml
index e6e62f86ed..823a0fbbef 100644
--- a/samples/RenderDemo/MainWindow.xaml
+++ b/samples/RenderDemo/MainWindow.xaml
@@ -39,6 +39,9 @@
+
+
+
diff --git a/samples/RenderDemo/Pages/SpringAnimationsPage.xaml b/samples/RenderDemo/Pages/SpringAnimationsPage.xaml
new file mode 100644
index 0000000000..26d572bb3f
--- /dev/null
+++ b/samples/RenderDemo/Pages/SpringAnimationsPage.xaml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/RenderDemo/Pages/SpringAnimationsPage.xaml.cs b/samples/RenderDemo/Pages/SpringAnimationsPage.xaml.cs
new file mode 100644
index 0000000000..78d3df2233
--- /dev/null
+++ b/samples/RenderDemo/Pages/SpringAnimationsPage.xaml.cs
@@ -0,0 +1,17 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace RenderDemo.Pages;
+
+public class SpringAnimationsPage : UserControl
+{
+ public SpringAnimationsPage()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
diff --git a/src/Avalonia.Base/Animation/Easings/SpringEasing.cs b/src/Avalonia.Base/Animation/Easings/SpringEasing.cs
new file mode 100644
index 0000000000..70e74c639f
--- /dev/null
+++ b/src/Avalonia.Base/Animation/Easings/SpringEasing.cs
@@ -0,0 +1,80 @@
+namespace Avalonia.Animation.Easings;
+
+///
+/// Eases a value using a user-defined spring formula.
+///
+public class SpringEasing : Easing
+{
+ private readonly Spring _internalSpring;
+
+ ///
+ /// The spring mass.
+ ///
+ public double Mass
+ {
+ get => _internalSpring.Mass;
+ set
+ {
+ _internalSpring.Mass = value;
+ }
+ }
+
+ ///
+ /// The spring stiffness.
+ ///
+ public double Stiffness
+ {
+ get => _internalSpring.Stiffness;
+ set
+ {
+ _internalSpring.Stiffness = value;
+ }
+ }
+
+ ///
+ /// The spring damping.
+ ///
+ public double Damping
+ {
+ get => _internalSpring.Damping;
+ set
+ {
+ _internalSpring.Damping = value;
+ }
+ }
+
+ ///
+ /// The spring initial velocity.
+ ///
+ public double InitialVelocity
+ {
+ get => _internalSpring.InitialVelocity;
+ set
+ {
+ _internalSpring.InitialVelocity = value;
+ }
+ }
+
+ public SpringEasing(double mass = 0d, double stiffness = 0d, double damping = 0d, double initialVelocity = 0d)
+ {
+ _internalSpring = new Spring();
+
+ Mass = mass;
+ Stiffness = stiffness;
+ Damping = damping;
+ InitialVelocity = initialVelocity;
+ }
+
+ public SpringEasing(Spring keySpline)
+ {
+ _internalSpring = keySpline;
+ }
+
+ public SpringEasing()
+ {
+ _internalSpring = new Spring();
+ }
+
+ ///
+ public override double Ease(double progress) => _internalSpring.GetSpringProgress(progress);
+}
diff --git a/src/Avalonia.Base/Animation/Spring.cs b/src/Avalonia.Base/Animation/Spring.cs
new file mode 100644
index 0000000000..03ada9196d
--- /dev/null
+++ b/src/Avalonia.Base/Animation/Spring.cs
@@ -0,0 +1,143 @@
+using System;
+using System.ComponentModel;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Utilities;
+
+namespace Avalonia.Animation;
+
+///
+/// Determines how an animation is used based on spring formula.
+///
+[TypeConverter(typeof(SpringTypeConverter))]
+public class Spring
+{
+ private SpringSolver _springSolver;
+ private double _mass;
+ private double _stiffness;
+ private double _damping;
+ private double _initialVelocity;
+ private bool _isDirty;
+
+ ///
+ /// Create a .
+ ///
+ public Spring()
+ {
+ _mass = 0.0;
+ _stiffness = 0.0;
+ _damping = 0.0;
+ _initialVelocity = 0.0;
+ _isDirty = true;
+ }
+
+ ///
+ /// Create a with the given parameters.
+ ///
+ /// The spring mass.
+ /// The spring stiffness.
+ /// The spring damping.
+ /// The spring initial velocity.
+ public Spring(double mass, double stiffness, double damping, double initialVelocity)
+ {
+ _mass = mass;
+ _stiffness = stiffness;
+ _damping = damping;
+ _initialVelocity = initialVelocity;
+ _isDirty = true;
+ }
+
+ ///
+ /// Parse a from a string. The string needs to contain 4 values in it.
+ ///
+ /// string with 4 values in it
+ /// culture of the string
+ /// Thrown if the string does not have 4 values
+ /// A with the appropriate values set
+ public static Spring Parse(string value, CultureInfo? culture)
+ {
+ if (culture is null)
+ {
+ culture = CultureInfo.InvariantCulture;
+ }
+
+ using var tokenizer = new StringTokenizer(value, culture, exceptionMessage: $"Invalid Spring string: \"{value}\".");
+ return new Spring(tokenizer.ReadDouble(), tokenizer.ReadDouble(), tokenizer.ReadDouble(), tokenizer.ReadDouble());
+ }
+
+ ///
+ /// The spring mass.
+ ///
+ public double Mass
+ {
+ get => _mass;
+ set
+ {
+ _mass = value;
+ _isDirty = true;
+ }
+ }
+
+ ///
+ /// The spring stiffness.
+ ///
+ public double Stiffness
+ {
+ get => _stiffness;
+ set
+ {
+ _stiffness = value;
+ _isDirty = true;
+ }
+ }
+
+ ///
+ /// The spring damping.
+ ///
+ public double Damping
+ {
+ get => _damping;
+ set
+ {
+ _damping = value;
+ _isDirty = true;
+ }
+ }
+
+ ///
+ /// The spring initial velocity.
+ ///
+ public double InitialVelocity
+ {
+ get => _initialVelocity;
+ set
+ {
+ _initialVelocity = value;
+ _isDirty = true;
+ }
+ }
+
+ ///
+ /// Calculates spring progress from a linear progress.
+ ///
+ /// the linear progress
+ /// The spring progress
+ public double GetSpringProgress(double linearProgress)
+ {
+ if (_isDirty)
+ {
+ Build();
+ }
+
+ return _springSolver.Solve(linearProgress);
+ }
+
+ ///
+ /// Create cached spring solver.
+ ///
+ private void Build()
+ {
+ _springSolver = new SpringSolver(_mass, _stiffness, _damping, _initialVelocity);
+ _isDirty = false;
+ }
+}
diff --git a/src/Avalonia.Base/Animation/SpringTypeConverter.cs b/src/Avalonia.Base/Animation/SpringTypeConverter.cs
new file mode 100644
index 0000000000..cfcf4c4a14
--- /dev/null
+++ b/src/Avalonia.Base/Animation/SpringTypeConverter.cs
@@ -0,0 +1,21 @@
+using System;
+using System.ComponentModel;
+using System.Globalization;
+
+namespace Avalonia.Animation;
+
+///
+/// Converts string values to values.
+///
+public class SpringTypeConverter : TypeConverter
+{
+ public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
+ {
+ return sourceType == typeof(string);
+ }
+
+ public override object ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
+ {
+ return Spring.Parse((string)value, CultureInfo.InvariantCulture);
+ }
+}
diff --git a/src/Avalonia.Base/Utilities/SpringSolver.cs b/src/Avalonia.Base/Utilities/SpringSolver.cs
new file mode 100644
index 0000000000..ead73e9b1b
--- /dev/null
+++ b/src/Avalonia.Base/Utilities/SpringSolver.cs
@@ -0,0 +1,121 @@
+// Ported from:
+// https://svn.webkit.org/repository/webkit/trunk/Source/WebCore/platform/graphics/SpringSolver.h
+/*
+ * Copyright (C) 2016 Apple Inc. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+ * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
+ * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
+ * THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+using System;
+
+namespace Avalonia.Utilities;
+
+internal struct SpringSolver
+{
+ private double m_w0;
+ private double m_zeta;
+ private double m_wd;
+ private double m_A;
+ private double m_B;
+
+ ///
+ ///
+ ///
+ /// The time period.
+ /// The damping ratio.
+ ///
+ public SpringSolver(TimeSpan period, double zeta, double initialVelocity)
+ : this(
+ 2 * Math.PI / period.TotalSeconds,
+ zeta,
+ initialVelocity)
+ {
+ // T is time period [s]
+ // T = (2*PI / sqrt(k)) * sqrt(m)
+
+ // ωn is natural frequency of the system [Hz] [1/s]
+ // ωn = 2*PI / T
+ }
+
+ ///
+ ///
+ ///
+ /// The mass of the oscillating body.
+ /// The stiffness of the oscillated body (spring constant).
+ /// The actual damping.
+ /// The initial velocity.
+ public SpringSolver(double m, double k, double c, double initialVelocity)
+ : this(
+ Math.Sqrt(k / m), // ωn
+ c / (2 * Math.Sqrt(k * m)), // c / Cc
+ initialVelocity)
+ {
+ // ωn is natural frequency of the system [Hz] [1/s]
+ // ωn = sqrt(k / m)
+
+ // Cc is critical damping coefficient
+ // Cc = 2 * Sqrt(k * m)
+ // Cc = 2 * m * wn
+ // Cc = 2 * m * Sqrt(k / m)
+
+ // ζ is damping ratio (Greek letter zeta)
+ // ζ = m_zeta = c / Cc
+ }
+
+ ///
+ ///
+ ///
+ /// The the natural frequency of the system [rad/s].
+ /// The damping ratio.
+ ///
+ public SpringSolver(double ωn, double zeta, double initialVelocity)
+ {
+ m_w0 = ωn;
+ m_zeta = zeta;
+
+ if (m_zeta < 1) {
+ // Under-damped.
+ m_wd = m_w0 * Math.Sqrt(1 - m_zeta * m_zeta);
+ m_A = 1;
+ m_B = (m_zeta * m_w0 + -initialVelocity) / m_wd;
+ } else {
+ // Critically damped (ignoring over-damped case for now).
+ m_A = 1;
+ m_B = -initialVelocity + m_w0;
+ m_wd = 0;
+ }
+ }
+
+ public readonly double Solve(double t)
+ {
+ if (m_zeta < 1) {
+ // Under-damped
+ t = Math.Exp(-t * m_zeta * m_w0) * (m_A * Math.Cos(m_wd * t) + m_B * Math.Sin(m_wd * t));
+ } else {
+ // Critically damped (ignoring over-damped case for now).
+ t = (m_A + m_B * t) * Math.Exp(-t * m_w0);
+ }
+
+ // Map range from [1..0] to [0..1].
+ return 1 - t;
+ }
+}
diff --git a/src/Windows/Avalonia.Win32/WinRT/Composition/WinUICompositorConnection.cs b/src/Windows/Avalonia.Win32/WinRT/Composition/WinUICompositorConnection.cs
index 84c3cc5c51..9a6bd9572a 100644
--- a/src/Windows/Avalonia.Win32/WinRT/Composition/WinUICompositorConnection.cs
+++ b/src/Windows/Avalonia.Win32/WinRT/Composition/WinUICompositorConnection.cs
@@ -120,10 +120,14 @@ namespace Avalonia.Win32.WinRT.Composition
private void RunLoop()
{
+ var cts = new CancellationTokenSource();
+ AppDomain.CurrentDomain.ProcessExit += (sender, args) =>
+ cts.Cancel();
+
using (var act = _compositor5.RequestCommitAsync())
act.SetCompleted(new RunLoopHandler(this));
- while (true)
+ while (!cts.IsCancellationRequested)
{
UnmanagedMethods.GetMessage(out var msg, IntPtr.Zero, 0, 0);
lock (_pumpLock)
diff --git a/tests/Avalonia.Base.UnitTests/Animation/SpringTests.cs b/tests/Avalonia.Base.UnitTests/Animation/SpringTests.cs
new file mode 100644
index 0000000000..47c0e48033
--- /dev/null
+++ b/tests/Avalonia.Base.UnitTests/Animation/SpringTests.cs
@@ -0,0 +1,118 @@
+using System;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Controls.Shapes;
+using Avalonia.Media;
+using Avalonia.Styling;
+using Xunit;
+
+namespace Avalonia.Base.UnitTests.Animation;
+
+public class SpringTests
+{
+ [Theory]
+ [InlineData("1,2 3,4")]
+ public void Can_Parse_Spring_Via_TypeConverter(string input)
+ {
+ var conv = new SpringTypeConverter();
+
+ var spring = (Spring)conv.ConvertFrom(input);
+
+ Assert.Equal(1, spring.Mass);
+ Assert.Equal(2, spring.Stiffness);
+ Assert.Equal(3, spring.Damping);
+ Assert.Equal(4, spring.InitialVelocity);
+ }
+
+ [Theory]
+ [InlineData("1,2F,3,4")]
+ [InlineData("Foo,Bar,Fee,Buzz")]
+ public void Can_Handle_Invalid_String_Via_TypeConverter(string input)
+ {
+ var conv = new SpringTypeConverter();
+
+ Assert.ThrowsAny(() => (Spring)conv.ConvertFrom(input));
+ }
+
+ [Fact]
+ public void SplineEasing_Can_Be_Mutated()
+ {
+ var easing = new SpringEasing(1, 1, 1, 0);
+
+ Assert.Equal(0, easing.Ease(0));
+ Assert.Equal(0.34029984660829826, easing.Ease(1));
+
+ easing.Mass = 2;
+ easing.Stiffness = 2;
+ easing.Damping = 2;
+ easing.InitialVelocity = 1;
+
+ Assert.NotEqual(0.05136985716812037, easing.Ease(0.5));
+ }
+
+ [Fact]
+ public void Check_SpringEasing_Handled_properly()
+ {
+ var keyframe1 = new KeyFrame()
+ {
+ Setters =
+ {
+ new Setter(RotateTransform.AngleProperty, -2.5d),
+ },
+ KeyTime = TimeSpan.FromSeconds(0)
+ };
+
+ var keyframe2 = new KeyFrame()
+ {
+ Setters =
+ {
+ new Setter(RotateTransform.AngleProperty, 2.5d),
+ },
+ KeyTime = TimeSpan.FromSeconds(5)
+ };
+
+ var animation = new Avalonia.Animation.Animation()
+ {
+ Duration = TimeSpan.FromSeconds(5),
+ Children =
+ {
+ keyframe1,
+ keyframe2
+ },
+ IterationCount = new IterationCount(5),
+ PlaybackDirection = PlaybackDirection.Alternate,
+ Easing = new SpringEasing(1, 10, 1, 0)
+ };
+
+ var rotateTransform = new RotateTransform(-2.5);
+ var rect = new Rectangle()
+ {
+ RenderTransform = rotateTransform
+ };
+
+ var clock = new TestClock();
+ var animationRun = animation.RunAsync(rect, clock);
+
+ clock.Step(TimeSpan.Zero);
+ Assert.Equal(rotateTransform.Angle, -2.5);
+ clock.Step(TimeSpan.FromSeconds(5));
+ Assert.Equal(rotateTransform.Angle, 5.522828945000075);
+
+ var tolerance = 0.01;
+ clock.Step(TimeSpan.Parse("00:00:10.0153932"));
+ var expected = -2.499763294237805;
+ Assert.True(Math.Abs(rotateTransform.Angle - expected) <= tolerance);
+
+ clock.Step(TimeSpan.Parse("00:00:11.2655407"));
+ expected = -1.1011448950348934;
+ Assert.True(Math.Abs(rotateTransform.Angle - expected) <= tolerance);
+
+ clock.Step(TimeSpan.Parse("00:00:12.6158773"));
+ expected = 2.1264981706749007;
+ Assert.True(Math.Abs(rotateTransform.Angle - expected) <= tolerance);
+
+ clock.Step(TimeSpan.Parse("00:00:14.6495256"));
+ expected = 5.4337608446234782;
+ Assert.True(Math.Abs(rotateTransform.Angle - expected) <= tolerance);
+ }
+}