Browse Source

Merge pull request #3844 from Deadpikle/feature/keyspline

Adds KeySpline property to KeyFrame
Jumar Macato 5 years ago
parent
commit
a2a23d7ee3

+ 1 - 1
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));
 

+ 9 - 0
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;

+ 3 - 0
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);
         }
 

+ 20 - 0
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
             }
         }
 
+        /// <summary>
+        /// Gets or sets the KeySpline of this <see cref="KeyFrame"/>.
+        /// </summary>
+        /// <value>The key spline.</value>
+        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.");
+                }
+            }
+        }
 
     }
 

+ 349 - 0
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
+{
+    /// <summary>
+    /// 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
+    /// </summary>
+    [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
+
+        /// <summary>
+        /// Create a <see cref="KeySpline"/> with X1 = Y1 = 0 and X2 = Y2 = 1.
+        /// </summary>
+        public KeySpline()
+        {
+            _controlPointX1 = 0.0;
+            _controlPointY1 = 0.0;
+            _controlPointX2 = 1.0;
+            _controlPointY2 = 1.0;
+            _isDirty = true;
+        }
+
+        /// <summary>
+        /// Create a <see cref="KeySpline"/> with the given parameters
+        /// </summary>
+        /// <param name="x1">X coordinate for the first control point</param>
+        /// <param name="y1">Y coordinate for the first control point</param>
+        /// <param name="x2">X coordinate for the second control point</param>
+        /// <param name="y2">Y coordinate for the second control point</param>
+        public KeySpline(double x1, double y1, double x2, double y2)
+        {
+            _controlPointX1 = x1;
+            _controlPointY1 = y1;
+            _controlPointX2 = x2;
+            _controlPointY2 = y2;
+            _isDirty = true;
+        }
+
+        /// <summary>
+        /// Parse a <see cref="KeySpline"/> from a string. The string
+        /// needs to contain 4 values in it for the 2 control points.
+        /// </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="KeySpline"/> with the appropriate values set</returns>
+        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());
+            }
+        }
+
+        /// <summary>
+        /// X coordinate of the first control point
+        /// </summary>
+        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.");
+                }
+            }
+        }
+
+        /// <summary>
+        /// Y coordinate of the first control point
+        /// </summary>
+        public double ControlPointY1
+        {
+            get => _controlPointY1;
+            set => _controlPointY1 = value;
+        }
+
+        /// <summary>
+        /// X coordinate of the second control point
+        /// </summary>
+        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.");
+                }
+            }
+        }
+
+        /// <summary>
+        /// Y coordinate of the second control point
+        /// </summary>
+        public double ControlPointY2
+        {
+            get => _controlPointY2;
+            set => _controlPointY2 = value;
+        }
+
+        /// <summary>
+        /// Calculates spline progress from a linear progress.
+        /// </summary>
+        /// <param name="linearProgress">the linear progress</param>
+        /// <returns>the spline progress</returns>
+        public double GetSplineProgress(double linearProgress)
+        {
+            if (_isDirty)
+            {
+                Build();
+            }
+
+            if (!_isSpecified)
+            {
+                return linearProgress;
+            }
+            else
+            {
+                SetParameterFromX(linearProgress);
+
+                return GetBezierValue(_By, _Cy, _parameter);
+            }
+        }
+
+        /// <summary>
+        /// Check to see whether the <see cref="KeySpline"/> is valid by looking
+        /// at its X values.
+        /// </summary>
+        /// <returns>true if the X values for this <see cref="KeySpline"/> fall in 
+        /// acceptable range; false otherwise.</returns>
+        public bool IsValid()
+        {
+            return IsValidXValue(_controlPointX1) && IsValidXValue(_controlPointX2);
+        }
+
+        /// <summary>
+        /// 
+        /// </summary>
+        /// <param name="value"></param>
+        /// <returns></returns>
+        private bool IsValidXValue(double value)
+        {
+            return value >= 0.0 && value <= 1.0;
+        }
+
+        /// <summary>
+        /// Compute cached coefficients.
+        /// </summary>
+        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;
+        }
+
+        /// <summary>
+        /// Get an X or Y value with the Bezier formula.
+        /// </summary>
+        /// <param name="b">the second Bezier coefficient</param>
+        /// <param name="c">the third Bezier coefficient</param>
+        /// <param name="t">the parameter value to evaluate at</param>
+        /// <returns>the value of the Bezier function at the given parameter</returns>
+        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;
+        }
+
+        /// <summary>
+        /// Get X and dX/dt at a given parameter
+        /// </summary>
+        /// <param name="t">the parameter value to evaluate at</param>
+        /// <param name="x">the value of x there</param>
+        /// <param name="dx">the value of dx/dt there</param>
+        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;
+        }
+
+        /// <summary>
+        /// 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.
+        /// </summary>
+        /// <param name="time">the time, scaled to fit in [0,1]</param>
+        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;
+                    }
+                }
+            }
+        }
+    }
+
+    /// <summary>
+    /// Converts string values to <see cref="KeySpline"/> values
+    /// </summary>
+    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);
+        }
+    }
+}

+ 145 - 0
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<ArgumentException>(() => keySpline.ControlPointX1 = input);
+            Assert.Throws<ArgumentException>(() => 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:
+            <Button Content="Capture"
+                    Click="Button_Click"/>
+            <ScrollViewer VerticalScrollBarVisibility="Visible">
+                <TextBlock Name="CaptureData"
+                           Text="---"
+                           TextWrapping="Wrap" />
+            </ScrollViewer>
+          3) Add the following code to the code behind:
+            private void Button_Click(object sender, RoutedEventArgs e)
+            {
+                CaptureData.Text += string.Format("\n{0} | {1}", myTranslateTransform3D.OffsetX, (TimeSpan)ExampleStoryboard.GetCurrentTime(this));
+                CaptureData.Text +=
+                    "\nKeySpline=\"" + mySplineKeyFrame.KeySpline.ControlPoint1.X.ToString() + "," +
+                    mySplineKeyFrame.KeySpline.ControlPoint1.Y.ToString() + " " +
+                    mySplineKeyFrame.KeySpline.ControlPoint2.X.ToString() + "," +
+                    mySplineKeyFrame.KeySpline.ControlPoint2.Y.ToString() + "\"";
+                CaptureData.Text += "\n-----";
+            }
+          4) Run the app, mess with the slider values, then click the button to capture output values
+         **/
+
+        [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);
+        }
+    }
+}