From 7e6f0b82659f529925a8a5df697a57babee048d4 Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Wed, 29 Apr 2020 10:57:16 -0400 Subject: [PATCH 1/8] Add KeySpline class from WPF source --- src/Avalonia.Animation/KeySpline.cs | 271 ++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 src/Avalonia.Animation/KeySpline.cs diff --git a/src/Avalonia.Animation/KeySpline.cs b/src/Avalonia.Animation/KeySpline.cs new file mode 100644 index 0000000000..34e8d89aae --- /dev/null +++ b/src/Avalonia.Animation/KeySpline.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.Text; +using Avalonia; +using Avalonia.Utilities; + +// From: https://github.com/dotnet/wpf/blob/ae1790531c3b993b56eba8b1f0dd395a3ed7de75/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/Animation/KeySpline.cs + +namespace Avalonia.Animation +{ + [TypeConverter(typeof(KeySplineTypeConverter))] + public class KeySpline : AvaloniaObject + { + // Control points + private double _controlPointX1; + private double _controlPointY1; + private double _controlPointX2; + private double _controlPointY2; + private bool _isSpecified; + private bool _isDirty; + + // The parameter that corresponds to the most recent time + private double _parameter; + + // Cached coefficients + private double _Bx; // 3*points[0].X + private double _Cx; // 3*points[1].X + private double _Cx_Bx; // 2*(Cx - Bx) + private double _three_Cx; // 3 - Cx + + private double _By; // 3*points[0].Y + private double _Cy; // 3*points[1].Y + + // constants + private const double _accuracy = .001; // 1/3 the desired accuracy in X + private const double _fuzz = .000001; // computational zero + + public KeySpline() + { + _controlPointX1 = 0.0; + _controlPointY1 = 0.0; + _controlPointX2 = 1.0; + _controlPointY2 = 1.0; + _isDirty = true; + } + + public KeySpline(double x1, double y1, double x2, double y2) + { + _controlPointX1 = x1; + _controlPointY1 = y1; + _controlPointX2 = x2; + _controlPointY2 = y2; + _isDirty = true; + } + + public static KeySpline Parse(string value, CultureInfo culture) + { + using (var tokenizer = new StringTokenizer((string)value, CultureInfo.InvariantCulture, exceptionMessage: "Invalid KeySpline.")) + { + return new KeySpline(tokenizer.ReadDouble(), tokenizer.ReadDouble(), tokenizer.ReadDouble(), tokenizer.ReadDouble()); + } + } + + public double ControlPointX1 + { + get => _controlPointX1; + set => _controlPointX1 = value; + } + + public double ControlPointY1 + { + get => _controlPointY1; + set => _controlPointY1 = value; + } + + public double ControlPointX2 + { + get => _controlPointX2; + set => _controlPointX2 = value; + } + + public double ControlPointY2 + { + get => _controlPointY2; + set => _controlPointY2 = value; + } + + /// + /// Calculates spline progress from a linear progress. + /// + /// the linear progress + /// the spline progress + public double GetSplineProgress(double linearProgress) + { + //ReadPreamble(); + + if (_isDirty) + { + Build(); + } + + if (!_isSpecified) + { + return linearProgress; + } + else + { + SetParameterFromX(linearProgress); + + return GetBezierValue(_By, _Cy, _parameter); + } + } + + /// + /// Compute cached coefficients. + /// + private void Build() + { + if (_controlPointX1 == 0 && _controlPointY1 == 0 && _controlPointX2 == 1 && _controlPointY2 == 1) + { + // This KeySpline would have no effect on the progress. + _isSpecified = false; + } + else + { + _isSpecified = true; + + _parameter = 0; + + // X coefficients + _Bx = 3 * _controlPointX1; + _Cx = 3 * _controlPointX2; + _Cx_Bx = 2 * (_Cx - _Bx); + _three_Cx = 3 - _Cx; + + // Y coefficients + _By = 3 * _controlPointY1; + _Cy = 3 * _controlPointY2; + } + + _isDirty = false; + } + + /// + /// Get an X or Y value with the Bezier formula. + /// + /// the second Bezier coefficient + /// the third Bezier coefficient + /// the parameter value to evaluate at + /// the value of the Bezier function at the given parameter + static private double GetBezierValue(double b, double c, double t) + { + double s = 1.0 - t; + double t2 = t * t; + + return b * t * s * s + c * t2 * s + t2 * t; + } + + /// + /// Get X and dX/dt at a given parameter + /// + /// the parameter value to evaluate at + /// the value of x there + /// the value of dx/dt there + private void GetXAndDx(double t, out double x, out double dx) + { + double s = 1.0 - t; + double t2 = t * t; + double s2 = s * s; + + x = _Bx * t * s2 + _Cx * t2 * s + t2 * t; + dx = _Bx * s2 + _Cx_Bx * s * t + _three_Cx * t2; + } + + /// + /// Compute the parameter value that corresponds to a given X value, using a modified + /// clamped Newton-Raphson algorithm to solve the equation X(t) - time = 0. We make + /// use of some known properties of this particular function: + /// * We are only interested in solutions in the interval [0,1] + /// * X(t) is increasing, so we can assume that if X(t) > time t > solution. We use + /// that to clamp down the search interval with every probe. + /// * The derivative of X and Y are between 0 and 3. + /// + /// the time, scaled to fit in [0,1] + private void SetParameterFromX(double time) + { + // Dynamic search interval to clamp with + double bottom = 0; + double top = 1; + + if (time == 0) + { + _parameter = 0; + } + else if (time == 1) + { + _parameter = 1; + } + else + { + // Loop while improving the guess + while (top - bottom > _fuzz) + { + double x, dx, absdx; + + // Get x and dx/dt at the current parameter + GetXAndDx(_parameter, out x, out dx); + absdx = Math.Abs(dx); + + // Clamp down the search interval, relying on the monotonicity of X(t) + if (x > time) + { + top = _parameter; // because parameter > solution + } + else + { + bottom = _parameter; // because parameter < solution + } + + // The desired accuracy is in ultimately in y, not in x, so the + // accuracy needs to be multiplied by dx/dy = (dx/dt) / (dy/dt). + // But dy/dt <=3, so we omit that + if (Math.Abs(x - time) < _accuracy * absdx) + { + break; // We're there + } + + if (absdx > _fuzz) + { + // Nonzero derivative, use Newton-Raphson to obtain the next guess + double next = _parameter - (x - time) / dx; + + // If next guess is out of the search interval then clamp it in + if (next >= top) + { + _parameter = (_parameter + top) / 2; + } + else if (next <= bottom) + { + _parameter = (_parameter + bottom) / 2; + } + else + { + // Next guess is inside the search interval, accept it + _parameter = next; + } + } + else // Zero derivative, halve the search interval + { + _parameter = (bottom + top) / 2; + } + } + } + } + } + + public class KeySplineTypeConverter : TypeConverter + { + public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) + { + return sourceType == typeof(string); + } + + public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) + { + return KeySpline.Parse((string)value, culture); + } + } +} From 24870351fa79ed925833b4afe11953fa0141ba29 Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Wed, 29 Apr 2020 11:32:41 -0400 Subject: [PATCH 2/8] Use KeySpline in Animation --- src/Avalonia.Animation/Animation.cs | 2 +- src/Avalonia.Animation/AnimatorKeyFrame.cs | 9 +++++ .../Animators/Animator`1.cs | 3 ++ src/Avalonia.Animation/KeyFrame.cs | 20 +++++++++++ src/Avalonia.Animation/KeySpline.cs | 36 ++++++++++++++++--- 5 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/Avalonia.Animation/Animation.cs b/src/Avalonia.Animation/Animation.cs index 1ce9f5db06..ca1d97290e 100644 --- a/src/Avalonia.Animation/Animation.cs +++ b/src/Avalonia.Animation/Animation.cs @@ -254,7 +254,7 @@ namespace Avalonia.Animation cue = new Cue(keyframe.KeyTime.TotalSeconds / Duration.TotalSeconds); } - var newKF = new AnimatorKeyFrame(handler, cue); + var newKF = new AnimatorKeyFrame(handler, cue, keyframe.KeySpline); subscriptions.Add(newKF.BindSetter(setter, control)); diff --git a/src/Avalonia.Animation/AnimatorKeyFrame.cs b/src/Avalonia.Animation/AnimatorKeyFrame.cs index 36d15e518e..f6a0c12be4 100644 --- a/src/Avalonia.Animation/AnimatorKeyFrame.cs +++ b/src/Avalonia.Animation/AnimatorKeyFrame.cs @@ -24,11 +24,20 @@ namespace Avalonia.Animation { AnimatorType = animatorType; Cue = cue; + KeySpline = null; + } + + public AnimatorKeyFrame(Type animatorType, Cue cue, KeySpline keySpline) + { + AnimatorType = animatorType; + Cue = cue; + KeySpline = keySpline; } internal bool isNeutral; public Type AnimatorType { get; } public Cue Cue { get; } + public KeySpline KeySpline { get; } public AvaloniaProperty Property { get; private set; } private object _value; diff --git a/src/Avalonia.Animation/Animators/Animator`1.cs b/src/Avalonia.Animation/Animators/Animator`1.cs index aa5e6aaf14..121ffda564 100644 --- a/src/Avalonia.Animation/Animators/Animator`1.cs +++ b/src/Avalonia.Animation/Animators/Animator`1.cs @@ -89,6 +89,9 @@ namespace Avalonia.Animation.Animators else newValue = (T)lastKeyframe.Value; + if (lastKeyframe.KeySpline != null) // TODO: do we use firstKeyFrame or lastKeyframe?! + progress = lastKeyframe.KeySpline.GetSplineProgress(progress); + return Interpolate(progress, oldValue, newValue); } diff --git a/src/Avalonia.Animation/KeyFrame.cs b/src/Avalonia.Animation/KeyFrame.cs index ec59586584..c2cc1aa051 100644 --- a/src/Avalonia.Animation/KeyFrame.cs +++ b/src/Avalonia.Animation/KeyFrame.cs @@ -19,6 +19,7 @@ namespace Avalonia.Animation { private TimeSpan _ktimeSpan; private Cue _kCue; + private KeySpline _kKeySpline; public KeyFrame() { @@ -74,6 +75,25 @@ namespace Avalonia.Animation } } + /// + /// Gets or sets the KeySpline of this . + /// + /// The key spline. + public KeySpline KeySpline + { + get + { + return _kKeySpline; + } + set + { + _kKeySpline = value; + if (value != null && !value.IsValid()) + { + throw new ArgumentException($"{nameof(KeySpline)} must have X coordinates >= 0.0 and <= 1.0."); + } + } + } } diff --git a/src/Avalonia.Animation/KeySpline.cs b/src/Avalonia.Animation/KeySpline.cs index 34e8d89aae..dc6fb61744 100644 --- a/src/Avalonia.Animation/KeySpline.cs +++ b/src/Avalonia.Animation/KeySpline.cs @@ -66,7 +66,17 @@ namespace Avalonia.Animation public double ControlPointX1 { get => _controlPointX1; - set => _controlPointX1 = value; + set + { + if (IsValidXValue(value)) + { + _controlPointX1 = value; + } + else + { + throw new ArgumentException("Invalid KeySpline X1 value. Must be >= 0.0 and <= 1.0."); + } + } } public double ControlPointY1 @@ -78,7 +88,17 @@ namespace Avalonia.Animation public double ControlPointX2 { get => _controlPointX2; - set => _controlPointX2 = value; + set + { + if (IsValidXValue(value)) + { + _controlPointX2 = value; + } + else + { + throw new ArgumentException("Invalid KeySpline X2 value. Must be >= 0.0 and <= 1.0."); + } + } } public double ControlPointY2 @@ -94,8 +114,6 @@ namespace Avalonia.Animation /// the spline progress public double GetSplineProgress(double linearProgress) { - //ReadPreamble(); - if (_isDirty) { Build(); @@ -113,6 +131,16 @@ namespace Avalonia.Animation } } + public bool IsValid() + { + return IsValidXValue(_controlPointX1) && IsValidXValue(_controlPointX2); + } + + private bool IsValidXValue(double value) + { + return value >= 0.0 && value <= 1.0; + } + /// /// Compute cached coefficients. /// From 30eacab09d5023774231e219fd3edc5bfa42fb05 Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Wed, 29 Apr 2020 12:10:43 -0400 Subject: [PATCH 3/8] Add some unit tests for KeySpline --- .../KeySplineTests.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/Avalonia.Animation.UnitTests/KeySplineTests.cs diff --git a/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs b/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs new file mode 100644 index 0000000000..a523be2a78 --- /dev/null +++ b/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs @@ -0,0 +1,48 @@ +using System; +using Avalonia.Controls; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Animation.UnitTests +{ + public class KeySplineTests + { + [Theory] + [InlineData("1,2 3,4")] + [InlineData("1 2 3 4")] + [InlineData("1 2,3 4")] + [InlineData("1,2,3,4")] + public void Can_Parse_KeySpline_Via_TypeConverter(string input) + { + var conv = new KeySplineTypeConverter(); + + var keySpline = (KeySpline)conv.ConvertFrom(input); + + Assert.Equal(1, keySpline.ControlPointX1); + Assert.Equal(2, keySpline.ControlPointY1); + Assert.Equal(3, keySpline.ControlPointX2); + Assert.Equal(4, keySpline.ControlPointY2); + } + + [Theory] + [InlineData(0.00)] + [InlineData(0.50)] + [InlineData(1.00)] + public void KeySpline_X_Values_In_Range_Do_Not_Throw(double input) + { + var keySpline = new KeySpline(); + keySpline.ControlPointX1 = input; // no exception will be thrown -- test will fail if exception thrown + keySpline.ControlPointX2 = input; // no exception will be thrown -- test will fail if exception thrown + } + + [Theory] + [InlineData(-0.01)] + [InlineData(1.01)] + public void KeySpline_X_Values_Cannot_Be_Out_Of_Range(double input) + { + var keySpline = new KeySpline(); + Assert.Throws(() => keySpline.ControlPointX1 = input); + Assert.Throws(() => keySpline.ControlPointX2 = input); + } + } +} From 47c0282ae9368d414310aa2ef167fb1e1465ab4a Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Wed, 29 Apr 2020 12:21:27 -0400 Subject: [PATCH 4/8] Update documentation --- src/Avalonia.Animation/KeySpline.cs | 49 ++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Animation/KeySpline.cs b/src/Avalonia.Animation/KeySpline.cs index dc6fb61744..fc7dc8966e 100644 --- a/src/Avalonia.Animation/KeySpline.cs +++ b/src/Avalonia.Animation/KeySpline.cs @@ -6,10 +6,16 @@ using System.Text; using Avalonia; using Avalonia.Utilities; -// From: https://github.com/dotnet/wpf/blob/ae1790531c3b993b56eba8b1f0dd395a3ed7de75/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/Animation/KeySpline.cs +// Ported from WPF open-source code. +// https://github.com/dotnet/wpf/blob/ae1790531c3b993b56eba8b1f0dd395a3ed7de75/src/Microsoft.DotNet.Wpf/src/PresentationCore/System/Windows/Media/Animation/KeySpline.cs namespace Avalonia.Animation { + /// + /// Determines how an animation is used based on a cubic bezier curve. + /// X1 and X2 must be between 0.0 and 1.0, inclusive. + /// See https://docs.microsoft.com/en-us/dotnet/api/system.windows.media.animation.keyspline + /// [TypeConverter(typeof(KeySplineTypeConverter))] public class KeySpline : AvaloniaObject { @@ -37,6 +43,9 @@ namespace Avalonia.Animation private const double _accuracy = .001; // 1/3 the desired accuracy in X private const double _fuzz = .000001; // computational zero + /// + /// Create a with X1 = Y1 = 0 and X2 = Y2 = 1. + /// public KeySpline() { _controlPointX1 = 0.0; @@ -46,6 +55,13 @@ namespace Avalonia.Animation _isDirty = true; } + /// + /// Create a with the given parameters + /// + /// X coordinate for the first control point + /// Y coordinate for the first control point + /// X coordinate for the second control point + /// Y coordinate for the second control point public KeySpline(double x1, double y1, double x2, double y2) { _controlPointX1 = x1; @@ -55,6 +71,14 @@ namespace Avalonia.Animation _isDirty = true; } + /// + /// Parse a from a string. The string + /// needs to contain 4 values in it for the 2 control points. + /// + /// 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 KeySpline Parse(string value, CultureInfo culture) { using (var tokenizer = new StringTokenizer((string)value, CultureInfo.InvariantCulture, exceptionMessage: "Invalid KeySpline.")) @@ -63,6 +87,9 @@ namespace Avalonia.Animation } } + /// + /// X coordinate of the first control point + /// public double ControlPointX1 { get => _controlPointX1; @@ -79,12 +106,18 @@ namespace Avalonia.Animation } } + /// + /// Y coordinate of the first control point + /// public double ControlPointY1 { get => _controlPointY1; set => _controlPointY1 = value; } + /// + /// X coordinate of the second control point + /// public double ControlPointX2 { get => _controlPointX2; @@ -101,6 +134,9 @@ namespace Avalonia.Animation } } + /// + /// Y coordinate of the second control point + /// public double ControlPointY2 { get => _controlPointY2; @@ -131,11 +167,22 @@ namespace Avalonia.Animation } } + /// + /// Check to see whether the is valid by looking + /// at its X values. + /// + /// true if the X values for this fall in + /// acceptable range; false otherwise. public bool IsValid() { return IsValidXValue(_controlPointX1) && IsValidXValue(_controlPointX2); } + /// + /// + /// + /// + /// private bool IsValidXValue(double value) { return value >= 0.0 && value <= 1.0; From 4cf4bb953bd7a11dfb80718e61730fec627b97a8 Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Wed, 29 Apr 2020 12:39:25 -0400 Subject: [PATCH 5/8] Update docs again (missed KeySplineTypeConverter) --- src/Avalonia.Animation/KeySpline.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Avalonia.Animation/KeySpline.cs b/src/Avalonia.Animation/KeySpline.cs index fc7dc8966e..5a4f7a15a3 100644 --- a/src/Avalonia.Animation/KeySpline.cs +++ b/src/Avalonia.Animation/KeySpline.cs @@ -331,6 +331,9 @@ namespace Avalonia.Animation } } + /// + /// Converts string values to values + /// public class KeySplineTypeConverter : TypeConverter { public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) From 996821b60b8b60159d911b9ee6499db262bfc4fb Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Thu, 30 Apr 2020 09:06:50 -0400 Subject: [PATCH 6/8] Removed TODO now that behavior is verified with WPF --- src/Avalonia.Animation/Animators/Animator`1.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Animation/Animators/Animator`1.cs b/src/Avalonia.Animation/Animators/Animator`1.cs index 121ffda564..0660440e30 100644 --- a/src/Avalonia.Animation/Animators/Animator`1.cs +++ b/src/Avalonia.Animation/Animators/Animator`1.cs @@ -89,7 +89,7 @@ namespace Avalonia.Animation.Animators else newValue = (T)lastKeyframe.Value; - if (lastKeyframe.KeySpline != null) // TODO: do we use firstKeyFrame or lastKeyframe?! + if (lastKeyframe.KeySpline != null) progress = lastKeyframe.KeySpline.GetSplineProgress(progress); return Interpolate(progress, oldValue, newValue); From 62b8da3cdf2dd4741078b7b82af972b95185e0ca Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Thu, 30 Apr 2020 15:46:07 -0400 Subject: [PATCH 7/8] Added a test for KeySpline animation --- .../KeySplineTests.cs | 76 ++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs b/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs index a523be2a78..b98564a03c 100644 --- a/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs +++ b/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs @@ -1,6 +1,7 @@ using System; -using Avalonia.Controls; -using Avalonia.UnitTests; +using Avalonia.Controls.Shapes; +using Avalonia.Media; +using Avalonia.Styling; using Xunit; namespace Avalonia.Animation.UnitTests @@ -44,5 +45,76 @@ namespace Avalonia.Animation.UnitTests Assert.Throws(() => keySpline.ControlPointX1 = input); Assert.Throws(() => keySpline.ControlPointX2 = input); } + + [Fact] + public void Check_KeySpline_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), + KeySpline = new KeySpline(0.1123555056179775, + 0.657303370786517, + 0.8370786516853934, + 0.499999999999999999) + }; + + var animation = new Animation() + { + Duration = TimeSpan.FromSeconds(5), + Children = + { + keyframe1, + keyframe2 + }, + IterationCount = new IterationCount(5), + PlaybackDirection = PlaybackDirection.Alternate + }; + + var rotateTransform = new RotateTransform(-2.5); + var rect = new Rectangle() + { + RenderTransform = rotateTransform + }; + + var clock = new TestClock(); + var animationRun = animation.RunAsync(rect, clock); + + // position is what you'd expect at end and beginning + clock.Step(TimeSpan.Zero); + Assert.Equal(rotateTransform.Angle, -2.5); + clock.Step(TimeSpan.FromSeconds(5)); + Assert.Equal(rotateTransform.Angle, 2.5); + + // test some points in between end and beginning + var tolerance = 0.01; + clock.Step(TimeSpan.Parse("00:00:10.0153932")); + var expected = -2.4122350198982545; + Assert.True(Math.Abs(rotateTransform.Angle - expected) <= tolerance); + + clock.Step(TimeSpan.Parse("00:00:11.2655407")); + expected = -0.37153223002125113; + Assert.True(Math.Abs(rotateTransform.Angle - expected) <= tolerance); + + clock.Step(TimeSpan.Parse("00:00:12.6158773")); + expected = 0.3967885416786294; + Assert.True(Math.Abs(rotateTransform.Angle - expected) <= tolerance); + + clock.Step(TimeSpan.Parse("00:00:14.6495256")); + expected = 1.8016358493761722; + Assert.True(Math.Abs(rotateTransform.Angle - expected) <= tolerance); + } } } From 7ba1c9661bd5d3db6b91b898d7e222e447fde841 Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Thu, 30 Apr 2020 15:58:21 -0400 Subject: [PATCH 8/8] Add some notes on how I got the KeySpline test values --- .../KeySplineTests.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs b/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs index b98564a03c..df7c0693e1 100644 --- a/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs +++ b/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs @@ -46,6 +46,31 @@ namespace Avalonia.Animation.UnitTests Assert.Throws(() => keySpline.ControlPointX2 = input); } + /* + To get the test values for the KeySpline test, you can: + 1) Grab the WPF sample for KeySpline animations from https://github.com/microsoft/WPF-Samples/tree/master/Animation/KeySplineAnimations + 2) Add the following xaml somewhere: +