Skip to content

Commit

Permalink
Fix Animator for progress values less than zero (#15726)
Browse files Browse the repository at this point in the history
* Add failing KeySpline tests

* Fix Animator for progress values less than zero

---------

Co-authored-by: Jumar Macato <16554748+jmacato@users.noreply.github.com>
  • Loading branch information
MrJul and jmacato authored May 16, 2024
1 parent 38d810b commit 42aa77e
Show file tree
Hide file tree
Showing 2 changed files with 163 additions and 36 deletions.
95 changes: 59 additions & 36 deletions src/Avalonia.Base/Animation/Animators/Animator`1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
}
}
}
104 changes: 104 additions & 0 deletions tests/Avalonia.Base.UnitTests/Animation/KeySplineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}

0 comments on commit 42aa77e

Please sign in to comment.