Browse Source

Fix Animator for progress values less than zero (#15726)

* Add failing KeySpline tests

* Fix Animator for progress values less than zero

---------

Co-authored-by: Jumar Macato <[email protected]>
Julien Lebosquain 1 năm trước cách đây
mục cha
commit
42aa77ebdc

+ 59 - 36
src/Avalonia.Base/Animation/Animators/Animator`1.cs

@@ -28,57 +28,70 @@ namespace Avalonia.Animation.Animators
             if (Count == 0)
                 return neutralValue;
 
-            var (beforeKeyFrame, afterKeyFrame) = FindKeyFrames(animationTime);
+            var (from, to) = GetKeyFrames(animationTime, neutralValue);
 
-            double beforeTime, afterTime;
-            T beforeValue, afterValue;
+            var progress = (animationTime - from.Time) / (to.Time - from.Time);
 
-            if (beforeKeyFrame is null)
-            {
-                beforeTime = 0.0;
-                beforeValue = afterKeyFrame is { FillBefore: true, Value: T fillValue } ? fillValue : neutralValue;
-            }
-            else
-            {
-                beforeTime = beforeKeyFrame.Cue.CueValue;
-                beforeValue = beforeKeyFrame.Value is T value ? value : neutralValue;
-            }
-
-            if (afterKeyFrame is null)
-            {
-                afterTime = 1.0;
-                afterValue = beforeKeyFrame is { FillAfter: true, Value: T fillValue } ? fillValue : neutralValue;
-            }
-            else
-            {
-                afterTime = afterKeyFrame.Cue.CueValue;
-                afterValue = afterKeyFrame.Value is T value ? value : neutralValue;
-            }
-
-            var progress = (animationTime - beforeTime) / (afterTime - beforeTime);
-
-            if (afterKeyFrame?.KeySpline is { } keySpline)
+            if (to.KeySpline is { } keySpline)
                 progress = keySpline.GetSplineProgress(progress);
 
-            return Interpolate(progress, beforeValue, afterValue);
+            return Interpolate(progress, from.Value, to.Value);
         }
 
-        private (AnimatorKeyFrame? Before, AnimatorKeyFrame? After) FindKeyFrames(double time)
+        private (KeyFrameInfo From, KeyFrameInfo To) GetKeyFrames(double time, T neutralValue)
         {
             Debug.Assert(Count >= 1);
 
-            for (var i = 0; i < Count; i++)
+            // Before or right at the first frame which isn't at time 0.0: interpolate between 0.0 and the first frame.
+            var firstFrame = this[0];
+            var firstTime = firstFrame.Cue.CueValue;
+            if (time <= firstTime && firstTime > 0.0)
             {
-                var keyFrame = this[i];
-                var keyFrameTime = keyFrame.Cue.CueValue;
+                var beforeValue = firstFrame.FillBefore ? GetTypedValue(firstFrame.Value, neutralValue) : neutralValue;
+                return (
+                    new KeyFrameInfo(0.0, beforeValue, firstFrame.KeySpline),
+                    KeyFrameInfo.FromKeyFrame(firstFrame, neutralValue));
+            }
 
-                if (time < keyFrameTime || keyFrameTime == 1.0)
-                    return (i > 0 ? this[i - 1] : null, keyFrame);
+            // Between two frames: interpolate between the previous frame and the next frame.
+            for (var i = 1; i < Count; ++i)
+            {
+                var frame = this[i];
+                if (time <= frame.Cue.CueValue)
+                {
+                    return (
+                        KeyFrameInfo.FromKeyFrame(this[i - 1], neutralValue),
+                        KeyFrameInfo.FromKeyFrame(this[i], neutralValue));
+                }
+            }
+
+            // Past the last frame which is at time 1.0: interpolate between the last two frames.
+            var lastFrame = this[Count - 1];
+            if (lastFrame.Cue.CueValue >= 1.0)
+            {
+                if (Count == 1)
+                {
+                    var beforeValue = lastFrame.FillBefore ? GetTypedValue(lastFrame.Value, neutralValue) : neutralValue;
+                    return (
+                        new KeyFrameInfo(0.0, beforeValue, lastFrame.KeySpline),
+                        KeyFrameInfo.FromKeyFrame(lastFrame, neutralValue));
+                }
+
+                return (
+                    KeyFrameInfo.FromKeyFrame(this[Count - 2], neutralValue),
+                    KeyFrameInfo.FromKeyFrame(lastFrame, neutralValue));
             }
 
-            return (this[Count - 1], null);
+            // Past the last frame which isn't at time 1.0: interpolate between the last frame and 1.0.
+            var afterValue = lastFrame.FillAfter ? GetTypedValue(lastFrame.Value, neutralValue) : neutralValue;
+            return (
+                KeyFrameInfo.FromKeyFrame(lastFrame, neutralValue),
+                new KeyFrameInfo(1.0, afterValue, lastFrame.KeySpline));
         }
 
+        private static T GetTypedValue(object? untypedValue, T neutralValue)
+            => untypedValue is T value ? value : neutralValue;
+
         public virtual IDisposable BindAnimation(Animatable control, IObservable<T> instance)
         {
             if (Property is null)
@@ -107,5 +120,15 @@ namespace Avalonia.Animation.Animators
         /// Interpolates in-between two key values given the desired progress time.
         /// </summary>
         public abstract T Interpolate(double progress, T oldValue, T newValue);
+
+        private readonly struct KeyFrameInfo(double time, T value, KeySpline? keySpline)
+        {
+            public readonly double Time = time;
+            public readonly T Value = value;
+            public readonly KeySpline? KeySpline = keySpline;
+
+            public static KeyFrameInfo FromKeyFrame(AnimatorKeyFrame source, T neutralValue)
+                => new(source.Cue.CueValue, GetTypedValue(source.Value, neutralValue), source.KeySpline);
+        }
     }
 }

+ 104 - 0
tests/Avalonia.Base.UnitTests/Animation/KeySplineTests.cs

@@ -213,5 +213,109 @@ namespace Avalonia.Base.UnitTests.Animation
             expected = 1.8016358493761722;
             Assert.True(Math.Abs(rotateTransform.Angle - expected) <= tolerance);
         }
+
+        // https://github.com/AvaloniaUI/Avalonia/issues/15704
+        [Theory]
+        [InlineData(nameof(BackEaseIn))]
+        [InlineData(nameof(BackEaseOut))]
+        [InlineData(nameof(BackEaseInOut))]
+        [InlineData(nameof(ElasticEaseIn))]
+        [InlineData(nameof(ElasticEaseOut))]
+        [InlineData(nameof(ElasticEaseInOut))]
+        public void KeySpline_Progress_Less_Than_Zero_Or_Greater_Than_One_Works(string easingType)
+        {
+            var easing = Easing.Parse(easingType);
+
+            var animation = new Avalonia.Animation.Animation
+            {
+                Duration = TimeSpan.FromSeconds(1.0),
+                Children =
+                {
+                    new KeyFrame
+                    {
+                        Cue = new Cue(0.0),
+                        Setters = { new Setter(TranslateTransform.YProperty, 10.0) }
+                    },
+                    new KeyFrame
+                    {
+                        Cue = new Cue(1.0),
+                        Setters = { new Setter(TranslateTransform.YProperty, 20.0) }
+                    }
+                },
+                IterationCount = new IterationCount(5),
+                PlaybackDirection = PlaybackDirection.Alternate,
+                Easing = easing
+            };
+
+            var transform = new TranslateTransform(0.0, 50.0);
+            var rect = new Rectangle { RenderTransform = transform };
+
+            var clock = new TestClock();
+
+            animation.RunAsync(rect, clock);
+
+            clock.Step(TimeSpan.Zero);
+            Assert.Equal(10.0, transform.Y, 0.0001);
+
+            for (var time = TimeSpan.FromSeconds(0.1); time < animation.Duration; time += TimeSpan.FromSeconds(0.1))
+            {
+                clock.Step(time);
+                Assert.True(double.IsFinite(transform.Y));
+                Assert.NotEqual(10.0, transform.Y);
+                Assert.NotEqual(20.0, transform.Y);
+            }
+
+            clock.Step(animation.Duration);
+            Assert.Equal(20.0, transform.Y, 0.0001);
+        }
+
+        [Theory]
+        [InlineData(nameof(BackEaseIn))]
+        [InlineData(nameof(BackEaseOut))]
+        [InlineData(nameof(BackEaseInOut))]
+        [InlineData(nameof(ElasticEaseIn))]
+        [InlineData(nameof(ElasticEaseOut))]
+        [InlineData(nameof(ElasticEaseInOut))]
+        public void KeySpline_Progress_Less_Than_Zero_Or_Greater_Than_One_Works_With_Single_KeyFrame(string easingType)
+        {
+            var easing = Easing.Parse(easingType);
+
+            var animation = new Avalonia.Animation.Animation
+            {
+                Duration = TimeSpan.FromSeconds(1.0),
+                Children =
+                {
+                    new KeyFrame
+                    {
+                        Cue = new Cue(1.0),
+                        Setters = { new Setter(TranslateTransform.YProperty, 10.0) }
+                    }
+                },
+                IterationCount = new IterationCount(5),
+                PlaybackDirection = PlaybackDirection.Alternate,
+                Easing = easing
+            };
+
+            var transform = new TranslateTransform(0.0, 50.0);
+            var rect = new Rectangle { RenderTransform = transform };
+
+            var clock = new TestClock();
+
+            animation.RunAsync(rect, clock);
+
+            clock.Step(TimeSpan.Zero);
+            Assert.Equal(50.0, transform.Y, 0.0001);
+
+            for (var time = TimeSpan.FromSeconds(0.1); time < animation.Duration; time += TimeSpan.FromSeconds(0.1))
+            {
+                clock.Step(time);
+                Assert.True(double.IsFinite(transform.Y));
+                Assert.NotEqual(50.0, transform.Y);
+                Assert.NotEqual(10.0, transform.Y);
+            }
+
+            clock.Step(animation.Duration);
+            Assert.Equal(10.0, transform.Y, 0.0001);
+        }
     }
 }