Repository: AvaloniaUI/Avalonia

Issue:
Translate animation flickers control visibility when using certain easings as of v11.0.6
### Describe the bug

We originally built a feature into our user prompt dialog overlay where if the user clicked into an area outside of the dialog, we would visually "shake" the dialog with a quick animation to indicate the operation wasn't allowed.  This was implemented back when we referenced Avalonia v11.0.5 and worked great.

We were about to implement a similar feature in another control in our latest codebase that now targets v11.0.7+ and found that the animation is causing the control to flicker visibility.  After digging in, we found that `BackEaseIn`, `BackEaseInOut`, `ElasticEaseIn`, and `ElasticEaseInOut` all cause the issue starting in Avalonia v11.0.6 and it is easily reproduced.

### To Reproduce

Add the following XAML to a project and check the `CheckBox` to trigger the animation.  You will see the button flicker visibility at times the easing (if it is `BackEaseIn`, `BackEaseInOut`, `ElasticEaseIn`, or `ElasticEaseInOut`) drops below the bottom line in the charts here: 

https://docs.avaloniaui.net/docs/reference/animation-settings

This all worked fine in v11.0.5, but some change in v11.0.6 broke it.  The bug is present from v11.0.6 through the current master.

This animation shows the issue in action:
![AnimationIssue](https://github.com/AvaloniaUI/Avalonia/assets/5760058/9e4dce06-5727-4f30-8458-9b89cc8a0d86)

Whereas here in v11.0.5 is how it should look:
![AnimationWorking](https://github.com/AvaloniaUI/Avalonia/assets/5760058/32c77a2f-00cf-40b3-876a-26fe80c9e1eb)

```
<StackPanel>
	<Panel Width="300" Height="200" HorizontalAlignment="Left" Background="LightGray">
		<Button x:Name="shakeButton" IsEnabled="False" Content="Shakes When Enabled" HorizontalAlignment="Center" VerticalAlignment="Center">
			<Button.Theme>
				<ControlTheme TargetType="Button">
					<Setter Property="Template">
						<ControlTemplate>
							<Border BorderBrush="Black" BorderThickness="1" Background="White" Padding="20">
								<ContentPresenter Content="{TemplateBinding Content}" />
							</Border>
						</ControlTemplate>
					</Setter>

					<Style Selector="^:disabled">
						<Setter Property="TextElement.Foreground" Value="LightGray" />
					</Style>

					<Style Selector="^:not(:disabled)">
						<Setter Property="TextElement.Foreground" Value="Blue" />
						<Style.Animations>
							<Animation FillMode="Both" Duration="0:0:1">
								<Animation.Easing>
								<!--
									The following easing have flicker issues, which is every
									easing that drops below the bottom line in the charts here: https://docs.avaloniaui.net/docs/reference/animation-settings
											
									<BackEaseIn/>
									<BackEaseInOut/>
									<ElasticEaseIn />
									<ElasticEaseInOut />
								-->
									<ElasticEaseInOut/>
								</Animation.Easing>
								<KeyFrame Cue="0%">
									<Setter Property="TranslateTransform.Y" Value="0" />
								</KeyFrame>
								<KeyFrame Cue="50%">
									<Setter Property="TranslateTransform.Y" Value="-10" />
								</KeyFrame>
								<KeyFrame Cue="100%">
									<Setter Property="TranslateTransform.Y" Value="0" />
								</KeyFrame>
							</Animation>
						</Style.Animations>
					</Style>

				</ControlTheme>
			</Button.Theme>
		</Button>
	</Panel>
	<TextBlock TextWrapping="Wrap">Check the box to enable the button and trigger an aimation.  BackEaseIn, BackEaseInOut, ElasticEaseIn, and ElasticEaseInOut all cause issues.</TextBlock>
	<CheckBox IsChecked="{Binding #shakeButton.IsEnabled}" Content="Is enabled" />
</StackPanel>
```

### Expected behavior

The animation should move the button correctly even when the easing temporarily puts the animated control out of range of the normal values, and no visibility flickering should occur.  This worked in v11.0.5.

### Avalonia version

v11.0.6 and later

### OS

Windows

### Additional context

_No response_


Hints:

Patch:
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<T> 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.
         /// </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);
+        }
     }
 }


Test patch:
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);
+        }
     }
 }