Browse Source

Preserve current `Animation` progress if `SpeedRatio` or `PlaybackDirection` changes (#19830)

* Add AnimationSpeedPage to RenderDemo

* Add tests

* Implement fix

* Minor improvements

Remove leftover vars/params
Replace some `if`s with terniary assignment
Improve comments
Unduplicate `playbackReversed` code

* Add unit test

DelayBetweenIterations_Behind_Initial_Point_Is_In_Front_Of_Iterations

* Add animation time precision

* Add missing check for negative `DelayBetweenIterations`

* Fix zero duration regression

---------

Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
pull/21921/head
timiimit-tam 2 months ago
committed by GitHub
parent
commit
064b84a982
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      samples/RenderDemo/MainWindow.xaml
  2. 117
      samples/RenderDemo/Pages/AnimationSpeedPage.xaml
  3. 23
      samples/RenderDemo/Pages/AnimationSpeedPage.xaml.cs
  4. 103
      samples/RenderDemo/ViewModels/AnimationSpeedPageViewModel.cs
  5. 395
      src/Avalonia.Base/Animation/AnimationInstance`1.cs
  6. 527
      tests/Avalonia.Base.UnitTests/Animation/AnimatableTests.cs

3
samples/RenderDemo/MainWindow.xaml

@ -51,6 +51,9 @@
<TabItem Header="Animations">
<pages:AnimationsPage />
</TabItem>
<TabItem Header="Animation Speed">
<pages:AnimationSpeedPage />
</TabItem>
<TabItem Header="Transitions">
<pages:TransitionsPage />
</TabItem>

117
samples/RenderDemo/Pages/AnimationSpeedPage.xaml

@ -0,0 +1,117 @@
<UserControl
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:viewModels="using:RenderDemo.ViewModels"
x:Class="RenderDemo.Pages.AnimationSpeedPage"
x:DataType="viewModels:AnimationSpeedPageViewModel"
MaxWidth="600">
<UserControl.Styles>
<Style Selector="TextBlock.rotatorNINF">
<Style.Animations>
<Animation Duration="0:0:1" IterationCount="INFINITE"
Delay="{Binding Delay, Mode=OneWay}"
DelayBetweenIterations="{Binding DelayIters, Mode=OneWay}"
PlaybackDirection="{Binding PlaybackDirection, Mode=OneWay}"
SpeedRatio="{Binding SpeedRatio, Mode=OneWay}">
<KeyFrame Cue="0%">
<Setter Property="RotateTransform.Angle" Value="0.0" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="RotateTransform.Angle" Value="360.0" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
<Style Selector="TextBlock.rotatorN10">
<Style.Animations>
<Animation Duration="0:0:1" IterationCount="10"
Delay="{Binding Delay, Mode=OneWay}"
DelayBetweenIterations="{Binding DelayIters, Mode=OneWay}"
PlaybackDirection="{Binding PlaybackDirection, Mode=OneWay}"
SpeedRatio="{Binding SpeedRatio, Mode=OneWay}">
<KeyFrame Cue="0%">
<Setter Property="RotateTransform.Angle" Value="0.0" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="RotateTransform.Angle" Value="360.0" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
<Style Selector="TextBlock.rotatorRINF">
<Style.Animations>
<Animation Duration="0:0:1" IterationCount="INFINITE"
Delay="{Binding Delay, Mode=OneWay}"
DelayBetweenIterations="{Binding DelayIters, Mode=OneWay}"
PlaybackDirection="{Binding PlaybackDirectionOpposite, Mode=OneWay}"
SpeedRatio="{Binding SpeedRatio, Mode=OneWay}">
<KeyFrame Cue="0%">
<Setter Property="RotateTransform.Angle" Value="0.0" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="RotateTransform.Angle" Value="360.0" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
<Style Selector="TextBlock.rotatorR10">
<Style.Animations>
<Animation Duration="0:0:1" IterationCount="10"
Delay="{Binding Delay, Mode=OneWay}"
DelayBetweenIterations="{Binding DelayIters, Mode=OneWay}"
PlaybackDirection="{Binding PlaybackDirectionOpposite, Mode=OneWay}"
SpeedRatio="{Binding SpeedRatio, Mode=OneWay}">
<KeyFrame Cue="0%">
<Setter Property="RotateTransform.Angle" Value="0.0" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="RotateTransform.Angle" Value="360.0" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
</UserControl.Styles>
<ScrollViewer>
<StackPanel Orientation="Vertical">
<Grid RowDefinitions="Auto,Auto,Auto,1*" ColumnDefinitions="Auto,5*,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="SpeedRatio:" />
<Slider Grid.Row="0" Grid.Column="1"
Value="{Binding SpeedRatio, Mode=OneWayToSource}"
Minimum="0" Maximum="2" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Delay:" />
<Slider Grid.Row="1" Grid.Column="1"
Value="{Binding DelayInput, Mode=OneWayToSource}"
Minimum="0" Maximum="10" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="DelayIters:" />
<Slider Grid.Row="2" Grid.Column="1"
Value="{Binding DelayItersInput, Mode=OneWayToSource}"
Minimum="0" Maximum="10" />
<ComboBox Grid.Row="0" Grid.RowSpan="3" Grid.Column="2"
ItemsSource="{Binding PlaybackDirections}"
SelectedValue="{Binding PlaybackDirection, Mode=OneWayToSource}" />
</Grid>
<Grid RowDefinitions="Auto,1*,Auto,1*" ColumnDefinitions="Auto,Auto"
Grid.Row="3" Grid.ColumnSpan="3" Height="400">
<TextBlock Grid.Row="0" Grid.Column="0" Text="PlayDir=AsSelected; Iters=INF" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Rotate me" Classes="rotatorNINF"
HorizontalAlignment="Center" VerticalAlignment="Center" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="PlayDir=AsSelected; Iters=10" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Rotate me" Classes="rotatorN10"
HorizontalAlignment="Center" VerticalAlignment="Center" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="PlayDir=Opposite; Iters=INF" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="Rotate me" Classes="rotatorRINF"
HorizontalAlignment="Center" VerticalAlignment="Center" />
<TextBlock Grid.Row="2" Grid.Column="1" Text="PlayDir=Opposite; Iters=10" />
<TextBlock Grid.Row="3" Grid.Column="1" Text="Rotate me" Classes="rotatorR10"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Grid>
</StackPanel>
</ScrollViewer>
</UserControl>

23
samples/RenderDemo/Pages/AnimationSpeedPage.xaml.cs

@ -0,0 +1,23 @@
using System.Reactive.Linq;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Markup.Xaml;
using RenderDemo.ViewModels;
namespace RenderDemo.Pages;
public class AnimationSpeedPage : UserControl
{
public AnimationSpeedPage()
{
InitializeComponent();
this.DataContext = new AnimationSpeedPageViewModel();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

103
samples/RenderDemo/ViewModels/AnimationSpeedPageViewModel.cs

@ -0,0 +1,103 @@
using System;
using System.Collections;
using Avalonia.Animation;
using MiniMvvm;
namespace RenderDemo.ViewModels;
public class AnimationSpeedPageViewModel : ViewModelBase
{
private double _speedRatio;
public double SpeedRatio
{
get => _speedRatio;
set => RaiseAndSetIfChanged(ref _speedRatio, value);
}
private static readonly PlaybackDirection[] s_playbackDirections = [
PlaybackDirection.Normal,
PlaybackDirection.Reverse,
PlaybackDirection.Alternate,
PlaybackDirection.AlternateReverse
];
public PlaybackDirection[] PlaybackDirections
{
get => s_playbackDirections;
}
private PlaybackDirection GetOppositePlaybackDirection(PlaybackDirection value)
{
switch (value)
{
case PlaybackDirection.Normal:
return PlaybackDirection.Reverse;
case PlaybackDirection.Reverse:
return PlaybackDirection.Normal;
case PlaybackDirection.Alternate:
return PlaybackDirection.AlternateReverse;
case PlaybackDirection.AlternateReverse:
return PlaybackDirection.Alternate;
}
throw new ArgumentOutOfRangeException();
}
private PlaybackDirection _playbackDirection;
public PlaybackDirection PlaybackDirection
{
get => _playbackDirection;
set
{
_playbackDirection = value;
RaisePropertyChanged(nameof(PlaybackDirection));
_playbackDirectionOpposite = GetOppositePlaybackDirection(value);
RaisePropertyChanged(nameof(PlaybackDirectionOpposite));
}
}
private PlaybackDirection _playbackDirectionOpposite;
public PlaybackDirection PlaybackDirectionOpposite
{
get => _playbackDirectionOpposite;
set
{
_playbackDirectionOpposite = value;
RaisePropertyChanged(nameof(PlaybackDirectionOpposite));
_playbackDirection = GetOppositePlaybackDirection(value);
RaisePropertyChanged(nameof(PlaybackDirection));
}
}
private TimeSpan _delay;
public TimeSpan Delay
{
get => _delay;
set => RaiseAndSetIfChanged(ref _delay, value);
}
public double DelayInput
{
get => _delay.TotalSeconds;
set => Delay = TimeSpan.FromSeconds(value);
}
private TimeSpan _delayIters;
public TimeSpan DelayIters
{
get => _delayIters;
set => RaiseAndSetIfChanged(ref _delayIters, value);
}
public double DelayItersInput
{
get => _delayIters.TotalSeconds;
set => DelayIters = TimeSpan.FromSeconds(value);
}
public AnimationSpeedPageViewModel()
{
SpeedRatio = 1;
PlaybackDirection = PlaybackDirection.Normal;
DelayInput = 0;
DelayItersInput = 0;
}
}

395
src/Avalonia.Base/Animation/AnimationInstance`1.cs

@ -12,73 +12,106 @@ namespace Avalonia.Animation
/// </summary>
internal class AnimationInstance<T> : SingleSubscriberObservableBase<T>
{
private T _lastInterpValue;
private T _firstKFValue;
private ulong? _iterationCount;
private ulong _currentIteration;
private bool _gotFirstKFValue;
private bool _playbackReversed;
private FillMode _fillMode;
private PlaybackDirection _playbackDirection;
private readonly Animator<T> _animator;
private readonly Animation _animation;
private readonly Animatable _targetControl;
private T _neutralValue;
private double _speedRatioConv;
private TimeSpan _initialDelay;
private TimeSpan _iterationDelay;
private TimeSpan _duration;
private Easings.Easing? _easeFunc;
private readonly Action? _onCompleteAction;
private readonly Func<double, T, T> _interpolator;
private IDisposable? _timerSub;
private readonly IClock _baseClock;
private IClock? _clock;
private EventHandler<AvaloniaPropertyChangedEventArgs>? _propertyChangedDelegate;
private EventHandler? _visibilityChangedHandler;
private EventHandler<VisualTreeAttachmentEventArgs>? _detachedHandler;
private readonly IClock _baseClock;
private IClock? _clock;
private Easings.Easing? _easeFunc;
private readonly Func<double, T, T> _interpolator;
private T _neutralValue;
private FillMode _fillMode;
private bool _isFirstFrame;
private bool _isInFirstInitialDelay;
private T _lastInterpValue;
private T _initialKFValue;
private long? _iterationCount;
private TimeSpan _initialDelay;
private TimeSpan _iterationDelay;
private TimeSpan _duration;
private TimeSpan _timePrev;
private double _animTimePrev;
private PlaybackDirection _playbackDirection;
private PlaybackDirection _playbackDirectionPrev;
private double _speedRatio;
private double _speedRatioPrev;
private bool _timeMovesBackwards;
private TimeSpan _timeOfLastChange;
private double _animTimeOfLastChange;
/// <summary>
/// Animation's smallest unit of time in terms of TimeSpan Ticks. This can be used
/// to increase possible runtime of animation before reaching over/under-flow.
/// </summary>
/// <remarks>
/// Value to use here can be found with <code>TimeSpan.FromMilliseconds(x).Ticks</code>
/// </remarks>
private const long PRECISION_IN_TICKS = 10_000;
private readonly bool _shouldPauseOnInvisible;
public AnimationInstance(Animation animation, Animatable control, Animator<T> animator, IClock baseClock, Action? OnComplete, Func<double, T, T> Interpolator, bool shouldPauseOnInvisible)
{
_lastInterpValue = default!;
_animator = animator;
_animation = animation;
_targetControl = control;
_onCompleteAction = OnComplete;
_interpolator = Interpolator;
_baseClock = baseClock;
_lastInterpValue = default!;
_firstKFValue = default!;
_initialKFValue = default!;
_neutralValue = default!;
_shouldPauseOnInvisible = shouldPauseOnInvisible;
_isFirstFrame = true;
_isInFirstInitialDelay = true;
_speedRatio = 1;
FetchProperties();
}
private void FetchProperties()
{
if (_animation.SpeedRatio < 0d)
throw new InvalidOperationException("SpeedRatio value should not be negative.");
if (_animation.Duration < TimeSpan.Zero)
throw new InvalidOperationException("Duration value cannot be negative.");
if (_animation.Delay < TimeSpan.Zero)
throw new InvalidOperationException("Delay value cannot be negative.");
_easeFunc = _animation.Easing;
_speedRatioConv = 1d / _animation.SpeedRatio;
_speedRatioPrev = _speedRatio;
_speedRatio = _animation.SpeedRatio;
if (_speedRatio < 0d)
throw new InvalidOperationException("SpeedRatio value cannot be negative.");
_initialDelay = _animation.Delay;
if (_initialDelay < TimeSpan.Zero)
throw new InvalidOperationException("Delay value cannot be negative.");
_duration = _animation.Duration;
if (_duration < TimeSpan.Zero)
throw new InvalidOperationException("Duration value cannot be negative.");
_iterationDelay = _animation.DelayBetweenIterations;
if (_iterationDelay < TimeSpan.Zero)
throw new InvalidOperationException("DelayBetweenIterations value cannot be negative.");
if (_animation.IterationCount.RepeatType == IterationType.Many)
_iterationCount = _animation.IterationCount.Value;
{
if (_animation.IterationCount.Value > long.MaxValue)
throw new InvalidOperationException("IterationCount value cannot be larger than long.MaxValue.");
_iterationCount = (long)_animation.IterationCount.Value;
}
else
{
_iterationCount = null;
}
_playbackDirectionPrev = _playbackDirection;
_playbackDirection = _animation.PlaybackDirection;
_fillMode = _animation.FillMode;
}
@ -93,7 +126,8 @@ namespace Avalonia.Animation
return;
// Animation may have been stopped before it has finished.
ApplyFinalFill();
if (CanApplyFinalFill())
ApplyFinalFill(_lastInterpValue);
_targetControl.PropertyChanged -= _propertyChangedDelegate;
timerSub.Dispose();
@ -148,11 +182,7 @@ namespace Avalonia.Animation
}
// Stop and dispose the animation when detached from the visual tree.
_detachedHandler = (_, _) =>
{
SetFinalValue();
DoComplete();
};
_detachedHandler = (_, _) => DoComplete(false);
visual.DetachedFromVisualTree += _detachedHandler;
}
@ -167,8 +197,8 @@ namespace Avalonia.Animation
{
if (_clock?.PlayState == PlayState.Pause)
return;
InternalStep(frameTick);
InternalStep(frameTick);
}
catch (Exception e)
{
@ -176,127 +206,274 @@ namespace Avalonia.Animation
}
}
private void SetFinalValue()
private bool CanApplyFinalFill()
{
var easedTime = _easeFunc!.Ease(_playbackReversed ? 0.0 : 1.0);
_lastInterpValue = _interpolator(easedTime, _neutralValue);
return _fillMode is FillMode.Forward or FillMode.Both;
}
private void ApplyFinalFill()
private void ApplyFinalFill(T interpolatedValue)
{
if (_animator.Property is null)
throw new InvalidOperationException("Animator has no property specified.");
if (_fillMode is FillMode.Forward or FillMode.Both)
_targetControl.SetValue(_animator.Property, _lastInterpValue);
_targetControl.SetValue(_animator.Property, interpolatedValue);
}
private void DoComplete()
private void DoComplete(bool stopAsIs)
{
ApplyFinalFill();
if (!stopAsIs)
{
// Update _lastInterpValue because PublishCompleted might perform final fill
// and we should ensure that filled value is snapped to last value that animation would
// reach if it was left running in current state.
var isIterationReversed = IsIterationReversed(IsAlternatingPlaybackDirection(), _iterationCount + 1);
_lastInterpValue = FindEdgeValue(IsAnimTimeGoingBackwards(), isIterationReversed);
}
_onCompleteAction?.Invoke();
PublishCompleted();
}
private void DoDelay()
private void DoInitialDelay()
{
if (_isInFirstInitialDelay)
{
if (_fillMode is not (FillMode.Backward or FillMode.Both))
return;
}
PublishNext(_initialKFValue);
}
private void DoIterationDelay(bool isIterationReversed)
{
if (_fillMode is not (FillMode.Backward or FillMode.Both)) return;
PublishNext(_currentIteration == 0 ? _firstKFValue : _lastInterpValue);
_lastInterpValue = FindEdgeValue(false, isIterationReversed);
PublishNext(_lastInterpValue);
}
private void DoPlayStates()
{
if (_clock!.PlayState == PlayState.Stop || _baseClock.PlayState == PlayState.Stop)
DoComplete();
DoComplete(true);
if (_isFirstFrame)
{
// In first frame of animation we determine the expected direction of time.
_timeMovesBackwards =
_animation.PlaybackDirection == PlaybackDirection.Reverse ||
_animation.PlaybackDirection == PlaybackDirection.AlternateReverse;
var initialKeyFrame = _timeMovesBackwards ? _animator.Last() : _animator.First();
_initialKFValue = initialKeyFrame.Value is T initialValue ? initialValue : _neutralValue;
_isFirstFrame = false;
}
}
private static bool IsIterationReversed(bool isAlternatingPlaybackDirection, long? iterationIndex)
{
if (isAlternatingPlaybackDirection)
return (iterationIndex & 1) != 0;
else
return false;
}
private T FindEdgeValue(bool isAnimTimeGoingBackwards, bool isIterationReversed)
{
double finalEaseValue = isAnimTimeGoingBackwards ^ isIterationReversed ? 0.0 : 1.0;
var easedTime = _easeFunc!.Ease(finalEaseValue);
return _interpolator(easedTime, _neutralValue);
}
private bool IsAnimTimeGoingBackwards()
{
return
_playbackDirection == PlaybackDirection.Reverse ||
_playbackDirection == PlaybackDirection.AlternateReverse;
}
private bool IsAlternatingPlaybackDirection()
{
return
_playbackDirection == PlaybackDirection.Alternate ||
_playbackDirection == PlaybackDirection.AlternateReverse;
}
private bool ApplyInitialDelay(ref long animTime)
{
// Handle all possible cases of applying initial delay and clamping.
long delay = _initialDelay.Ticks / PRECISION_IN_TICKS;
if (_iterationCount.HasValue)
{
animTime -= delay;
if (animTime <= 0)
{
if (animTime < -delay)
animTime = -delay;
return true;
}
}
else
{
// Determine the interval of initial delay.
long low = -delay;
long high = 0;
// Handle all 3 location cases based on above interval.
if (animTime < low)
{
animTime += delay;
if (animTime > 0)
{
animTime = 0;
return true;
}
}
else if (animTime > high)
{
animTime -= delay;
if (animTime < 0)
{
animTime = 0;
return true;
}
}
else // if (animTime >= low && animTime <= high)
{
animTime = 0;
return true;
}
}
return false;
}
if (!_gotFirstKFValue)
private void ApplyLimitedClamp(ref double animTime)
{
if (_timeMovesBackwards)
{
if (animTime > 0)
animTime = 0;
}
else
{
var firstKeyFrame = _animator.First();
_firstKFValue = firstKeyFrame.Value is T value ? value : _neutralValue;
_gotFirstKFValue = true;
if (animTime < 0)
animTime = 0;
}
}
private void InternalStep(TimeSpan time)
{
DoPlayStates();
FetchProperties();
// Scale timebases according to speedratio.
var indexTime = time.Ticks;
var iterDuration = _duration.Ticks * _speedRatioConv;
var iterDelay = _iterationDelay.Ticks * _speedRatioConv;
var initDelay = _initialDelay.Ticks * _speedRatioConv;
if (_speedRatio != _speedRatioPrev || _playbackDirection != _playbackDirectionPrev)
{
// Remember the time when speed changed.
// All we can know is that it changed some time between current and prev frame.
// We assume that this happened exactly at prev frame.
_timeOfLastChange = _timePrev;
_animTimeOfLastChange = _animTimePrev;
}
bool isAnimTimeGoingBackwards = IsAnimTimeGoingBackwards();
// This conditional checks if the time given is the very start/zero
// and when we have an active delay time.
if (initDelay > 0 && indexTime <= initDelay)
// Combine SpeedRatio and PlaybackDirection into a single signed speed ratio.
double speedRatio = isAnimTimeGoingBackwards ? -_speedRatio : _speedRatio;
// Calculate animation time. That's time that has passed inside
// the animation since its beginning.
var timeSinceLastChange = time - _timeOfLastChange;
var animTimeSinceLastChange = timeSinceLastChange.Ticks * speedRatio / PRECISION_IN_TICKS;
var animTimeExact = _animTimeOfLastChange + animTimeSinceLastChange;
if (_iterationCount.HasValue)
{
DoDelay();
return;
// Make sure animation time is inside a valid interval.
ApplyLimitedClamp(ref animTimeExact);
}
// Calculate timebases.
var iterationTime = iterDuration + iterDelay;
var opsTime = indexTime - initDelay;
var playbackTime = opsTime % iterationTime;
_timePrev = time;
_animTimePrev = animTimeExact;
var animTime = (long)animTimeExact;
// Get animation time oriented in the direction of time. Animation running in
// same direction as it was on first frame will always increase this variable.
long animTimePositive = _timeMovesBackwards ? -animTime : animTime;
if (_initialDelay > TimeSpan.Zero)
{
bool isCurrentlyInsideDelay = ApplyInitialDelay(ref animTimePositive);
if (isCurrentlyInsideDelay)
{
DoInitialDelay();
return;
}
_isInFirstInitialDelay = false;
}
_currentIteration = (ulong)(opsTime / iterationTime);
var iterDuration = _duration.Ticks / PRECISION_IN_TICKS;
var iterDelay = _iterationDelay.Ticks / PRECISION_IN_TICKS;
var iterDurationTotal = iterDuration + iterDelay;
// Stop animation when the current iteration is beyond the iteration count or
// when the duration is set to zero while animating and snap to the last iterated value.
if (_currentIteration + 1 > _iterationCount || _duration == TimeSpan.Zero)
// An iteration with no duration can't be interpolated, no matter how long the
// delay between iterations is: end the animation and snap to its final value.
if (iterDuration <= 0)
{
SetFinalValue();
DoComplete();
DoComplete(false);
return;
}
if (playbackTime <= iterDuration)
// Calculate current iteration info.
var iterIndex = animTimePositive / iterDurationTotal;
var iterTime = animTimePositive % iterDurationTotal;
bool playbackReversed = animTimePositive < 0;
if (playbackReversed)
{
// Normalize time for interpolation.
var normalizedTime = playbackTime / iterDuration;
// Animation time is behind the starting point of animation.
// First negative iteration has index -1, first positive iteration has index 0.
iterIndex--;
// Move iteration delay to the front of iteration, which is (when moving
// backwards through animation time) effectively at the end of iteration.
iterTime = -iterTime;
}
playbackReversed ^= _timeMovesBackwards ^ IsIterationReversed(IsAlternatingPlaybackDirection(), iterIndex);
// Check if normalized time needs to be reversed according to PlaybackDirection
var itersUntilEnd = _iterationCount - iterIndex;
// End animation when limit is reached.
if (itersUntilEnd <= 0)
{
DoComplete(false);
return;
}
switch (_playbackDirection)
if (iterTime > iterDuration && iterTime <= iterDurationTotal && iterDelay > 0)
{
// The last iteration's trailing delay should be skipped.
if (_iterationCount.HasValue && itersUntilEnd <= 1)
{
case PlaybackDirection.Normal:
_playbackReversed = false;
break;
case PlaybackDirection.Reverse:
_playbackReversed = true;
break;
case PlaybackDirection.Alternate:
_playbackReversed = _currentIteration % 2 != 0;
break;
case PlaybackDirection.AlternateReverse:
_playbackReversed = _currentIteration % 2 == 0;
break;
default:
throw new InvalidOperationException(
$"Animation direction value is unknown: {_playbackDirection}");
DoComplete(false);
return;
}
if (_playbackReversed)
normalizedTime = 1 - normalizedTime;
DoIterationDelay(playbackReversed);
}
else if (iterTime <= iterDuration)
{
// Ease and interpolate.
var normalized = iterTime / (double)iterDuration;
if (playbackReversed)
normalized = 1 - normalized;
// Ease and interpolate
var easedTime = _easeFunc!.Ease(normalizedTime);
var easedTime = _easeFunc!.Ease(normalized);
_lastInterpValue = _interpolator(easedTime, _neutralValue);
PublishNext(_lastInterpValue);
}
else if (playbackTime > iterDuration &&
playbackTime <= iterationTime &&
iterDelay > 0)
{
// The last iteration's trailing delay should be skipped.
if (_currentIteration + 1 < _iterationCount)
DoDelay();
else
DoComplete();
}
}
private void UpdateNeutralValue()

