diff --git a/src/Avalonia.Base/Animation/AnimationInstance`1.cs b/src/Avalonia.Base/Animation/AnimationInstance`1.cs index 3be646d66c..6358dd2c6b 100644 --- a/src/Avalonia.Base/Animation/AnimationInstance`1.cs +++ b/src/Avalonia.Base/Animation/AnimationInstance`1.cs @@ -7,7 +7,7 @@ using Avalonia.Data; namespace Avalonia.Animation { /// - /// Handles interpolation and time-related functions + /// Handles interpolation and time-related functions /// for keyframe animations. /// internal class AnimationInstance : SingleSubscriberObservableBase @@ -35,6 +35,8 @@ namespace Avalonia.Animation private readonly IClock _baseClock; private IClock? _clock; private EventHandler? _propertyChangedDelegate; + private EventHandler? _visibilityChangedHandler; + private EventHandler? _detachedHandler; public AnimationInstance(Animation animation, Animatable control, Animator animator, IClock baseClock, Action? OnComplete, Func Interpolator) { @@ -80,11 +82,34 @@ namespace Avalonia.Animation protected override void Unsubscribed() { + // Guard against reentrancy: DoComplete() can trigger Unsubscribed() via the + // _onCompleteAction disposal chain, and then PublishCompleted() calls it again. + var timerSub = _timerSub; + _timerSub = null; + if (timerSub is null) + return; + // Animation may have been stopped before it has finished. ApplyFinalFill(); _targetControl.PropertyChanged -= _propertyChangedDelegate; - _timerSub?.Dispose(); + timerSub.Dispose(); + + if (_targetControl is Visual visual) + { + if (_visibilityChangedHandler is not null) + { + visual.IsEffectivelyVisibleChanged -= _visibilityChangedHandler; + _visibilityChangedHandler = null; + } + + if (_detachedHandler is not null) + { + visual.DetachedFromVisualTree -= _detachedHandler; + _detachedHandler = null; + } + } + _clock!.PlayState = PlayState.Stop; } @@ -92,6 +117,35 @@ namespace Avalonia.Animation { _clock = new Clock(_baseClock); _timerSub = _clock.Subscribe(Step); + + if (_targetControl is Visual visual) + { + _visibilityChangedHandler = (_, _) => + { + if (_clock is null || _clock.PlayState == PlayState.Stop) + return; + if (visual.IsEffectivelyVisible) + { + if (_clock.PlayState == PlayState.Pause) + _clock.PlayState = PlayState.Run; + } + else + { + if (_clock.PlayState == PlayState.Run) + _clock.PlayState = PlayState.Pause; + } + }; + visual.IsEffectivelyVisibleChanged += _visibilityChangedHandler; + + // If already invisible when animation starts, pause immediately. + if (!visual.IsEffectivelyVisible) + _clock.PlayState = PlayState.Pause; + + // Stop and dispose the animation when detached from the visual tree. + _detachedHandler = (_, _) => DoComplete(); + visual.DetachedFromVisualTree += _detachedHandler; + } + _propertyChangedDelegate ??= ControlPropertyChanged; _targetControl.PropertyChanged += _propertyChangedDelegate; UpdateNeutralValue(); @@ -101,7 +155,10 @@ namespace Avalonia.Animation { try { - InternalStep(frameTick); + if (_clock?.PlayState == PlayState.Pause) + return; + + InternalStep(frameTick); } catch (Exception e) { diff --git a/src/Avalonia.Base/Visual.cs b/src/Avalonia.Base/Visual.cs index 95d55754d3..4bd359fa78 100644 --- a/src/Avalonia.Base/Visual.cs +++ b/src/Avalonia.Base/Visual.cs @@ -208,6 +208,11 @@ namespace Avalonia /// public bool IsEffectivelyVisible { get; private set; } = true; + /// + /// Raised when changes. + /// + internal event EventHandler? IsEffectivelyVisibleChanged; + /// /// Updates the property based on the parent's /// . @@ -221,6 +226,7 @@ namespace Avalonia return; IsEffectivelyVisible = isEffectivelyVisible; + IsEffectivelyVisibleChanged?.Invoke(this, EventArgs.Empty); // PERF-SENSITIVE: This is called on entire hierarchy and using foreach or LINQ // will cause extra allocations and overhead. diff --git a/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs b/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs index 4a3d67d0c7..752c1b166b 100644 --- a/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs +++ b/tests/Avalonia.Base.UnitTests/Animation/AnimationIterationTests.cs @@ -93,6 +93,256 @@ namespace Avalonia.Base.UnitTests.Animation Assert.True(animationRun.Status == TaskStatus.RanToCompletion); Assert.Equal(border.Width, 100d); } + + [Fact] + public void Pause_Animation_When_IsEffectivelyVisible_Is_False() + { + var keyframe1 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 200d), }, Cue = new Cue(1d) + }; + var keyframe2 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 150d), }, Cue = new Cue(0.5d) + }; + + var keyframe3 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 100d), }, Cue = new Cue(0d) + }; + + var animation = new Animation() + { + Duration = TimeSpan.FromSeconds(3), + Delay = TimeSpan.FromSeconds(3), + DelayBetweenIterations = TimeSpan.FromSeconds(3), + IterationCount = new IterationCount(2), + Children = { keyframe1, keyframe2, keyframe3 } + }; + + var border = new Border() { Height = 100d, Width = 100d }; + + var clock = new TestClock(); + var animationRun = animation.RunAsync(border, clock, TestContext.Current.CancellationToken); + + border.Measure(Size.Infinity); + border.Arrange(new Rect(border.DesiredSize)); + + clock.Step(TimeSpan.Zero); + + clock.Step(TimeSpan.FromSeconds(0)); + Assert.Equal(100d, border.Width); + + // Hide the border — this should pause the animation clock. + border.IsVisible = false; + + clock.Step(TimeSpan.FromSeconds(4.5)); + + // Width should not change while invisible (animation is paused). + Assert.Equal(100d, border.Width); + + // Show the border — animation resumes from where it left off. + border.IsVisible = true; + + // The pause absorbed 4.5s of wall-clock time, so internal time = wall - 4.5. + // To reach internal time 6s (end of iteration 1, cue 1.0 -> width 200): + // wall = 4.5 + 6 = 10.5 + clock.Step(TimeSpan.FromSeconds(10.5)); + Assert.Equal(200d, border.Width); + + // To complete the animation (internal time 14s triggers trailing delay of iter 2): + // wall = 4.5 + 14 = 18.5 + clock.Step(TimeSpan.FromSeconds(18.5)); + Assert.True(animationRun.Status == TaskStatus.RanToCompletion); + Assert.Equal(100d, border.Width); + } + + [Fact] + public void Pause_Animation_When_IsEffectivelyVisible_Is_False_Nested() + { + var keyframe1 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 200d), }, Cue = new Cue(1d) + }; + var keyframe2 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 150d), }, Cue = new Cue(0.5d) + }; + + var keyframe3 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 100d), }, Cue = new Cue(0d) + }; + + var animation = new Animation() + { + Duration = TimeSpan.FromSeconds(3), + Delay = TimeSpan.FromSeconds(3), + DelayBetweenIterations = TimeSpan.FromSeconds(3), + IterationCount = new IterationCount(2), + Children = { keyframe1, keyframe2, keyframe3 } + }; + + var border = new Border() { Height = 100d, Width = 100d }; + + var borderParent = new Border { Child = border }; + + var clock = new TestClock(); + var animationRun = animation.RunAsync(border, clock, TestContext.Current.CancellationToken); + + border.Measure(Size.Infinity); + border.Arrange(new Rect(border.DesiredSize)); + + clock.Step(TimeSpan.Zero); + + clock.Step(TimeSpan.FromSeconds(0)); + Assert.Equal(100d, border.Width); + + // Hide the parent — this makes border.IsEffectivelyVisible false, + // which should pause the animation clock. + borderParent.IsVisible = false; + + clock.Step(TimeSpan.FromSeconds(4.5)); + + // Width should not change while parent is invisible (animation is paused). + Assert.Equal(100d, border.Width); + + // Show the parent — animation resumes from where it left off. + borderParent.IsVisible = true; + + // The pause absorbed 4.5s of wall-clock time, so internal time = wall - 4.5. + // To reach internal time 6s (end of iteration 1, cue 1.0 -> width 200): + // wall = 4.5 + 6 = 10.5 + clock.Step(TimeSpan.FromSeconds(10.5)); + Assert.Equal(200d, border.Width); + + // To complete the animation (internal time 14s triggers trailing delay of iter 2): + // wall = 4.5 + 14 = 18.5 + clock.Step(TimeSpan.FromSeconds(18.5)); + Assert.True(animationRun.Status == TaskStatus.RanToCompletion); + Assert.Equal(100d, border.Width); + } + + [Fact] + public void Stop_And_Dispose_Animation_When_Detached_From_Visual_Tree() + { + var keyframe1 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 200d), }, Cue = new Cue(1d) + }; + var keyframe2 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 100d), }, Cue = new Cue(0d) + }; + var animation = new Animation() + { + Duration = TimeSpan.FromSeconds(10), + IterationCount = new IterationCount(1), + Children = { keyframe2, keyframe1 } + }; + var border = new Border() { Height = 100d, Width = 50d }; + var root = new TestRoot(border); + var clock = new TestClock(); + var animationRun = animation.RunAsync(border, clock, TestContext.Current.CancellationToken); + clock.Step(TimeSpan.Zero); + clock.Step(TimeSpan.FromSeconds(0)); + Assert.False(animationRun.IsCompleted); + + // Detach from visual tree + root.Child = null; + + // Animation should be completed/disposed + Assert.True(animationRun.IsCompleted); + + // Further clock ticks should not affect the border + var widthAfterDetach = border.Width; + clock.Step(TimeSpan.FromSeconds(5)); + clock.Step(TimeSpan.FromSeconds(10)); + Assert.Equal(widthAfterDetach, border.Width); + } + + [Fact] + public void Pause_Animation_When_Control_Starts_Invisible() + { + var keyframe1 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 200d), }, Cue = new Cue(1d) + }; + var keyframe2 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 100d), }, Cue = new Cue(0d) + }; + var animation = new Animation() + { + Duration = TimeSpan.FromSeconds(3), + IterationCount = new IterationCount(1), + Children = { keyframe2, keyframe1 } + }; + + var border = new Border() { Height = 100d, Width = 100d, IsVisible = false }; + var clock = new TestClock(); + var animationRun = animation.RunAsync(border, clock, TestContext.Current.CancellationToken); + + // Clock ticks while invisible should not advance the animation. + clock.Step(TimeSpan.Zero); + clock.Step(TimeSpan.FromSeconds(1)); + clock.Step(TimeSpan.FromSeconds(2)); + Assert.Equal(100d, border.Width); + Assert.False(animationRun.IsCompleted); + + // Make visible — animation starts from the beginning. + border.IsVisible = true; + + // The pause absorbed 2s of wall-clock time, so to reach internal time 3s: + // wall = 2 + 3 = 5 + clock.Step(TimeSpan.FromSeconds(5)); + Assert.True(animationRun.IsCompleted); + Assert.Equal(100d, border.Width); + } + + [Fact] + public void Animation_Plays_Correctly_After_Reattach() + { + var keyframe1 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 200d), }, Cue = new Cue(1d) + }; + var keyframe2 = new KeyFrame() + { + Setters = { new Setter(Layoutable.WidthProperty, 100d), }, Cue = new Cue(0d) + }; + var animation = new Animation() + { + Duration = TimeSpan.FromSeconds(5), + IterationCount = new IterationCount(1), + FillMode = FillMode.Forward, + Children = { keyframe2, keyframe1 } + }; + + var border = new Border() { Height = 100d, Width = 50d }; + var root = new TestRoot(border); + var clock = new TestClock(); + var animationRun = animation.RunAsync(border, clock, TestContext.Current.CancellationToken); + + clock.Step(TimeSpan.Zero); + Assert.False(animationRun.IsCompleted); + + // Detach — animation completes. + root.Child = null; + Assert.True(animationRun.IsCompleted); + + // Reattach and start a fresh animation. + root.Child = border; + var clock2 = new TestClock(); + var animationRun2 = animation.RunAsync(border, clock2, TestContext.Current.CancellationToken); + + clock2.Step(TimeSpan.Zero); + Assert.False(animationRun2.IsCompleted); + + clock2.Step(TimeSpan.FromSeconds(5)); + Assert.True(animationRun2.IsCompleted); + Assert.Equal(200d, border.Width); + } [Fact] public void Check_FillModes_Start_and_End_Values_if_Retained()