diff --git a/src/Avalonia.Base/Animation/Animators/Animator`1.cs b/src/Avalonia.Base/Animation/Animators/Animator`1.cs index 8a4469d0209..954b62f9bca 100644 --- a/src/Avalonia.Base/Animation/Animators/Animator`1.cs +++ b/src/Avalonia.Base/Animation/Animators/Animator`1.cs @@ -28,57 +28,70 @@ protected T InterpolationHandler(double animationTime, T neutralValue) 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 instance) { if (Property is null) @@ -107,5 +120,15 @@ internal IDisposable Run(Animation animation, Animatable control, IClock? clock, /// Interpolates in-between two key values given the desired progress time. /// 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); + } } } diff --git a/tests/Avalonia.Base.UnitTests/Animation/KeySplineTests.cs b/tests/Avalonia.Base.UnitTests/Animation/KeySplineTests.cs index 61e8103bd59..8af1f7ec020 100644 --- a/tests/Avalonia.Base.UnitTests/Animation/KeySplineTests.cs +++ b/tests/Avalonia.Base.UnitTests/Animation/KeySplineTests.cs @@ -213,5 +213,109 @@ public void Check_KeySpline_Parsing_Is_Correct() 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); + } } }