527
tests/Avalonia.Base.UnitTests/Animation/AnimatableTests.cs

@ -654,6 +654,533 @@ namespace Avalonia.Base.UnitTests.Animation
}
}
[Fact]
public void Changing_SpeedRatio_Keeps_Playback_Time_Constant()
{
using (Start())
{
var keyframe1 = new KeyFrame()
{
Setters = { new Setter(Visual.OpacityProperty, 0d), },
KeyTime = TimeSpan.FromSeconds(0)
};
var keyframe2 = new KeyFrame()
{
Setters = { new Setter(Visual.OpacityProperty, 1d), },
KeyTime = TimeSpan.FromSeconds(1)
};
var animation = new Avalonia.Animation.Animation()
{
Duration = TimeSpan.FromSeconds(1),
Children = { keyframe1, keyframe2 },
};
Border target;
var clock = new TestClock();
var root = new TestRoot
{
Clock = clock,
Styles = { new Style(x => x.OfType<Border>()) { Animations = { animation }, } },
Child = target = new Border { Background = Brushes.Red, }
};
root.Measure(Size.Infinity);
root.Arrange(new Rect(root.DesiredSize));
clock.Step(TimeSpan.FromSeconds(0));
clock.Step(TimeSpan.FromSeconds(0.25));
animation.SpeedRatio = 2;
Assert.Equal(0.25, target.Opacity);
clock.Step(TimeSpan.FromSeconds(0.25));
Assert.Equal(0.25, target.Opacity);
clock.Step(TimeSpan.FromSeconds(0.25 + 0.125));
Assert.Equal(0.5, target.Opacity);
animation.SpeedRatio = 0.5;
Assert.Equal(0.5, target.Opacity);
clock.Step(TimeSpan.FromSeconds(0.25 + 0.125));
Assert.Equal(0.5, target.Opacity);
clock.Step(TimeSpan.FromSeconds(0.25 + 0.125 + 0.5));
Assert.Equal(0.75, target.Opacity);
animation.SpeedRatio = 1;
Assert.Equal(0.75, target.Opacity);
clock.Step(TimeSpan.FromSeconds(0.25 + 0.125 + 0.5));
Assert.Equal(0.75, target.Opacity);
clock.Step(TimeSpan.FromSeconds(0.25 + 0.125 + 0.5 + 0.25));
Assert.Equal(1, target.Opacity);
}
}
[Fact]
public void Changing_PlaybackDirection_Keeps_Playback_Time_Constant()
{
using (Start())
{
var keyframe1 = new KeyFrame()
{
Setters = { new Setter(Visual.OpacityProperty, 0d), },
KeyTime = TimeSpan.FromSeconds(0)
};
var keyframe2 = new KeyFrame()
{
Setters = { new Setter(Visual.OpacityProperty, 1d), },
KeyTime = TimeSpan.FromSeconds(1)
};
var animation = new Avalonia.Animation.Animation()
{
Duration = TimeSpan.FromSeconds(1),
Children = { keyframe1, keyframe2 },
PlaybackDirection = PlaybackDirection.Normal
};
Border target;
var clock = new TestClock();
var root = new TestRoot
{
Clock = clock,
Styles = { new Style(x => x.OfType<Border>()) { Animations = { animation }, } },
Child = target = new Border { Background = Brushes.Red, }
};
root.Measure(Size.Infinity);
root.Arrange(new Rect(root.DesiredSize));
clock.Step(TimeSpan.FromSeconds(0));
clock.Step(TimeSpan.FromSeconds(0.25));
Assert.Equal(0.25, target.Opacity);
clock.Step(TimeSpan.FromSeconds(0.3));
Assert.Equal(0.3, target.Opacity);
animation.PlaybackDirection = PlaybackDirection.Reverse;
clock.Step(TimeSpan.FromSeconds(0.3));
Assert.Equal(0.3, target.Opacity);
clock.Step(TimeSpan.FromSeconds(0.35));
Assert.Equal(0.25, target.Opacity);
}
}
[Fact]
public void Reversing_Direction_Past_Initial_Point_Clamps_To_Initial_Point()
{
using (Start())
{
var keyframe1 = new KeyFrame()
{
Setters = { new Setter(Visual.OpacityProperty, 0d), },
KeyTime = TimeSpan.FromSeconds(0)
};
var keyframe2 = new KeyFrame()
{
Setters = { new Setter(Visual.OpacityProperty, 1d), },
KeyTime = TimeSpan.FromSeconds(1)
};
var animation = new Avalonia.Animation.Animation()
{
Duration = TimeSpan.FromSeconds(1),
Delay = TimeSpan.FromSeconds(0.5),
Children = { keyframe1, keyframe2 },
PlaybackDirection = PlaybackDirection.Normal
};
Border target;
var clock = new TestClock();
var root = new TestRoot
{
Clock = clock,
Styles = { new Style(x => x.OfType<Border>()) { Animations = { animation }, } },
Child = target = new Border { Background = Brushes.Red, }
};
root.Measure(Size.Infinity);
root.Arrange(new Rect(root.DesiredSize));
clock.Step(TimeSpan.FromSeconds(0));
Assert.Equal(1, target.Opacity);
clock.Step(TimeSpan.FromSeconds(0.5));
Assert.Equal(1, target.Opacity);
clock.Step(TimeSpan.FromSeconds(1));
Assert.Equal(0.5, target.Opacity);
animation.PlaybackDirection = PlaybackDirection.Reverse;
clock.Step(TimeSpan.FromSeconds(1));
Assert.Equal(0.5, target.Opacity);
clock.Step(TimeSpan.FromSeconds(1.2));
Assert.Equal(0.3, target.Opacity);
clock.Step(TimeSpan.FromSeconds(1.5));
Assert.Equal(0, target.Opacity);
clock.Step(TimeSpan.FromSeconds(2));
Assert.Equal(0, target.Opacity);
clock.Step(TimeSpan.FromSeconds(3));
Assert.Equal(0, target.Opacity);
animation.PlaybackDirection = PlaybackDirection.Normal;
clock.Step(TimeSpan.FromSeconds(3));
Assert.Equal(0, target.Opacity);
clock.Step(TimeSpan.FromSeconds(3.5));
Assert.Equal(0, target.Opacity);
clock.Step(TimeSpan.FromSeconds(4));
Assert.Equal(0.5, target.Opacity);
clock.Step(TimeSpan.FromSeconds(4.5));
Assert.Equal(1, target.Opacity);
}
}
[Fact]
public void DelayBetweenIterations_Behind_Initial_Point_Is_In_Front_Of_Iterations()
{
using (Start())
{
var keyframe1 = new KeyFrame()
{
Setters = { new Setter(Visual.OpacityProperty, 0d), },
KeyTime = TimeSpan.FromSeconds(0)
};
var keyframe2 = new KeyFrame()
{
Setters = { new Setter(Visual.OpacityProperty, 1d), },
KeyTime = TimeSpan.FromSeconds(1)
};
var animation = new Avalonia.Animation.Animation()
{
Duration = TimeSpan.FromSeconds(1),
DelayBetweenIterations = TimeSpan.FromSeconds(0.5),
Children = { keyframe1, keyframe2 },
IterationCount = IterationCount.Infinite,
PlaybackDirection = PlaybackDirection.Normal
};
Border target;
var clock = new TestClock();
var root = new TestRoot
{
Clock = clock,
Styles = { new Style(x => x.OfType<Border>()) { Animations = { animation }, } },
Child = target = new Border { Background = Brushes.Red, }
};
root.Measure(Size.Infinity);
root.Arrange(new Rect(root.DesiredSize));
clock.Step(TimeSpan.FromSeconds(0));
Assert.Equal(0, target.Opacity);
clock.Step(TimeSpan.FromSeconds(0.1));
Assert.Equal(0.1, target.Opacity);
animation.PlaybackDirection = PlaybackDirection.Reverse;
clock.Step(TimeSpan.FromSeconds(0.1));
Assert.Equal(0.1, target.Opacity);
clock.Step(TimeSpan.FromSeconds(0.2));
Assert.Equal(0, target.Opacity);
clock.Step(TimeSpan.FromSeconds(0.2 + 0.1));
Assert.Equal(0.9, target.Opacity);
clock.Step(TimeSpan.FromSeconds(0.2 + 0.9));
Assert.Equal(0.1, target.Opacity, 0.000001);
clock.Step(TimeSpan.FromSeconds(0.2 + 1));
Assert.Equal(0, target.Opacity);
clock.Step(TimeSpan.FromSeconds(0.2 + 1 + 0.25));
Assert.Equal(0, target.Opacity);
clock.Step(TimeSpan.FromSeconds(0.2 + 1 + 0.499));
Assert.Equal(0, target.Opacity);
clock.Step(TimeSpan.FromSeconds(0.2 + 1 + 0.5));
Assert.Equal(1, target.Opacity);
clock.Step(TimeSpan.FromSeconds(0.2 + 1 + 0.5 + 0.1));
Assert.Equal(0.9, target.Opacity);
}
}
[Fact]
public void Zero_Duration_With_DelayBetweenIterations_Should_Finish_Animation()
{
using (Start())
{
var keyframe1 = new KeyFrame
{
Setters = { new Setter(Visual.OpacityProperty, 0d) }, Cue = new Cue(0d)
};
var keyframe2 = new KeyFrame
{
Setters = { new Setter(Visual.OpacityProperty, 1d) }, Cue = new Cue(1d)
};
var animation = new Avalonia.Animation.Animation
{
Duration = TimeSpan.Zero,
DelayBetweenIterations = TimeSpan.FromSeconds(0.5),
IterationCount = IterationCount.Infinite,
Children = { keyframe1, keyframe2 }
};
Border target;
var clock = new TestClock();
var root = new TestRoot
{
Clock = clock,
Styles = { new Style(x => x.OfType<Border>()) { Animations = { animation } } },
Child = target = new Border { Background = Brushes.Red }
};
root.Measure(Size.Infinity);
root.Arrange(new Rect(root.DesiredSize));
clock.Step(TimeSpan.FromSeconds(0));
Assert.False(double.IsNaN(target.Opacity));
Assert.Equal(1, target.Opacity);
Assert.False(target.IsAnimating(Visual.OpacityProperty));
clock.Step(TimeSpan.FromSeconds(0.5));
Assert.False(double.IsNaN(target.Opacity));
}
}
[Fact]
public void Setting_Duration_To_Zero_With_DelayBetweenIterations_Should_Finish_Animation()
{
using (Start())
{
var keyframe1 = new KeyFrame
{
Setters = { new Setter(Visual.OpacityProperty, 0d) }, KeyTime = TimeSpan.FromSeconds(0)
};
var keyframe2 = new KeyFrame
{
Setters = { new Setter(Visual.OpacityProperty, 1d) }, KeyTime = TimeSpan.FromSeconds(1)
};
var animation = new Avalonia.Animation.Animation
{
Duration = TimeSpan.FromSeconds(1),
DelayBetweenIterations = TimeSpan.FromSeconds(0.5),
IterationCount = IterationCount.Infinite,
Children = { keyframe1, keyframe2 }
};
Border target;
var clock = new TestClock();
var root = new TestRoot
{
Clock = clock,
Styles = { new Style(x => x.OfType<Border>()) { Animations = { animation } } },
Child = target = new Border { Background = Brushes.Red }
};
root.Measure(Size.Infinity);
root.Arrange(new Rect(root.DesiredSize));
clock.Step(TimeSpan.FromSeconds(0));
clock.Step(TimeSpan.FromSeconds(0.5));
Assert.Equal(0.5, target.Opacity);
animation.Duration = TimeSpan.Zero;
clock.Step(TimeSpan.FromSeconds(1));
Assert.False(double.IsNaN(target.Opacity));
Assert.Equal(1, target.Opacity);
Assert.False(target.IsAnimating(Visual.OpacityProperty));
}
}
[Fact]
public void Reversing_Direction_Past_Initial_Point_Parks_Animation_Until_Resumed()
{
var keyframe1 = new KeyFrame
{
Setters = { new Setter(Visual.OpacityProperty, 0d) }, KeyTime = TimeSpan.FromSeconds(0)
};
var keyframe2 = new KeyFrame
{
Setters = { new Setter(Visual.OpacityProperty, 1d) }, KeyTime = TimeSpan.FromSeconds(1)
};
var animation = new Avalonia.Animation.Animation
{
Duration = TimeSpan.FromSeconds(1),
Children = { keyframe1, keyframe2 }
};
var target = new Border { Background = Brushes.Red };
var clock = new TestClock();
var animationRun = animation.RunAsync(target, clock, TestContext.Current.CancellationToken);
clock.Step(TimeSpan.FromSeconds(0));
clock.Step(TimeSpan.FromSeconds(0.5));
Assert.Equal(0.5, target.Opacity);
// Reverse the animation so that it runs back to its initial point, where a limited
// number of iterations clamps it. The animation is parked rather than finished: it
// stays subscribed, and so RunAsync stays pending, because playback can still be
// resumed by changing the direction back.
animation.PlaybackDirection = PlaybackDirection.Reverse;
clock.Step(TimeSpan.FromSeconds(0.5));
clock.Step(TimeSpan.FromSeconds(1));
clock.Step(TimeSpan.FromSeconds(10));
Assert.Equal(0, target.Opacity);
Assert.False(animationRun.IsCompleted);
// Resuming from the parked state runs the animation to its end and completes it.
animation.PlaybackDirection = PlaybackDirection.Normal;
clock.Step(TimeSpan.FromSeconds(10));
clock.Step(TimeSpan.FromSeconds(10.5));
Assert.Equal(0.5, target.Opacity);
clock.Step(TimeSpan.FromSeconds(11));
Assert.Equal(1, target.Opacity);
Assert.True(animationRun.IsCompleted);
}
[Fact]
public void Changing_SpeedRatio_Every_Frame_Should_Not_Lose_Playback_Time()
{
using (Start())
{
var keyframe1 = new KeyFrame
{
Setters = { new Setter(Visual.OpacityProperty, 0d) }, KeyTime = TimeSpan.FromSeconds(0)
};
var keyframe2 = new KeyFrame
{
Setters = { new Setter(Visual.OpacityProperty, 1d) }, KeyTime = TimeSpan.FromSeconds(10)
};
var animation = new Avalonia.Animation.Animation
{
Duration = TimeSpan.FromSeconds(10),
IterationCount = IterationCount.Infinite,
Children = { keyframe1, keyframe2 }
};
Border target;
var clock = new TestClock();
var root = new TestRoot
{
Clock = clock,
Styles = { new Style(x => x.OfType<Border>()) { Animations = { animation } } },
Child = target = new Border { Background = Brushes.Red }
};
root.Measure(Size.Infinity);
root.Arrange(new Rect(root.DesiredSize));
clock.Step(TimeSpan.FromSeconds(0));
// 60 frames of 1/60th of a second: one second of playback out of a ten second
// animation. SpeedRatio stays effectively 1 but is written on every frame, as it
// would be when bound to a slider being dragged. Re-anchoring playback time on
// each change must not accumulate rounding errors.
var frame = TimeSpan.FromTicks(TimeSpan.TicksPerSecond / 60);
var time = TimeSpan.Zero;
for (var i = 0; i < 60; i++)
{
time += frame;
animation.SpeedRatio = 1d + i * 1e-9;
clock.Step(time);
}
Assert.Equal(0.1, target.Opacity, 0.001);
}
}
[Fact]
public void Reversing_Infinite_Animation_Past_Initial_Point_Replays_Previous_Iterations()
{
using (Start())
{
var keyframe1 = new KeyFrame
{
Setters = { new Setter(Visual.OpacityProperty, 0d) }, KeyTime = TimeSpan.FromSeconds(0)
};
var keyframe2 = new KeyFrame
{
Setters = { new Setter(Visual.OpacityProperty, 1d) }, KeyTime = TimeSpan.FromSeconds(1)
};
var animation = new Avalonia.Animation.Animation
{
Duration = TimeSpan.FromSeconds(1),
Delay = TimeSpan.FromSeconds(0.5),
IterationCount = IterationCount.Infinite,
FillMode = FillMode.Both,
Children = { keyframe1, keyframe2 }
};
Border target;
var clock = new TestClock();
var root = new TestRoot
{
Clock = clock,
Styles = { new Style(x => x.OfType<Border>()) { Animations = { animation } } },
Child = target = new Border { Background = Brushes.Red }
};
root.Measure(Size.Infinity);
root.Arrange(new Rect(root.DesiredSize));
clock.Step(TimeSpan.FromSeconds(0));
Assert.Equal(0, target.Opacity);
// Initial delay, then half of the first iteration.
clock.Step(TimeSpan.FromSeconds(1));
Assert.Equal(0.5, target.Opacity);
animation.PlaybackDirection = PlaybackDirection.Reverse;
clock.Step(TimeSpan.FromSeconds(1));
Assert.Equal(0.5, target.Opacity);
clock.Step(TimeSpan.FromSeconds(1.25));
Assert.Equal(0.25, target.Opacity);
// Back at the initial point.
clock.Step(TimeSpan.FromSeconds(1.5));
Assert.Equal(0, target.Opacity);
// The initial delay is mirrored around the initial point, so crossing it while
// reversed takes twice the Delay. Note that a finite animation behaves
// differently: it clamps at the initial point instead, and only ever spends
// Delay there. See Reversing_Direction_Past_Initial_Point_Clamps_To_Initial_Point.
clock.Step(TimeSpan.FromSeconds(2));
Assert.Equal(0, target.Opacity);
clock.Step(TimeSpan.FromSeconds(2.5));
Assert.Equal(0, target.Opacity);
// Past the delay, the iteration before the initial one plays backwards.
clock.Step(TimeSpan.FromSeconds(2.75));
Assert.Equal(0.75, target.Opacity);
clock.Step(TimeSpan.FromSeconds(3));
Assert.Equal(0.5, target.Opacity);
clock.Step(TimeSpan.FromSeconds(3.5));
Assert.Equal(1, target.Opacity);
}
}
private static IDisposable Start()
{
var clock = new MockGlobalClock();

Loading…
Cancel
Save