diff --git a/src/Avalonia.Animation/Easing/Easing.cs b/src/Avalonia.Animation/Easing/Easing.cs index 5b0dea6c60..e006459652 100644 --- a/src/Avalonia.Animation/Easing/Easing.cs +++ b/src/Avalonia.Animation/Easing/Easing.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Globalization; using System.Linq; namespace Avalonia.Animation.Easings @@ -25,6 +26,11 @@ namespace Avalonia.Animation.Easings /// Returns the instance of the parsed type. public static Easing Parse(string e) { + if (e.Contains(',')) + { + return new SplineEasing(KeySpline.Parse(e, CultureInfo.InvariantCulture)); + } + if (_easingTypes == null) { _easingTypes = new Dictionary(); diff --git a/src/Avalonia.Animation/Easing/SplineEasing.cs b/src/Avalonia.Animation/Easing/SplineEasing.cs new file mode 100644 index 0000000000..975fcc4746 --- /dev/null +++ b/src/Avalonia.Animation/Easing/SplineEasing.cs @@ -0,0 +1,85 @@ +namespace Avalonia.Animation.Easings +{ + /// + /// Eases a value + /// using a user-defined cubic bezier curve. + /// Good for custom easing functions that doesn't quite + /// fit with the built-in ones. + /// + public class SplineEasing : Easing + { + /// + /// X coordinate of the first control point + /// + public double X1 + { + get => _internalKeySpline.ControlPointX1; + set + { + _internalKeySpline.ControlPointX1 = value; + } + } + + /// + /// Y coordinate of the first control point + /// + public double Y1 + { + get => _internalKeySpline.ControlPointY1; + set + { + _internalKeySpline.ControlPointY1 = value; + } + } + + /// + /// X coordinate of the second control point + /// + public double X2 + { + get => _internalKeySpline.ControlPointX2; + set + { + _internalKeySpline.ControlPointX2 = value; + } + } + + /// + /// Y coordinate of the second control point + /// + public double Y2 + { + get => _internalKeySpline.ControlPointY2; + set + { + _internalKeySpline.ControlPointY2 = value; + } + } + + private readonly KeySpline _internalKeySpline; + + public SplineEasing(double x1 = 0d, double y1 = 0d, double x2 = 1d, double y2 = 1d) + { + _internalKeySpline = new KeySpline(); + + this.X1 = x1; + this.Y1 = y1; + this.X2 = x2; + this.Y1 = y2; + } + + public SplineEasing(KeySpline keySpline) + { + _internalKeySpline = keySpline; + } + + public SplineEasing() + { + _internalKeySpline = new KeySpline(); + } + + /// + public override double Ease(double progress) => + _internalKeySpline.GetSplineProgress(progress); + } +} diff --git a/src/Avalonia.Animation/KeySpline.cs b/src/Avalonia.Animation/KeySpline.cs index 5a4f7a15a3..a6e9769186 100644 --- a/src/Avalonia.Animation/KeySpline.cs +++ b/src/Avalonia.Animation/KeySpline.cs @@ -81,7 +81,10 @@ namespace Avalonia.Animation /// 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.")) + if (culture is null) + culture = CultureInfo.InvariantCulture; + + using (var tokenizer = new StringTokenizer((string)value, culture, exceptionMessage: $"Invalid KeySpline string: \"{value}\".")) { return new KeySpline(tokenizer.ReadDouble(), tokenizer.ReadDouble(), tokenizer.ReadDouble(), tokenizer.ReadDouble()); } @@ -98,6 +101,7 @@ namespace Avalonia.Animation if (IsValidXValue(value)) { _controlPointX1 = value; + _isDirty = true; } else { @@ -112,7 +116,11 @@ namespace Avalonia.Animation public double ControlPointY1 { get => _controlPointY1; - set => _controlPointY1 = value; + set + { + _controlPointY1 = value; + _isDirty = true; + } } /// @@ -126,6 +134,7 @@ namespace Avalonia.Animation if (IsValidXValue(value)) { _controlPointX2 = value; + _isDirty = true; } else { @@ -140,7 +149,11 @@ namespace Avalonia.Animation public double ControlPointY2 { get => _controlPointY2; - set => _controlPointY2 = value; + set + { + _controlPointY2 = value; + _isDirty = true; + } } /// @@ -330,20 +343,4 @@ namespace Avalonia.Animation } } } - - /// - /// 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/src/Avalonia.Animation/KeySplineTypeConverter.cs b/src/Avalonia.Animation/KeySplineTypeConverter.cs new file mode 100644 index 0000000000..cd7427a37d --- /dev/null +++ b/src/Avalonia.Animation/KeySplineTypeConverter.cs @@ -0,0 +1,25 @@ +using System; +using System.ComponentModel; +using System.Globalization; + +// 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 +{ + /// + /// 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 index df7c0693e1..fa2ed61e65 100644 --- a/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs +++ b/tests/Avalonia.Animation.UnitTests/KeySplineTests.cs @@ -1,4 +1,5 @@ using System; +using Avalonia.Animation.Easings; using Avalonia.Controls.Shapes; using Avalonia.Media; using Avalonia.Styling; @@ -25,6 +26,16 @@ namespace Avalonia.Animation.UnitTests Assert.Equal(4, keySpline.ControlPointY2); } + [Theory] + [InlineData("1,2F,3,4")] + [InlineData("Foo,Bar,Fee,Buzz")] + public void Can_Handle_Invalid_String_KeySpline_Via_TypeConverter(string input) + { + var conv = new KeySplineTypeConverter(); + + Assert.ThrowsAny(() => (KeySpline)conv.ConvertFrom(input)); + } + [Theory] [InlineData(0.00)] [InlineData(0.50)] @@ -46,6 +57,22 @@ namespace Avalonia.Animation.UnitTests Assert.Throws(() => keySpline.ControlPointX2 = input); } + [Fact] + public void SplineEasing_Can_Be_Mutated() + { + var easing = new SplineEasing(); + + Assert.Equal(0, easing.Ease(0)); + Assert.Equal(1, easing.Ease(1)); + + easing.X1 = 0.25; + easing.Y1 = 0.5; + easing.X2 = 0.75; + easing.Y2 = 1.0; + + Assert.NotEqual(0.5, easing.Ease(0.5)); + } + /* 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 @@ -141,5 +168,73 @@ namespace Avalonia.Animation.UnitTests expected = 1.8016358493761722; Assert.True(Math.Abs(rotateTransform.Angle - expected) <= tolerance); } + + [Fact] + public void Check_KeySpline_Parsing_Is_Correct() + { + 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 Animation() + { + Duration = TimeSpan.FromSeconds(5), + Children = + { + keyframe1, + keyframe2 + }, + IterationCount = new IterationCount(5), + PlaybackDirection = PlaybackDirection.Alternate, + Easing = Easing.Parse("0.1123555056179775,0.657303370786517,0.8370786516853934,0.499999999999999999") + }; + + 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); + } } }