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..0660440e30 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) + 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 new file mode 100644 index 0000000000..5a4f7a15a3 --- /dev/null +++ b/src/Avalonia.Animation/KeySpline.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.Text; +using Avalonia; +using Avalonia.Utilities; + +// 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 + { + // 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 + + /// + /// Create a with X1 = Y1 = 0 and X2 = Y2 = 1. + /// + public KeySpline() + { + _controlPointX1 = 0.0; + _controlPointY1 = 0.0; + _controlPointX2 = 1.0; + _controlPointY2 = 1.0; + _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; + _controlPointY1 = y1; + _controlPointX2 = x2; + _controlPointY2 = y2; + _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.")) + { + return new KeySpline(tokenizer.ReadDouble(), tokenizer.ReadDouble(), tokenizer.ReadDouble(), tokenizer.ReadDouble()); + } + } + + /// + /// X coordinate of the first control point + /// + public double ControlPointX1 + { + get => _controlPointX1; + set + { + if (IsValidXValue(value)) + { + _controlPointX1 = value; + } + else + { + throw new ArgumentException("Invalid KeySpline X1 value. Must be >= 0.0 and <= 1.0."); + } + } + } + + /// + /// 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; + set + { + if (IsValidXValue(value)) + { + _controlPointX2 = value; + } + else + { + throw new ArgumentException("Invalid KeySpline X2 value. Must be >= 0.0 and <= 1.0."); + } + } + } + + /// + /// Y coordinate of the second control point + /// + 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) + { + if (_isDirty) + { + Build(); + } + + if (!_isSpecified) + { + return linearProgress; + } + else + { + SetParameterFromX(linearProgress); + + return GetBezierValue(_By, _Cy, _parameter); + } + } + + /// + /// 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; + } + + /// + /// 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; + } + } + } + } + } + + /// + /// Converts string values to values + /// + 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); + } + } +} diff --git a/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs b/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs new file mode 100644 index 0000000000..df7c0693e1 --- /dev/null +++ b/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs @@ -0,0 +1,145 @@ +using System; +using Avalonia.Controls.Shapes; +using Avalonia.Media; +using Avalonia.Styling; +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); + } + + /* + 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: +