diff --git a/samples/RenderDemo/MainWindow.xaml b/samples/RenderDemo/MainWindow.xaml index e1dbd20b074..2d90b4bc72b 100644 --- a/samples/RenderDemo/MainWindow.xaml +++ b/samples/RenderDemo/MainWindow.xaml @@ -49,6 +49,9 @@ + + + diff --git a/samples/RenderDemo/Pages/AnimationSpeedPage.xaml b/samples/RenderDemo/Pages/AnimationSpeedPage.xaml new file mode 100644 index 00000000000..08e88e53e7a --- /dev/null +++ b/samples/RenderDemo/Pages/AnimationSpeedPage.xaml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/RenderDemo/Pages/AnimationSpeedPage.xaml.cs b/samples/RenderDemo/Pages/AnimationSpeedPage.xaml.cs new file mode 100644 index 00000000000..00dce1d0558 --- /dev/null +++ b/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); + } +} diff --git a/samples/RenderDemo/ViewModels/AnimationSpeedPageViewModel.cs b/samples/RenderDemo/ViewModels/AnimationSpeedPageViewModel.cs new file mode 100644 index 00000000000..3fd45639dab --- /dev/null +++ b/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; + } +} diff --git a/src/Avalonia.Base/Animation/AnimationInstance`1.cs b/src/Avalonia.Base/Animation/AnimationInstance`1.cs index ec27978939b..e30d3d5ab56 100644 --- a/src/Avalonia.Base/Animation/AnimationInstance`1.cs +++ b/src/Avalonia.Base/Animation/AnimationInstance`1.cs @@ -12,68 +12,102 @@ namespace Avalonia.Animation /// internal class AnimationInstance : SingleSubscriberObservableBase { - 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 _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 _interpolator; private IDisposable? _timerSub; + private EventHandler? _propertyChangedDelegate; + private readonly IClock _baseClock; private IClock? _clock; - private EventHandler? _propertyChangedDelegate; + + private Easings.Easing? _easeFunc; + private readonly Func _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 long _animTimePrev; + + private PlaybackDirection _playbackDirection; + private PlaybackDirection _playbackDirectionPrev; + private double _speedRatio; + private double _speedRatioPrev; + private bool _timeMovesBackwards; + + private TimeSpan _timeOfLastChange; + private long _animTimeOfLastChange; + + /// + /// 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. + /// + /// + /// Value to use here can be found with TimeSpan.FromMilliseconds(x).Ticks + /// + private const long PRECISION_IN_TICKS = 10_000; public AnimationInstance(Animation animation, Animatable control, Animator animator, IClock baseClock, Action? OnComplete, Func Interpolator) { + _lastInterpValue = default!; _animator = animator; _animation = animation; _targetControl = control; _onCompleteAction = OnComplete; _interpolator = Interpolator; _baseClock = baseClock; - _lastInterpValue = default!; - _firstKFValue = default!; + _initialKFValue = default!; _neutralValue = default!; + _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; } @@ -81,7 +115,8 @@ private void FetchProperties() protected override void Unsubscribed() { // Animation may have been stopped before it has finished. - ApplyFinalFill(); + if (CanApplyFinalFill()) + ApplyFinalFill(_lastInterpValue); _targetControl.PropertyChanged -= _propertyChangedDelegate; _timerSub?.Dispose(); @@ -109,121 +144,278 @@ public void Step(TimeSpan frameTick) } } - private void ApplyFinalFill() + private bool CanApplyFinalFill() + { + return _fillMode is FillMode.Forward or FillMode.Both; + } + + 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 (!_gotFirstKFValue) + if (_isFirstFrame) { - _firstKFValue = (T)_animator.First().Value!; - _gotFirstKFValue = true; + // In first frame of animation we determine the expected direction of time. + _timeMovesBackwards = + _animation.PlaybackDirection == PlaybackDirection.Reverse || + _animation.PlaybackDirection == PlaybackDirection.AlternateReverse; + + if (_timeMovesBackwards) + { + if (_animator.Last().Value is T last) + _initialKFValue = last; + } + else + { + if (_animator.First().Value is T first) + _initialKFValue = first; + } + + _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; + } + + private void ApplyLimitedClamp(ref long animTime) + { + if (_timeMovesBackwards) + { + if (animTime > 0) + animTime = 0; + } + else + { + 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(); + + // Combine SpeedRatio and PlaybackDirection into a single signed speed ratio. + double speedRatio = isAnimTimeGoingBackwards ? -_speedRatio : _speedRatio; - // 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) + // Calculate animation time. That's time that has passed inside + // the animation since its beginning. + var timeSinceLastChange = time - _timeOfLastChange; + var animTimeSinceLastChange = (long)(timeSinceLastChange.Ticks / PRECISION_IN_TICKS * speedRatio); + var animTime = _animTimeOfLastChange + animTimeSinceLastChange; + + if (_iterationCount.HasValue) { - DoDelay(); - return; + // Make sure animation time is inside a valid interval. + ApplyLimitedClamp(ref animTime); } - // Calculate timebases. - var iterationTime = iterDuration + iterDelay; - var opsTime = indexTime - initDelay; - var playbackTime = opsTime % iterationTime; + _timePrev = time; + _animTimePrev = animTime; - _currentIteration = (ulong)(opsTime / iterationTime); + // 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; - // 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) + if (_initialDelay > TimeSpan.Zero) { - var easedTime = _easeFunc!.Ease(_playbackReversed ? 0.0 : 1.0); - _lastInterpValue = _interpolator(easedTime, _neutralValue); - DoComplete(); + bool isCurrentlyInsideDelay = ApplyInitialDelay(ref animTimePositive); + if (isCurrentlyInsideDelay) + { + DoInitialDelay(); + return; + } + + _isInFirstInitialDelay = false; + } + + var iterDuration = _duration.Ticks / PRECISION_IN_TICKS; + var iterDelay = _iterationDelay.Ticks / PRECISION_IN_TICKS; + var iterDurationTotal = iterDuration + iterDelay; + + if (iterDurationTotal <= 0) + { + 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() diff --git a/tests/Avalonia.Base.UnitTests/Animation/AnimatableTests.cs b/tests/Avalonia.Base.UnitTests/Animation/AnimatableTests.cs index a76b5a9c3fb..cd00635e1fe 100644 --- a/tests/Avalonia.Base.UnitTests/Animation/AnimatableTests.cs +++ b/tests/Avalonia.Base.UnitTests/Animation/AnimatableTests.cs @@ -654,6 +654,262 @@ public void Zero_Duration_Should_Finish_Animation_With_Infinite_Iteration() } } + [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()) { 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()) { 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()) { 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()) { 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); + } + } + private static IDisposable Start() { var clock = new MockGlobalClock();