9 changed files with 543 additions and 1 deletions
@ -0,0 +1,35 @@ |
|||
<UserControl |
|||
xmlns="https://github.com/avaloniaui" |
|||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
|||
x:Class="RenderDemo.Pages.SpringAnimationsPage" |
|||
MaxWidth="600"> |
|||
<UserControl.Resources> |
|||
<SpringEasing x:Key="SpringEasing" Mass="1" Stiffness="2000" Damping="20" InitialVelocity="0" /> |
|||
</UserControl.Resources> |
|||
<UserControl.Styles> |
|||
<Style Selector="Border.spring"> |
|||
<Style.Animations> |
|||
<Animation Duration="0:0:0.900" |
|||
IterationCount="Infinite" |
|||
PlaybackDirection="Normal" |
|||
Easing="{DynamicResource SpringEasing}"> |
|||
<KeyFrame Cue="0%" KeySpline=""> |
|||
<Setter Property="TranslateTransform.X" Value="-300"/> |
|||
</KeyFrame> |
|||
<KeyFrame Cue="100%"> |
|||
<Setter Property="TranslateTransform.X" Value="0" /> |
|||
</KeyFrame> |
|||
</Animation> |
|||
</Style.Animations> |
|||
</Style> |
|||
</UserControl.Styles> |
|||
<Grid> |
|||
<Border Classes="spring" Background="Red" Width="50" Height="50"> |
|||
<Border.RenderTransform> |
|||
<TransformGroup> |
|||
<TranslateTransform /> |
|||
</TransformGroup> |
|||
</Border.RenderTransform> |
|||
</Border> |
|||
</Grid> |
|||
</UserControl> |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -0,0 +1,80 @@ |
|||
namespace Avalonia.Animation.Easings; |
|||
|
|||
/// <summary>
|
|||
/// Eases a <see cref="double"/> value using a user-defined spring formula.
|
|||
/// </summary>
|
|||
public class SpringEasing : Easing |
|||
{ |
|||
private readonly Spring _internalSpring; |
|||
|
|||
/// <summary>
|
|||
/// The spring mass.
|
|||
/// </summary>
|
|||
public double Mass |
|||
{ |
|||
get => _internalSpring.Mass; |
|||
set |
|||
{ |
|||
_internalSpring.Mass = value; |
|||
} |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// The spring stiffness.
|
|||
/// </summary>
|
|||
public double Stiffness |
|||
{ |
|||
get => _internalSpring.Stiffness; |
|||
set |
|||
{ |
|||
_internalSpring.Stiffness = value; |
|||
} |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// The spring damping.
|
|||
/// </summary>
|
|||
public double Damping |
|||
{ |
|||
get => _internalSpring.Damping; |
|||
set |
|||
{ |
|||
_internalSpring.Damping = value; |
|||
} |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// The spring initial velocity.
|
|||
/// </summary>
|
|||
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(); |
|||
} |
|||
|
|||
/// <inheritdoc/>
|
|||
public override double Ease(double progress) => _internalSpring.GetSpringProgress(progress); |
|||
} |
|||
@ -0,0 +1,143 @@ |
|||
using System; |
|||
using System.ComponentModel; |
|||
using System.Globalization; |
|||
using Avalonia; |
|||
using Avalonia.Utilities; |
|||
|
|||
namespace Avalonia.Animation; |
|||
|
|||
/// <summary>
|
|||
/// Determines how an animation is used based on spring formula.
|
|||
/// </summary>
|
|||
[TypeConverter(typeof(SpringTypeConverter))] |
|||
public class Spring |
|||
{ |
|||
private SpringSolver _springSolver; |
|||
private double _mass; |
|||
private double _stiffness; |
|||
private double _damping; |
|||
private double _initialVelocity; |
|||
private bool _isDirty; |
|||
|
|||
/// <summary>
|
|||
/// Create a <see cref="Spring"/>.
|
|||
/// </summary>
|
|||
public Spring() |
|||
{ |
|||
_mass = 0.0; |
|||
_stiffness = 0.0; |
|||
_damping = 0.0; |
|||
_initialVelocity = 0.0; |
|||
_isDirty = true; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Create a <see cref="Spring"/> with the given parameters.
|
|||
/// </summary>
|
|||
/// <param name="mass">The spring mass.</param>
|
|||
/// <param name="stiffness">The spring stiffness.</param>
|
|||
/// <param name="damping">The spring damping.</param>
|
|||
/// <param name="initialVelocity">The spring initial velocity.</param>
|
|||
public Spring(double mass, double stiffness, double damping, double initialVelocity) |
|||
{ |
|||
_mass = mass; |
|||
_stiffness = stiffness; |
|||
_damping = damping; |
|||
_initialVelocity = initialVelocity; |
|||
_isDirty = true; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Parse a <see cref="Spring"/> from a string. The string needs to contain 4 values in it.
|
|||
/// </summary>
|
|||
/// <param name="value">string with 4 values in it</param>
|
|||
/// <param name="culture">culture of the string</param>
|
|||
/// <exception cref="FormatException">Thrown if the string does not have 4 values</exception>
|
|||
/// <returns>A <see cref="Spring"/> with the appropriate values set</returns>
|
|||
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()); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// The spring mass.
|
|||
/// </summary>
|
|||
public double Mass |
|||
{ |
|||
get => _mass; |
|||
set |
|||
{ |
|||
_mass = value; |
|||
_isDirty = true; |
|||
} |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// The spring stiffness.
|
|||
/// </summary>
|
|||
public double Stiffness |
|||
{ |
|||
get => _stiffness; |
|||
set |
|||
{ |
|||
_stiffness = value; |
|||
_isDirty = true; |
|||
} |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// The spring damping.
|
|||
/// </summary>
|
|||
public double Damping |
|||
{ |
|||
get => _damping; |
|||
set |
|||
{ |
|||
_damping = value; |
|||
_isDirty = true; |
|||
} |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// The spring initial velocity.
|
|||
/// </summary>
|
|||
public double InitialVelocity |
|||
{ |
|||
get => _initialVelocity; |
|||
set |
|||
{ |
|||
_initialVelocity = value; |
|||
_isDirty = true; |
|||
} |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Calculates spring progress from a linear progress.
|
|||
/// </summary>
|
|||
/// <param name="linearProgress">the linear progress</param>
|
|||
/// <returns>The spring progress</returns>
|
|||
public double GetSpringProgress(double linearProgress) |
|||
{ |
|||
if (_isDirty) |
|||
{ |
|||
Build(); |
|||
} |
|||
|
|||
return _springSolver.Solve(linearProgress); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Create cached spring solver.
|
|||
/// </summary>
|
|||
private void Build() |
|||
{ |
|||
_springSolver = new SpringSolver(_mass, _stiffness, _damping, _initialVelocity); |
|||
_isDirty = false; |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
using System; |
|||
using System.ComponentModel; |
|||
using System.Globalization; |
|||
|
|||
namespace Avalonia.Animation; |
|||
|
|||
/// <summary>
|
|||
/// Converts string values to <see cref="Spring"/> values.
|
|||
/// </summary>
|
|||
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); |
|||
} |
|||
} |
|||
@ -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; |
|||
|
|||
/// <summary>
|
|||
///
|
|||
/// </summary>
|
|||
/// <param name="period">The time period.</param>
|
|||
/// <param name="zeta">The damping ratio.</param>
|
|||
/// <param name="initialVelocity"></param>
|
|||
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
|
|||
} |
|||
|
|||
/// <summary>
|
|||
///
|
|||
/// </summary>
|
|||
/// <param name="m">The mass of the oscillating body.</param>
|
|||
/// <param name="k">The stiffness of the oscillated body (spring constant).</param>
|
|||
/// <param name="c">The actual damping.</param>
|
|||
/// <param name="initialVelocity">The initial velocity.</param>
|
|||
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
|
|||
} |
|||
|
|||
/// <summary>
|
|||
///
|
|||
/// </summary>
|
|||
/// <param name="ωn">The the natural frequency of the system [rad/s].</param>
|
|||
/// <param name="zeta">The damping ratio.</param>
|
|||
/// <param name="initialVelocity"></param>
|
|||
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; |
|||
} |
|||
} |
|||
@ -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<Exception>(() => (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); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue